Merge branch 'develop' into release

This commit is contained in:
SabreCat
2018-03-22 18:53:40 +00:00
159 changed files with 2459 additions and 2098 deletions

View File

@@ -1,10 +1,6 @@
{
"presets": ["es2015"],
"plugins": [
"transform-object-rest-spread",
["transform-async-to-module-method", {
"module": "bluebird",
"method": "coroutine"
}]
"transform-es2015-modules-commonjs",
"syntax-object-rest-spread",
]
}

View File

@@ -4,6 +4,7 @@ node_modules/**
.bower-registry/**
website/client-old/**
website/client/**
website/client/store/**
website/views/**
website/build/**
dist/**

2
.nvmrc
View File

@@ -1 +1 @@
6
8

View File

@@ -1,6 +1,6 @@
language: node_js
node_js:
- '6'
- '8'
services:
- mongodb
cache:
@@ -8,8 +8,6 @@ cache:
- 'node_modules'
addons:
chrome: stable
before_install:
- npm install -g npm@5
before_script:
- npm run test:build
- cp config.json.example config.json

View File

@@ -1,4 +1,4 @@
FROM node:boron
FROM node:8
# Upgrade NPM to v5 (Yarn is needed because of this bug https://github.com/npm/npm/issues/16807)
# The used solution is suggested here https://github.com/npm/npm/issues/16807#issuecomment-313591975

View File

@@ -1,4 +1,4 @@
FROM node:boron
FROM node:8
ENV ADMIN_EMAIL admin@habitica.com
ENV AMAZON_PAYMENTS_CLIENT_ID amzn1.application-oa2-client.68ed9e6904ef438fbc1bf86bf494056e

View File

@@ -2,7 +2,6 @@ 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';
@@ -45,7 +44,7 @@ export function kill (proc) {
* before failing.
*/
export function awaitPort (port, max = 60) {
return new Bluebird((rej, res) => {
return new Promise((rej, res) => {
let socket;
let timeout;
let interval;

View File

@@ -16,7 +16,6 @@
const authorName = 'Blade';
const authorUuid = '75f270e8-c5db-4722-a5e6-a83f1b23f76b';
global.Promise = require('bluebird');
const MongoClient = require('mongodb').MongoClient;
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');

View File

@@ -11,7 +11,6 @@
* pm'ed each user asking if they would like their tasks reset to the previous day
***************************************/
global.Promise = require('bluebird');
const MongoClient = require('mongodb').MongoClient;
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');

View File

@@ -12,7 +12,6 @@
* from an object to a number, hence this migration.
***************************************/
global.Promise = require('bluebird');
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');
const Timer = require('./utils/timer');

View File

@@ -9,7 +9,6 @@
* and transfers a group's progress to it
***************************************/
global.Promise = require('bluebird');
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');
const Timer = require('./utils/timer');

View File

@@ -12,7 +12,6 @@
* <userid>@example.com
***************************************/
global.Promise = require('bluebird');
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');
const Timer = require('./utils/timer');

View File

@@ -9,7 +9,6 @@
* they support a type and options and label
* ***************************************/
global.Promise = require('bluebird');
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');
const Timer = require('./utils/timer');

View File

@@ -12,7 +12,6 @@
* message into the chat for affected parties.
***************************************/
global.Promise = require('bluebird');
const uuid = require('uuid');
const TaskQueue = require('cwait').TaskQueue;
const logger = require('./utils/logger');

View File

@@ -0,0 +1,88 @@
var migrationName = '20171211_sanitize_emails.js';
var authorName = 'Julius'; // in case script author needs to know when their ...
var authorUuid = 'dd16c270-1d6d-44bd-b4f9-737342e79be6'; //... own data is done
/*
User creation saves email as lowercase, but updating an email did not.
Run this script to ensure all lowercased emails in db AFTER fix for updating emails is implemented.
This will fix inconsistent querying for an email when attempting to password reset.
*/
var monk = require('monk');
var connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
var dbUsers = monk(connectionString).get('users', { castIds: false });
function processUsers(lastId) {
var query = {
'auth.local.email': /[A-Z]/
};
if (lastId) {
query._id = {
$gt: lastId
}
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 250,
fields: [ // specify fields we are interested in to limit retrieved data (empty if we're not reading data)
'auth.local.email'
],
})
.then(updateUsers)
.catch(function (err) {
console.log(err);
return exiting(1, 'ERROR! ' + err);
});
}
var progressCount = 1000;
var count = 0;
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users found and modified.');
displayData();
return;
}
var userPromises = users.map(updateUser);
var lastUser = users[users.length - 1];
return Promise.all(userPromises)
.then(function () {
processUsers(lastUser._id);
});
}
function updateUser (user) {
count++;
var push;
var set = {
'auth.local.email': user.auth.local.email.toLowerCase()
};
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);
}
module.exports = processUsers;

View File

@@ -1,4 +1,6 @@
If you need to use a migration from this folder, move it to /migrations.
Note that /migrations files (excluding /archive) are linted, so to pass test you'll have to make sure
that the file is written correctly.
that the file is written correctly.
They might also be using some old deps that we don't use anymore like Bluebird, mongoskin, ...

View File

@@ -1,5 +1,3 @@
import Bluebird from 'Bluebird';
import { model as Challenges } from '../../website/server/models/challenge';
import { model as User } from '../../website/server/models/user';
@@ -17,10 +15,10 @@ async function syncChallengeToMembers (challenges) {
promises.push(user.save());
});
return Bluebird.all(promises);
return Promise.all(promises);
});
return await Bluebird.all(challengSyncPromises);
return await Promise.all(challengSyncPromises);
}
async function syncChallenges (lastChallengeDate) {

View File

@@ -1,5 +1,3 @@
import Bluebird from 'bluebird';
import { model as Group } from '../../website/server/models/group';
import { model as User } from '../../website/server/models/user';
@@ -16,7 +14,7 @@ async function createGroup (name, privacy, type, leaderId) {
group.leader = user._id;
user.guilds.push(group._id);
return Bluebird.all([group.save(), user.save()]);
return Promise.all([group.save(), user.save()]);
}
module.exports = async function groupCreator () {

View File

@@ -3,7 +3,6 @@
/*
* This migration will find users with unlimited subscriptions who are also eligible for Jackalope mounts, and award them
*/
import Bluebird from 'bluebird';
import { model as Group } from '../../website/server/models/group';
import { model as User } from '../../website/server/models/user';
@@ -38,7 +37,7 @@ async function handOutJackalopes () {
cursor.on('close', async () => {
console.log('done');
return await Bluebird.all(promises);
return await Promise.all(promises);
});
}

View File

@@ -9,8 +9,6 @@ let authorUuid = ''; // ... own data is done
* subscription to all members
*/
import Bluebird from 'bluebird';
import { model as Group } from '../../website/server/models/group';
import * as payments from '../../website/server/libs/payments';
@@ -28,7 +26,7 @@ async function updateGroupsWithGroupPlans () {
});
cursor.on('close', async () => {
return await Bluebird.all(promises);
return await Promise.all(promises);
});
}

View File

@@ -1,14 +1,14 @@
require('babel-register');
require('babel-polyfill');
// This file must use ES5, everything required can be in ES6
function setUpServer () {
const nconf = require('nconf'); // eslint-disable-line global-require, no-unused-vars
const mongoose = require('mongoose'); // eslint-disable-line global-require, no-unused-vars
const Bluebird = require('bluebird'); // eslint-disable-line global-require, no-unused-vars
const setupNconf = require('../website/server/libs/setupNconf'); // eslint-disable-line global-require
setupNconf();
// We require src/server and npt src/index because
// 1. nconf is already setup
// 2. we don't need clustering

View File

@@ -1,4 +1,3 @@
let Bluebird = require('bluebird');
let request = require('superagent');
let last = require('lodash/last');
let AWS = require('aws-sdk');
@@ -74,7 +73,7 @@ function uploadToS3 (start, end, filesUrls) {
});
console.log(promises.length);
return Bluebird.all(promises)
return Promise.all(promises)
.then(() => {
currentIndex += 50;
uploadToS3(currentIndex, currentIndex + 50, filesUrls);

2152
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"amplitude": "^3.5.0",
"apidoc": "^0.17.5",
"autoprefixer": "^8.1.0",
"aws-sdk": "^2.209.0",
"aws-sdk": "^2.211.0",
"axios": "^0.18.0",
"axios-progress-bar": "^1.1.8",
"babel-core": "^6.0.0",
@@ -18,7 +18,7 @@
"babel-loader": "^7.1.4",
"babel-plugin-syntax-async-functions": "^6.13.0",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-async-to-module-method": "^6.8.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
"babel-plugin-transform-object-rest-spread": "^6.16.0",
"babel-plugin-transform-regenerator": "^6.16.1",
"babel-polyfill": "^6.6.1",
@@ -26,7 +26,6 @@
"babel-register": "^6.6.0",
"babel-runtime": "^6.11.6",
"bcrypt": "^1.0.2",
"bluebird": "^3.3.5",
"body-parser": "^1.15.0",
"bootstrap": "^4.0.0",
"bootstrap-vue": "^2.0.0-rc.2",
@@ -34,7 +33,7 @@
"cookie-session": "^1.2.0",
"coupon-code": "^0.4.5",
"cross-env": "^5.1.4",
"css-loader": "^0.28.0",
"css-loader": "^0.28.11",
"csv-stringify": "^2.0.4",
"cwait": "^1.1.1",
"domain-middleware": "~0.1.0",
@@ -51,7 +50,7 @@
"gulp.spritesmith": "^6.9.0",
"habitica-markdown": "^1.3.0",
"hellojs": "^1.15.1",
"html-webpack-plugin": "^2.8.1",
"html-webpack-plugin": "^3.0.0",
"image-size": "^0.6.2",
"in-app-purchase": "^1.8.9",
"intro.js": "^2.6.0",
@@ -93,7 +92,7 @@
"svgo": "^1.0.5",
"svgo-loader": "^2.1.0",
"universal-analytics": "^0.4.16",
"url-loader": "^0.6.2",
"url-loader": "^1.0.0",
"useragent": "^2.1.9",
"uuid": "^3.0.1",
"validator": "^9.4.1",
@@ -114,8 +113,8 @@
},
"private": true,
"engines": {
"node": "^6.9.1",
"npm": "^5.0.0"
"node": "^8.9.4",
"npm": "^5.6.0"
},
"scripts": {
"lint": "eslint --ext .js,.vue .",
@@ -142,7 +141,9 @@
"apidoc": "gulp apidoc"
},
"devDependencies": {
"@vue/test-utils": "^1.0.0-beta.12",
"babel-plugin-istanbul": "^4.1.6",
"babel-plugin-syntax-object-rest-spread": "^6.13.0",
"chai": "^4.1.2",
"chai-as-promised": "^7.1.1",
"chalk": "^2.3.2",
@@ -150,15 +151,15 @@
"connect-history-api-fallback": "^1.1.0",
"coveralls": "^3.0.0",
"cross-spawn": "^6.0.5",
"eslint": "^4.18.2",
"eslint": "^4.19.0",
"eslint-config-habitrpg": "^4.0.0",
"eslint-friendly-formatter": "^3.0.0",
"eslint-loader": "^1.3.0",
"eslint-loader": "^2.0.0",
"eslint-plugin-html": "^4.0.2",
"eslint-plugin-mocha": "^4.12.1",
"eventsource-polyfill": "^0.9.6",
"expect.js": "^0.3.1",
"http-proxy-middleware": "^0.17.0",
"http-proxy-middleware": "^0.18.0",
"istanbul": "^1.1.0-alpha.1",
"karma": "^2.0.0",
"karma-babel-preprocessor": "^7.0.0",
@@ -176,11 +177,11 @@
"mocha": "^5.0.4",
"monk": "^6.0.5",
"nightwatch": "^0.9.20",
"puppeteer": "^1.1.1",
"puppeteer": "^1.2.0",
"require-again": "^2.0.0",
"selenium-server": "^3.11.0",
"sinon": "^4.4.5",
"sinon-chai": "^2.8.0",
"sinon-chai": "^3.0.0",
"sinon-stub-promise": "^4.0.0",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-dev-middleware": "^2.0.5",

View File

@@ -1,5 +1,4 @@
require('babel-register');
require('babel-polyfill');
// 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

View File

@@ -151,7 +151,10 @@ describe('GET challenges/groups/:groupId', () => {
});
officialChallenge = await generateChallenge(user, group, {
official: true,
categories: [{
name: 'habitica_official',
slug: 'habitica_official',
}],
});
challenge = await generateChallenge(user, group);

View File

@@ -193,7 +193,10 @@ describe('GET challenges/user', () => {
});
officialChallenge = await generateChallenge(user, group, {
official: true,
categories: [{
name: 'habitica_official',
slug: 'habitica_official',
}],
});
challenge = await generateChallenge(user, group);

View File

@@ -2,13 +2,12 @@ import {
generateUser,
} from '../../../../helpers/api-v3-integration.helper';
import xml2js from 'xml2js';
import Bluebird from 'bluebird';
import util from 'util';
let parseStringAsync = Bluebird.promisify(xml2js.parseString, {context: xml2js});
let parseStringAsync = util.promisify(xml2js.parseString).bind(xml2js);
describe('GET /export/userdata.xml', () => {
// TODO disabled because it randomly causes the build to fail
xit('should return a valid XML file with user data', async () => {
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'},
@@ -31,13 +30,21 @@ describe('GET /export/userdata.xml', () => {
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);
let habitIds = _.map(res.tasks.habits, '_id');
expect(habitIds).to.have.deep.members([tasks[0]._id, tasks[4]._id]);
expect(res.tasks.dailys.length).to.equal(2);
expect(res.tasks.dailys[0]._id).to.equal(tasks[1]._id);
let dailyIds = _.map(res.tasks.dailys, '_id');
expect(dailyIds).to.have.deep.members([tasks[1]._id, tasks[5]._id]);
expect(res.tasks.rewards.length).to.equal(2);
expect(res.tasks.rewards[0]._id).to.equal(tasks[2]._id);
let rewardIds = _.map(res.tasks.rewards, '_id');
expect(rewardIds).to.have.deep.members([tasks[2]._id, tasks[6]._id]);
expect(res.tasks.todos.length).to.equal(3);
expect(res.tasks.todos[1]._id).to.equal(tasks[3]._id);
let todoIds = _.map(res.tasks.todos, '_id');
expect(todoIds).to.deep.include.members([tasks[3]._id, tasks[7]._id]);
});
});

View File

@@ -201,8 +201,8 @@ describe('GET /groups', () => {
await expect(user.get('/groups?type=publicGuilds&paginate=true&page=1'))
.to.eventually.have.a.lengthOf(GUILD_PER_PAGE);
let page2 = await expect(user.get('/groups?type=publicGuilds&paginate=true&page=2'))
.to.eventually.have.a.lengthOf(1 + 3); // 1 created now, 3 by other tests
expect(page2[3].name).to.equal('guild with less members');
.to.eventually.have.a.lengthOf(1 + 4); // 1 created now, 4 by other tests
expect(page2[4].name).to.equal('guild with less members');
});
});
@@ -220,4 +220,18 @@ describe('GET /groups', () => {
await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern'))
.to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW);
});
it('returns a list of groups user has access to', async () => {
let group = await generateGroup(user, {
name: 'c++ coders',
type: 'guild',
privacy: 'public',
});
// search for 'c++ coders'
await expect(user.get('/groups?type=publicGuilds&paginate=true&page=0&search=c%2B%2B+coders'))
.to.eventually.have.lengthOf(1)
.and.to.have.nested.property('[0]')
.and.to.have.property('_id', group._id);
});
});

View File

@@ -44,12 +44,12 @@ 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 () => {
it('returns an error if 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'),
message: t('youAreAlreadyInGroup'),
});
});
@@ -262,6 +262,30 @@ describe('POST /group/:groupId/join', () => {
await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false);
});
it('does not allow user to leave a party if a quest was active and they were 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 userToInvite.update({
[`items.quests.${PET_QUEST}`]: 1,
});
await userToInvite.post(`/groups/${oldParty._id}/quests/invite/${PET_QUEST}`);
await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(true);
await user.post(`/groups/${party._id}/invite`, {
uuids: [userToInvite._id],
});
await expect(userToInvite.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('messageCannotLeaveWhileQuesting'),
});
});
it('invites joining member to active quest', async () => {
await user.update({
[`items.quests.${PET_QUEST}`]: 1,

View File

@@ -2,8 +2,8 @@ import {
createAndPopulateGroup,
translate as t,
generateUser,
sleep,
} from '../../../../helpers/api-v3-integration.helper';
import Bluebird from 'bluebird';
describe('POST /groups/:groupId/quests/accept', () => {
const PET_QUEST = 'whale';
@@ -140,7 +140,7 @@ describe('POST /groups/:groupId/quests/accept', () => {
// quest will start after everyone has accepted
await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
await Bluebird.delay(500);
await sleep(0.5);
await rejectingMember.sync();

View File

@@ -2,8 +2,8 @@ import {
createAndPopulateGroup,
translate as t,
generateUser,
sleep,
} from '../../../../helpers/api-v3-integration.helper';
import Bluebird from 'bluebird';
describe('POST /groups/:groupId/quests/force-start', () => {
const PET_QUEST = 'whale';
@@ -135,7 +135,7 @@ describe('POST /groups/:groupId/quests/force-start', () => {
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await Bluebird.delay(500);
await sleep(0.5);
await Promise.all([
partyMemberThatRejects.sync(),
@@ -161,7 +161,7 @@ describe('POST /groups/:groupId/quests/force-start', () => {
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await Bluebird.delay(500);
await sleep(0.5);
await questingGroup.sync();
@@ -184,7 +184,7 @@ describe('POST /groups/:groupId/quests/force-start', () => {
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await Bluebird.delay(500);
await sleep(0.5);
await questingGroup.sync();
@@ -201,7 +201,7 @@ describe('POST /groups/:groupId/quests/force-start', () => {
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await Bluebird.delay(500);
await sleep(0.5);
await questingGroup.sync();
@@ -222,7 +222,7 @@ describe('POST /groups/:groupId/quests/force-start', () => {
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await Bluebird.delay(500);
await sleep(0.5);
await questingGroup.sync();

View File

@@ -2,9 +2,9 @@ import {
createAndPopulateGroup,
translate as t,
generateUser,
sleep,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
import Bluebird from 'bluebird';
describe('POST /groups/:groupId/quests/reject', () => {
let questingGroup;
@@ -168,7 +168,7 @@ describe('POST /groups/:groupId/quests/reject', () => {
// quest will start after everyone has accepted or rejected
await rejectingMember.post(`/groups/${questingGroup._id}/quests/reject`);
await Bluebird.delay(500);
await sleep(0.5);
await questingGroup.sync();

View File

@@ -2,7 +2,6 @@ import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import Bluebird from 'bluebird';
describe('GET /shops/market', () => {
let user;
@@ -42,13 +41,13 @@ describe('GET /shops/market', () => {
return array;
}, []);
let results = await Bluebird.each(items, (item) => {
let results = await Promise.all(items.map((item) => {
let { purchaseType, key } = item;
return user.post(`/user/purchase/${purchaseType}/${key}`);
});
}));
expect(results.length).to.be.greaterThan(0);
results.forEach((item) => {
items.forEach((item) => {
expect(item).to.include.keys('key', 'text', 'notes', 'class', 'value', 'currency');
});
});

View File

@@ -2,8 +2,8 @@ import {
generateUser,
generateGroup,
generateChallenge,
sleep,
} from '../../../../../helpers/api-integration/v3';
import Bluebird from 'bluebird';
import { find } from 'lodash';
describe('POST /tasks/:id/score/:direction', () => {
@@ -27,7 +27,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test habit',
type: 'habit',
});
await Bluebird.delay(1000);
await sleep(1);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.habits[0];
});
@@ -65,7 +65,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test daily',
type: 'daily',
});
await Bluebird.delay(1000);
await sleep(1);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.dailys[0];
});
@@ -109,7 +109,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test todo',
type: 'todo',
});
await Bluebird.delay(1000);
await sleep(1);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.todos[0];
});
@@ -134,7 +134,7 @@ describe('POST /tasks/:id/score/:direction', () => {
text: 'test reward',
type: 'reward',
});
await Bluebird.delay(1000);
await sleep(1);
let updatedUser = await user.get('/user');
usersChallengeTaskId = updatedUser.tasksOrder.todos[0];
});

View File

@@ -11,7 +11,6 @@ import {
each,
map,
} from 'lodash';
import Bluebird from 'bluebird';
import {
sha1MakeSalt,
sha1Encrypt as sha1EncryptPassword,
@@ -104,7 +103,7 @@ describe('DELETE /user', () => {
password,
});
await Bluebird.all(map(ids, id => {
await Promise.all(map(ids, id => {
return expect(checkExistence('tasks', id)).to.eventually.eql(false);
}));
});

View File

@@ -0,0 +1,24 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('GET /user/in-app-rewards', () => {
let user;
before(async () => {
user = await generateUser();
});
it('returns the reward items available for purchase', async () => {
let buyList = await user.get('/user/in-app-rewards');
expect(_.find(buyList, item => {
return item.text === t('armorWarrior1Text');
})).to.exist;
expect(_.find(buyList, item => {
return item.text === t('armorWarrior2Text');
})).to.not.exist;
});
});

View File

@@ -0,0 +1,27 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('GET /user/toggle-pinned-item', () => {
let user;
before(async () => {
user = await generateUser();
});
it('cannot unpin potion', async () => {
await expect(user.get('/user/toggle-pinned-item/potion/potion'))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('cannotUnpinArmoirPotion'),
});
});
it('can pin shield_rogue_5', async () => {
let result = await user.get('/user/toggle-pinned-item/marketGear/gear.flat.shield_rogue_5');
expect(result.pinnedItems.length).to.be.eql(user.pinnedItems.length + 1);
});
});

View File

@@ -258,11 +258,31 @@ describe('POST /user/class/cast/:spellId', () => {
expect(user.achievements.birthday).to.equal(1);
});
it('passes correct target to spell when targetType === \'task\'', async () => {
await user.update({'stats.class': 'wizard', 'stats.lvl': 11});
let task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
let result = await user.post(`/user/class/cast/fireball?targetId=${task._id}`);
expect(result.task._id).to.equal(task._id);
});
it('passes correct target to spell when targetType === \'self\'', async () => {
await user.update({'stats.class': 'wizard', 'stats.lvl': 14, 'stats.mp': 50});
let result = await user.post('/user/class/cast/frost');
expect(result.user.stats.mp).to.equal(10);
});
// 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');

View File

@@ -30,10 +30,12 @@ describe('POST /user/release-both', () => {
'items.currentPet': animal,
'items.pets': loadPets(),
'items.mounts': loadMounts(),
'achievements.triadBingo': true,
});
});
it('returns an error when user balance is too low and user does not have triadBingo', async () => {
// @TODO: Traid is now free. Add this back if we need
xit('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,
@@ -45,9 +47,7 @@ describe('POST /user/release-both', () => {
// More tests in common code unit tests
it('grants triad bingo with gems', async () => {
await user.update({
balance: 1.5,
});
await user.update();
let response = await user.post('/user/release-both');
await user.sync();

View File

@@ -27,6 +27,33 @@ describe('PUT /user', () => {
expect(user.stats.hp).to.eql(14);
});
it('tags must be an array', async () => {
await expect(user.put('/user', {
tags: {
tag: true,
},
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'mustBeArray',
});
});
it('update tags', async () => {
let userTags = user.tags;
await user.put('/user', {
tags: [...user.tags, {
name: 'new tag',
}],
});
await user.sync();
expect(user.tags.length).to.be.eql(userTags.length + 1);
});
it('profile.name cannot be an empty string or null', async () => {
await expect(user.put('/user', {
'profile.name': ' ', // string should be trimmed

View File

@@ -357,6 +357,21 @@ describe('POST /user/auth/local/register', () => {
});
});
it('sanitizes email params to a lowercase string before creating the user', async () => {
let username = generateRandomUserName();
let email = 'ISANEmAiL@ExAmPle.coM';
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.auth.local.email).to.equal(email.toLowerCase());
});
it('fails on a habitica.com email', async () => {
let username = generateRandomUserName();
let email = `${username}@habitica.com`;

View File

@@ -13,7 +13,7 @@ import nconf from 'nconf';
const ENDPOINT = '/user/auth/update-email';
describe('PUT /user/auth/update-email', () => {
let newEmail = 'some-new-email_2@example.net';
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 () => {
@@ -53,14 +53,15 @@ describe('PUT /user/auth/update-email', () => {
});
it('changes email if new email and existing password are provided', async () => {
let lowerCaseNewEmail = newEmail.toLowerCase();
let response = await user.put(ENDPOINT, {
newEmail,
password: oldPassword,
});
expect(response).to.eql({ email: 'some-new-email_2@example.net' });
expect(response.email).to.eql(lowerCaseNewEmail);
await user.sync();
expect(user.auth.local.email).to.eql(newEmail);
expect(user.auth.local.email).to.eql(lowerCaseNewEmail);
});
it('rejects if email is already taken', async () => {

View File

@@ -1,7 +1,6 @@
/* eslint-disable global-require */
import moment from 'moment';
import nconf from 'nconf';
import Bluebird from 'bluebird';
import requireAgain from 'require-again';
import { recoverCron, cron } from '../../../../../website/server/libs/cron';
import { model as User } from '../../../../../website/server/models/user';
@@ -1363,7 +1362,7 @@ describe('recoverCron', () => {
});
it('throws an error if user cannot be found', async () => {
execStub.returns(Bluebird.resolve(null));
execStub.returns(Promise.resolve(null));
try {
await recoverCron(status, locals);
@@ -1374,8 +1373,8 @@ describe('recoverCron', () => {
});
it('increases status.times count and reruns up to 4 times', async () => {
execStub.returns(Bluebird.resolve({_cronSignature: 'RUNNING_CRON'}));
execStub.onCall(4).returns(Bluebird.resolve({_cronSignature: 'NOT_RUNNING'}));
execStub.returns(Promise.resolve({_cronSignature: 'RUNNING_CRON'}));
execStub.onCall(4).returns(Promise.resolve({_cronSignature: 'NOT_RUNNING'}));
await recoverCron(status, locals);
@@ -1384,7 +1383,7 @@ describe('recoverCron', () => {
});
it('throws an error if recoverCron runs 5 times', async () => {
execStub.returns(Bluebird.resolve({_cronSignature: 'RUNNING_CRON'}));
execStub.returns(Promise.resolve({_cronSignature: 'RUNNING_CRON'}));
try {
await recoverCron(status, locals);

View File

@@ -47,7 +47,7 @@ describe('slack', () => {
expect(IncomingWebhook.prototype.send).to.be.calledOnce;
expect(IncomingWebhook.prototype.send).to.be.calledWith({
text: 'flagger (flagger-id) flagged a message (language: flagger-lang)',
text: 'flagger (flagger-id; language: flagger-lang) flagged a message',
attachments: [{
fallback: 'Flag Message',
color: 'danger',

View File

@@ -8,7 +8,6 @@ import {
attachTranslateFunction,
} from '../../../../../website/server/middlewares/language';
import common from '../../../../../website/common';
import Bluebird from 'bluebird';
import { model as User } from '../../../../../website/server/models/user';
const i18n = common.i18n;
@@ -162,7 +161,7 @@ describe('language middleware', () => {
return this;
},
exec () {
return Bluebird.resolve({
return Promise.resolve({
preferences: {
language: 'it',
},

View File

@@ -1,4 +1,3 @@
import Bluebird from 'bluebird';
import moment from 'moment';
import { model as User } from '../../../../../website/server/models/user';
import { model as Group } from '../../../../../website/server/models/group';
@@ -123,7 +122,7 @@ describe('User Model', () => {
it('adds notifications without data for all given users via static method', async () => {
let user = new User();
let otherUser = new User();
await Bluebird.all([user.save(), otherUser.save()]);
await Promise.all([user.save(), otherUser.save()]);
await User.pushNotification({_id: {$in: [user._id, otherUser._id]}}, 'CRON');
@@ -149,7 +148,7 @@ describe('User Model', () => {
it('adds notifications with data and seen status for all given users via static method', async () => {
let user = new User();
let otherUser = new User();
await Bluebird.all([user.save(), otherUser.save()]);
await Promise.all([user.save(), otherUser.save()]);
await User.pushNotification({_id: {$in: [user._id, otherUser._id]}}, 'CRON', {field: 1}, true);

View File

@@ -0,0 +1,211 @@
import { shallow, createLocalVue } from '@vue/test-utils';
import TaskColumn from 'client/components/tasks/column.vue';
import Store from 'client/libs/store';
// eslint-disable no-exclusive-tests
const localVue = createLocalVue();
localVue.use(Store);
describe('Task Column', () => {
let wrapper;
let store, getters;
let habits, taskListOverride, tasks;
function makeWrapper (additionalSetup = {}) {
let type = 'habit';
let mocks = {
$t () {},
};
let stubs = ['b-modal']; // <b-modal> is a custom component and not tested here
return shallow(TaskColumn, {
propsData: {
type,
},
mocks,
stubs,
localVue,
...additionalSetup,
});
}
it('returns a vue instance', () => {
wrapper = makeWrapper();
expect(wrapper.isVueInstance()).to.be.true;
});
describe('Passed Properties', () => {
beforeEach(() => {
wrapper = makeWrapper();
});
it('defaults isUser to false', () => {
expect(wrapper.vm.isUser).to.be.false;
});
it('passes isUser to component instance', () => {
wrapper.setProps({ isUser: false });
expect(wrapper.vm.isUser).to.be.false;
wrapper.setProps({ isUser: true });
expect(wrapper.vm.isUser).to.be.true;
});
});
describe('Computed Properties', () => {
beforeEach(() => {
habits = [
{ id: 1 },
{ id: 2 },
];
taskListOverride = [
{ id: 3 },
{ id: 4 },
];
getters = {
// (...) => { ... } will return a value
// (...) => (...) => { ... } will return a function
// Task Column expects a function
'tasks:getFilteredTaskList': () => () => habits,
};
store = new Store({getters});
wrapper = makeWrapper({store});
});
it('returns task list from props for group-plan', () => {
wrapper.setProps({ taskListOverride });
wrapper.vm.taskList.forEach((el, i) => {
expect(el).to.eq(taskListOverride[i]);
});
wrapper.setProps({ isUser: false, taskListOverride });
wrapper.vm.taskList.forEach((el, i) => {
expect(el).to.eq(taskListOverride[i]);
});
});
it('returns task list from store for user', () => {
wrapper.setProps({ isUser: true, taskListOverride });
wrapper.vm.taskList.forEach((el, i) => {
expect(el).to.eq(habits[i]);
});
});
});
describe('Methods', () => {
describe('Filter By Tags', () => {
beforeEach(() => {
tasks = [
{ tags: [3, 4] },
{ tags: [2, 3] },
{ tags: [] },
{ tags: [1, 3] },
];
});
it('returns all tasks if no tag is given', () => {
let returnedTasks = wrapper.vm.filterByTagList(tasks);
expect(returnedTasks).to.have.lengthOf(tasks.length);
tasks.forEach((task, i) => {
expect(returnedTasks[i]).to.eq(task);
});
});
it('returns tasks for given single tag', () => {
let returnedTasks = wrapper.vm.filterByTagList(tasks, [3]);
expect(returnedTasks).to.have.lengthOf(3);
expect(returnedTasks[0]).to.eq(tasks[0]);
expect(returnedTasks[1]).to.eq(tasks[1]);
expect(returnedTasks[2]).to.eq(tasks[3]);
});
it('returns tasks for given multiple tags', () => {
let returnedTasks = wrapper.vm.filterByTagList(tasks, [2, 3]);
expect(returnedTasks).to.have.lengthOf(1);
expect(returnedTasks[0]).to.eq(tasks[1]);
});
});
describe('Filter By Search Text', () => {
beforeEach(() => {
tasks = [
{
text: 'Hello world 1',
note: '',
checklist: [],
},
{
text: 'Hello world 2',
note: '',
checklist: [],
},
{
text: 'Generic Task Title',
note: '',
checklist: [
{ text: 'Check 1' },
{ text: 'Check 2' },
{ text: 'Check 3' },
],
},
{
text: 'Hello world 3',
note: 'Generic Task Note',
checklist: [
{ text: 'Checkitem 1' },
{ text: 'Checkitem 2' },
{ text: 'Checkitem 3' },
],
},
];
});
it('returns all tasks for empty search term', () => {
let returnedTasks = wrapper.vm.filterBySearchText(tasks);
expect(returnedTasks).to.have.lengthOf(tasks.length);
tasks.forEach((task, i) => {
expect(returnedTasks[i]).to.eq(task);
});
});
it('returns tasks for search term in title /i', () => {
['Title', 'TITLE', 'title', 'tItLe'].forEach((term) => {
expect(wrapper.vm.filterBySearchText(tasks, term)[0]).to.eq(tasks[2]);
});
});
it('returns tasks for search term in note /i', () => {
['Note', 'NOTE', 'note', 'nOtE'].forEach((term) => {
expect(wrapper.vm.filterBySearchText(tasks, term)[0]).to.eq(tasks[3]);
});
});
it('returns tasks for search term in checklist title /i', () => {
['Check', 'CHECK', 'check', 'cHeCK'].forEach((term) => {
let returnedTasks = wrapper.vm.filterBySearchText(tasks, term);
expect(returnedTasks[0]).to.eq(tasks[2]);
expect(returnedTasks[1]).to.eq(tasks[3]);
});
['Checkitem', 'CHECKITEM', 'checkitem', 'cHeCKiTEm'].forEach((term) => {
expect(wrapper.vm.filterBySearchText(tasks, term)[0]).to.eq(tasks[3]);
});
});
});
});
});

View File

@@ -0,0 +1,62 @@
import {
getTypeLabel,
getFilterLabels,
getActiveFilter,
} from 'client/libs/store/helpers/filterTasks.js';
describe('Filter Category for Tasks', () => {
describe('getTypeLabel', () => {
it('should return correct task type labels', () => {
expect(getTypeLabel('habit')).to.eq('habits');
expect(getTypeLabel('daily')).to.eq('dailies');
expect(getTypeLabel('todo')).to.eq('todos');
expect(getTypeLabel('reward')).to.eq('rewards');
});
});
describe('getFilterLabels', () => {
let habit, daily, todo, reward;
beforeEach(() => {
habit = ['all', 'yellowred', 'greenblue'];
daily = ['all', 'due', 'notDue'];
todo = ['remaining', 'scheduled', 'complete2'];
reward = ['all', 'custom', 'wishlist'];
});
it('should return all task type filter labels by type', () => {
// habits
getFilterLabels('habit').forEach((item, i) => {
expect(item).to.eq(habit[i]);
});
// dailys
getFilterLabels('daily').forEach((item, i) => {
expect(item).to.eq(daily[i]);
});
// todos
getFilterLabels('todo').forEach((item, i) => {
expect(item).to.eq(todo[i]);
});
// rewards
getFilterLabels('reward').forEach((item, i) => {
expect(item).to.eq(reward[i]);
});
});
});
describe('getActiveFilter', () => {
it('should return single function by default', () => {
let activeFilter = getActiveFilter('habit');
expect(activeFilter).to.be.an('object');
expect(activeFilter).to.have.all.keys('label', 'filterFn', 'default');
expect(activeFilter.default).to.be.true;
});
it('should return single function for given filter type', () => {
let activeFilterLabel = 'yellowred';
let activeFilter = getActiveFilter('habit', activeFilterLabel);
expect(activeFilter).to.be.an('object');
expect(activeFilter).to.have.all.keys('label', 'filterFn');
expect(activeFilter.label).to.eq(activeFilterLabel);
});
});
});

View File

@@ -0,0 +1,43 @@
import {
orderSingleTypeTasks,
// orderMultipleTypeTasks,
} from 'client/libs/store/helpers/orderTasks.js';
import shuffle from 'lodash/shuffle';
describe('Task Order Helper Function', () => {
let tasks, shuffledTasks, taskOrderList;
beforeEach(() => {
taskOrderList = [1, 2, 3, 4];
tasks = [];
taskOrderList.forEach(i => tasks.push({ _id: i, id: i }));
shuffledTasks = shuffle(tasks);
});
it('should return tasks as is for no task order', () => {
expect(orderSingleTypeTasks(shuffledTasks)).to.eq(shuffledTasks);
});
it('should return tasks in expected order', () => {
let newOrderedTasks = orderSingleTypeTasks(shuffledTasks, taskOrderList);
newOrderedTasks.forEach((item, index) => {
expect(item).to.eq(tasks[index]);
});
});
it('should return new tasks at end of expected order', () => {
let newTaskIds = [10, 15, 20];
newTaskIds.forEach(i => tasks.push({ _id: i, id: i }));
shuffledTasks = shuffle(tasks);
let newOrderedTasks = orderSingleTypeTasks(shuffledTasks, taskOrderList);
// checking tasks with order
newOrderedTasks.slice(0, taskOrderList.length).forEach((item, index) => {
expect(item).to.eq(tasks[index]);
});
// check for new task ids
newOrderedTasks.slice(-3).forEach(item => {
expect(item.id).to.be.oneOf(newTaskIds);
});
});
});

View File

@@ -0,0 +1,118 @@
import generateStore from 'client/store';
describe('Store Getters for Tasks', () => {
let store, habits, dailys, todos, rewards;
beforeEach(() => {
store = generateStore();
// Get user preference data and user tasks order data
store.state.user.data = {
preferences: {},
tasksOrder: {
habits: [],
dailys: [],
todos: [],
rewards: [],
},
};
});
describe('Task List', () => {
beforeEach(() => {
habits = [
{ id: 1 },
{ id: 2 },
];
dailys = [
{ id: 3 },
{ id: 4 },
];
todos = [
{ id: 5 },
{ id: 6 },
];
rewards = [
{ id: 7 },
{ id: 8 },
];
store.state.tasks.data = {
habits,
dailys,
todos,
rewards,
};
});
it('should returns all tasks by task type', () => {
let returnedTasks = store.getters['tasks:getUnfilteredTaskList']('habit');
expect(returnedTasks).to.eq(habits);
returnedTasks = store.getters['tasks:getUnfilteredTaskList']('daily');
expect(returnedTasks).to.eq(dailys);
returnedTasks = store.getters['tasks:getUnfilteredTaskList']('todo');
expect(returnedTasks).to.eq(todos);
returnedTasks = store.getters['tasks:getUnfilteredTaskList']('reward');
expect(returnedTasks).to.eq(rewards);
});
});
// @TODO add task filter check for rewards and dailys
describe('Task Filters', () => {
beforeEach(() => {
habits = [
// weak habit
{ value: 0 },
// strong habit
{ value: 2 },
];
todos = [
// scheduled todos
{ completed: false, date: 'Mon, 15 Jan 2018 12:18:29 GMT' },
// completed todos
{ completed: true },
];
store.state.tasks.data = {
habits,
todos,
};
});
it('should return weak habits', () => {
let returnedTasks = store.getters['tasks:getFilteredTaskList']({
type: 'habit',
filterType: 'yellowred',
});
expect(returnedTasks[0]).to.eq(habits[0]);
});
it('should return strong habits', () => {
let returnedTasks = store.getters['tasks:getFilteredTaskList']({
type: 'habit',
filterType: 'greenblue',
});
expect(returnedTasks[0]).to.eq(habits[1]);
});
it('should return scheduled todos', () => {
let returnedTasks = store.getters['tasks:getFilteredTaskList']({
type: 'todo',
filterType: 'scheduled',
});
expect(returnedTasks[0]).to.eq(todos[0]);
});
it('should return completed todos', () => {
let returnedTasks = store.getters['tasks:getFilteredTaskList']({
type: 'todo',
filterType: 'complete2',
});
expect(returnedTasks[0]).to.eq(todos[1]);
});
});
});

View File

@@ -4,14 +4,20 @@ import sinon from 'sinon'; // eslint-disable-line no-shadow
import {
generateUser,
} from '../../../helpers/common.helper';
import buyGear from '../../../../website/common/script/ops/buy/buyGear';
import {BuyMarketGearOperation} from '../../../../website/common/script/ops/buy/buyMarketGear';
import shared from '../../../../website/common/script';
import {
BadRequest, NotAuthorized, NotFound,
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyGear', () => {
function buyGear (user, req, analytics) {
let buyOp = new BuyMarketGearOperation(user, req, analytics);
return buyOp.purchase();
}
describe('shared.ops.buyMarketGear', () => {
let user;
let analytics = {track () {}};
@@ -111,6 +117,31 @@ describe('shared.ops.buyGear', () => {
}
});
it('does not buy equipment of different class', (done) => {
user.stats.gp = 82;
user.stats.class = 'warrior';
try {
buyGear(user, {params: {key: 'weapon_special_winter2018Rogue'}});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('cannotBuyItem'));
done();
}
});
it('does not buy equipment in bulk', (done) => {
user.stats.gp = 82;
try {
buyGear(user, {params: {key: 'armor_warrior_1'}, quantity: 3});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAbleToBuyInBulk'));
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;

View File

@@ -26,10 +26,11 @@ describe('shared.ops.releaseBoth', () => {
user.items.currentMount = animal;
user.items.currentPet = animal;
user.balance = 1.5;
user.achievements.triadBingo = true;
});
it('returns an error when user balance is too low and user does not have triadBingo', (done) => {
xit('returns an error when user balance is too low and user does not have triadBingo', (done) => {
user.balance = 0;
try {

View File

@@ -1,7 +1,6 @@
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';
@@ -106,7 +105,7 @@ export async function createAndPopulateGroup (settings = {}) {
guild: { guilds: [group._id] },
};
let members = await Bluebird.all(
let members = await Promise.all(
times(numberOfMembers, () => {
return generateUser(groupMembershipTypes[group.type]);
})
@@ -114,7 +113,7 @@ export async function createAndPopulateGroup (settings = {}) {
await group.update({ memberCount: numberOfMembers + 1});
let invitees = await Bluebird.all(
let invitees = await Promise.all(
times(numberOfInvites, () => {
return generateUser();
})
@@ -126,9 +125,9 @@ export async function createAndPopulateGroup (settings = {}) {
});
});
await Bluebird.all(invitationPromises);
await Promise.all(invitationPromises);
await Bluebird.map(invitees, (invitee) => invitee.sync());
await Promise.all(invitees.map((invitee) => invitee.sync()));
return {
groupLeader,

View File

@@ -2,8 +2,6 @@
/* eslint-disable global-require */
/* eslint-disable no-process-env */
import Bluebird from 'bluebird';
//------------------------------
// Global modules
//------------------------------
@@ -16,7 +14,6 @@ global.sinon = require('sinon');
let sinonStubPromise = require('sinon-stub-promise');
sinonStubPromise(global.sinon);
global.sandbox = sinon.sandbox.create();
global.Promise = Bluebird;
import setupNconf from '../../website/server/libs/setupNconf';
setupNconf('./config.json.example');

View File

@@ -1,7 +1 @@
export async function sleep (seconds = 1) {
let milliseconds = seconds * 1000;
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
export { default as sleep } from '../../website/server/libs/sleep';

View File

@@ -1,13 +1,11 @@
/* eslint-disable no-process-env */
import nconf from 'nconf';
import mongoose from 'mongoose';
import Bluebird from 'bluebird';
import setupNconf from '../../website/server/libs/setupNconf';
if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose
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
setupNconf('./config.json.example');

View File

@@ -3,7 +3,6 @@
--timeout 8000
--check-leaks
--globals io
-r babel-polyfill
--require babel-register
--require ./test/helpers/globals.helper
--exit

View File

@@ -11,12 +11,12 @@ source /home/vagrant/.profile
echo Setting up node...
cd /vagrant
nvm install
nvm use
nvm install 8
nvm use 8
nvm alias default current
echo Update npm...
npm install -g npm@4
npm install -g npm@5
echo Installing global modules...
npm install -g gulp mocha node-pre-gyp

View File

@@ -14,10 +14,17 @@ div
router-view(v-if="!isUserLoggedIn || isStaticPage")
template(v-else)
template(v-if="isUserLoaded")
div.resting-banner(v-if="showRestingBanner")
span.content
span.label {{ $t('innCheckOutBanner') }}
span.separator |
span.resume(@click="resumeDamage()") {{ $t('resumeDamage') }}
div.closepadding(@click="hideBanner()")
span.svg-icon.inline.icon-10(aria-hidden="true", v-html="icons.close")
notifications-display
app-menu
app-menu(:class='{"restingInn": showRestingBanner}')
.container-fluid
app-header
app-header(:class='{"restingInn": showRestingBanner}')
buyModal(
:item="selectedItemToBuy || {}",
:withPin="true",
@@ -34,13 +41,15 @@ div
div(:class='{sticky: user.preferences.stickyHeader}')
router-view
app-footer
app-footer
audio#sound(autoplay, ref="sound")
source#oggSource(type="audio/ogg", :src="sound.oggSource")
source#mp3Source(type="audio/mp3", :src="sound.mp3Source")
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
#loading-screen-inapp {
#melior {
margin: 0 auto;
@@ -53,7 +62,7 @@ div
}
h2 {
color: #fff;
color: $white;
font-size: 32px;
font-weight: bold;
}
@@ -72,10 +81,10 @@ div
.notification {
border-radius: 1000px;
background-color: #24cc8f;
background-color: $green-10;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
padding: .5em 1em;
color: #fff;
color: $white;
margin-top: .5em;
margin-bottom: .5em;
}
@@ -93,7 +102,9 @@ div
}
</style>
<style>
<style lang='scss'>
@import '~client/assets/scss/colors.scss';
/* @TODO: The modal-open class is not being removed. Let's try this for now */
.modal, .modal-open {
overflow-y: scroll !important;
@@ -101,13 +112,66 @@ div
.modal-backdrop.show {
opacity: 1 !important;
background-color: rgba(67, 40, 116, 0.9) !important;
background-color: $purple-100 !important;
}
/* Push progress bar above modals */
#nprogress .bar {
z-index: 1043 !important; /* Must stay above nav bar */
}
.restingInn {
.navbar {
top: 40px;
}
#app-header {
margin-top: 96px !important;
}
}
.resting-banner {
width: 100%;
height: 40px;
background-color: $blue-10;
position: fixed;
top: 0;
z-index: 1030;
display: flex;
.content {
height: 24px;
line-height: 1.71;
text-align: center;
color: $white;
margin: auto;
}
.closepadding {
margin: 11px 24px;
display: inline-block;
position: absolute;
right: 0;
top: 0;
cursor: pointer;
span svg path {
stroke: $blue-500;
}
}
.separator {
color: $blue-100;
margin: 0px 15px;
}
.resume {
font-weight: bold;
cursor: pointer;
}
}
</style>
<script>
@@ -128,6 +192,8 @@ import { setup as setupPayments } from 'client/libs/payments';
import amazonPaymentsModal from 'client/components/payments/amazonModal';
import spellsMixin from 'client/mixins/spells';
import svgClose from 'assets/svg/close.svg';
export default {
mixins: [notifications, spellsMixin],
name: 'app',
@@ -143,6 +209,9 @@ export default {
},
data () {
return {
icons: Object.freeze({
close: svgClose,
}),
selectedItemToBuy: null,
selectedSpellToBuy: null,
@@ -152,6 +221,7 @@ export default {
},
loading: true,
currentTipNumber: 0,
bannerHidden: false,
};
},
computed: {
@@ -172,6 +242,9 @@ export default {
return this.$t(`tip${tipNumber}`);
},
showRestingBanner () {
return !this.bannerHidden && this.user.preferences.sleep;
},
},
created () {
this.$root.$on('playSound', (sound) => {
@@ -462,6 +535,12 @@ export default {
hideLoadingScreen () {
this.loading = false;
},
hideBanner () {
this.bannerHidden = true;
},
resumeDamage () {
this.$store.dispatch('user:sleep');
},
},
};
</script>

View File

@@ -0,0 +1,20 @@
.Pet {
margin: auto !important;
display: block;
position: relative;
right: 0;
bottom: 0;
left: 0;
&:not([class*="FlyingPig"]) {
top: -28px !important;
}
}
.Pet[class*="FlyingPig"] {
top: 7px !important;
}
.Pet.Pet-Dragon-Hydra {
top: -16px !important;
}

View File

@@ -33,3 +33,4 @@
@import './banner';
@import './progress-bar';
@import './pin';
@import './animals';

View File

@@ -114,10 +114,9 @@
.toggle-down(@click="sections.staff = !sections.staff", v-if="!sections.staff")
.svg-icon(v-html="icons.downIcon")
.section.row(v-if="sections.staff")
// @TODO open member modal when clicking on a staff member
.col-4.staff(v-for='user in staff', :class='{staff: user.type === "Staff", moderator: user.type === "Moderator", bailey: user.name === "It\'s Bailey"}')
div
.title {{user.name}}
a.title(@click="viewStaffProfile(user.uuid)") {{user.name}}
.svg-icon.staff-icon(v-html="icons.tierStaff", v-if='user.type === "Staff"')
.svg-icon.mod-icon(v-html="icons.tierMod", v-if='user.type === "Moderator" && user.name !== "It\'s Bailey"')
.svg-icon.npc-icon(v-html="icons.tierNPC", v-if='user.name === "It\'s Bailey"')
@@ -595,78 +594,97 @@ export default {
{
name: 'beffymaroo',
type: 'Staff',
uuid: '9fe7183a-4b79-4c15-9629-a1aee3873390',
},
// {
// name: 'lefnire',
// type: 'Staff',
// uuid: '00000000-0000-4000-9000-000000000000',
// },
{
name: 'Lemoness',
type: 'Staff',
uuid: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0',
},
{
name: 'paglias',
type: 'Staff',
uuid: 'ed4c688c-6652-4a92-9d03-a5a79844174a',
},
{
name: 'redphoenix',
type: 'Staff',
uuid: 'cb46ad54-8c78-4dbc-a8ed-4e3185b2b3ff',
},
{
name: 'SabreCat',
type: 'Staff',
uuid: '7f14ed62-5408-4e1b-be83-ada62d504931',
},
{
name: 'TheHollidayInn',
type: 'Staff',
uuid: '206039c6-24e4-4b9f-8a31-61cbb9aa3f66',
},
{
name: 'viirus',
type: 'Staff',
uuid: 'a327d7e0-1c2e-41be-9193-7b30b484413f',
},
{
name: 'It\'s Bailey',
type: 'Moderator',
uuid: '9da65443-ed43-4c21-804f-d260c1361596',
},
{
name: 'Alys',
type: 'Moderator',
uuid: 'd904bd62-da08-416b-a816-ba797c9ee265',
},
{
name: 'Blade',
type: 'Moderator',
uuid: '75f270e8-c5db-4722-a5e6-a83f1b23f76b',
},
{
name: 'Breadstrings',
type: 'Moderator',
uuid: '3b675c0e-d7a6-440c-8687-bc67cd0bf4e9',
},
{
name: 'Cantras',
type: 'Moderator',
uuid: '28771972-ca6d-4c03-8261-e1734aa7d21d',
},
// {
// name: 'Daniel the Bard',
// type: 'Moderator',
// uuid: '1f7c4a74-03a3-4b2c-b015-112d0acbd593',
// },
{
name: 'deilann 5.0.5b',
type: 'Moderator',
uuid: 'e7b5d1e2-3b6e-4192-b867-8bafdb03eeec',
},
{
name: 'Dewines',
type: 'Moderator',
uuid: '262a7afb-6b57-4d81-88e0-80d2e9f6cbdc',
},
{
name: 'Fox_town',
type: 'Moderator',
uuid: 'a05f0152-d66b-4ef1-93ac-4adb195d0031',
},
{
name: 'Megan',
type: 'Moderator',
uuid: '73e5125c-2c87-4004-8ccd-972aeac4f17a',
},
{
name: 'shanaqui',
type: 'Moderator',
uuid: 'bb089388-28ae-4e42-a8fa-f0c2bfb6f779',
},
],
newMessage: '',
@@ -720,7 +738,6 @@ export default {
this.newMessage = newText;
},
toggleSleep () {
this.user.preferences.sleep = !this.user.preferences.sleep;
this.$store.dispatch('user:sleep');
},
async sendMessage () {
@@ -764,6 +781,14 @@ export default {
this.$root.$emit('bv::show::modal', 'world-boss-rage');
}
},
async viewStaffProfile (staffId) {
let staffDetails = await this.$store.dispatch('members:fetchMember', { memberId: staffId });
this.$root.$emit('habitica:show-profile', {
user: staffDetails.data.data,
startingPage: 'profile',
});
},
},
};
</script>

View File

@@ -193,31 +193,28 @@
</style>
<script>
import { mapState } from 'client/libs/store';
import each from 'lodash/each';
import throttle from 'lodash/throttle';
import moment from 'moment';
import Item from 'client/components/inventory/item';
import ItemRows from 'client/components/ui/itemRows';
import CountBadge from 'client/components/ui/countBadge';
import cardsModal from './cards-modal';
import HatchedPetDialog from '../stable/hatchedPetDialog';
import startQuestModal from '../../groups/startQuestModal';
import createAnimal from 'client/libs/createAnimal';
import QuestInfo from '../../shops/quests/questInfo.vue';
import moment from 'moment';
const allowedSpecialItems = ['snowball', 'spookySparkles', 'shinySeed', 'seafoam'];
import { mapState } from 'client/libs/store';
import createAnimal from 'client/libs/createAnimal';
import notifications from 'client/mixins/notifications';
import DragDropDirective from 'client/directives/dragdrop.directive';
import MouseMoveDirective from 'client/directives/mouseposition.directive';
const allowedSpecialItems = ['snowball', 'spookySparkles', 'shinySeed', 'seafoam'];
const groups = [
['eggs', 'Pet_Egg_'],
['hatchingPotions', 'Pet_HatchingPotion_'],
@@ -321,14 +318,12 @@ export default {
let specialArray = itemsByType.special;
if (this.user.purchased.plan.customerId) {
specialArray.push({
key: 'mysteryItem',
class: `inventory_present inventory_present_${moment().format('MM')}`,
text: this.$t('subscriberItemText'),
quantity: this.user.purchased.plan.mysteryItems.length,
});
}
specialArray.push({
key: 'mysteryItem',
class: `inventory_present inventory_present_${moment().format('MM')}`,
text: this.$t('subscriberItemText'),
quantity: this.user.purchased.plan.mysteryItems.length,
});
for (let type in this.content.cardTypes) {
let card = this.user.items.special[`${type}Received`] || [];

View File

@@ -276,18 +276,6 @@
display: inline-block;
}
.stable .item .item-content.Pet:not(.FlyingPig) {
top: -28px;
}
.stable .item .item-content.FlyingPig {
top: 7px;
}
.stable .item .item-content.Pet-Dragon-Hydra {
top: -16px !important;
}
.hatchablePopover {
width: 180px
}

View File

@@ -76,9 +76,9 @@
hr
button.btn.btn-primary(@click='showBailey()', popover-trigger='mouseenter', popover-placement='right', :popover="$t('showBaileyPop')") {{ $t('showBailey') }}
button.btn.btn-primary(@click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', :popover="$t('fixValPop')") {{ $t('fixVal') }}
button.btn.btn-primary(v-if='user.preferences.disableClasses == true', @click='changeClassForUser(false)',
button.btn.btn-primary.mr-2.mb-2(@click='showBailey()', popover-trigger='mouseenter', popover-placement='right', :popover="$t('showBaileyPop')") {{ $t('showBailey') }}
button.btn.btn-primary.mr-2.mb-2(@click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', :popover="$t('fixValPop')") {{ $t('fixVal') }}
button.btn.btn-primary.mb-2(v-if='user.preferences.disableClasses == true', @click='changeClassForUser(false)',
popover-trigger='mouseenter', popover-placement='right', :popover="$t('enableClassPop')") {{ $t('enableClass') }}
hr
@@ -93,7 +93,7 @@
option(v-for='option in dayStartOptions' :value='option.value') {{option.name}}
.col-5
button.btn.btn-block.btn-primary(@click='openDayStartModal()',
button.btn.btn-block.btn-primary.mt-1(@click='openDayStartModal()',
:disabled='newDayStart === user.preferences.dayStart')
| {{ $t('saveCustomDayStart') }}
hr
@@ -111,8 +111,8 @@
div
ul.list-inline
li(v-for='network in SOCIAL_AUTH_NETWORKS')
button.btn.btn-primary(v-if='!user.auth[network.key].id', @click='socialAuth(network.key, user)') {{ $t('registerWithSocial', {network: network.name}) }}
button.btn.btn-primary(disabled='disabled', v-if='!hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('registeredWithSocial', {network: network.name}) }}
button.btn.btn-primary.mb-2(v-if='!user.auth[network.key].id', @click='socialAuth(network.key, user)') {{ $t('registerWithSocial', {network: network.name}) }}
button.btn.btn-primary.mb-2(disabled='disabled', v-if='!hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('registeredWithSocial', {network: network.name}) }}
button.btn.btn-danger(@click='deleteSocialAuth(network.key)', v-if='hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('detachSocial', {network: network.name}) }}
hr
div(v-if='!user.auth.local.username')
@@ -171,9 +171,9 @@
div
h5 {{ $t('dangerZone') }}
div
button.btn.btn-danger(@click='openResetModal()',
button.btn.btn-danger.mr-2.mb-2(@click='openResetModal()',
popover-trigger='mouseenter', popover-placement='right', v-b-popover.hover.auto="$t('resetAccPop')") {{ $t('resetAccount') }}
button.btn.btn-danger(@click='openDeleteModal()',
button.btn.btn-danger.mb-2(@click='openDeleteModal()',
popover-trigger='mouseenter', v-b-popover.hover.auto="$t('deleteAccPop')") {{ $t('deleteAccount') }}
</template>

View File

@@ -486,6 +486,10 @@
return c.identifier === 'spells';
})[0];
let questsCategory = _filter(categories, (c) => {
return c.identifier === 'quests';
})[0];
let setCategories = _filter(categories, 'specialClass');
let result = _groupBy(setCategories, 'specialClass');
@@ -496,6 +500,12 @@
];
}
if (questsCategory) {
result.quests = [
questsCategory,
];
}
return result;
},
isGearLocked (gear) {

View File

@@ -8,14 +8,14 @@
v-if='type === "reward"')
.d-flex
h2.tasks-column-title
| {{ $t(types[type].label) }}
| {{ $t(typeLabel) }}
.badge.badge-pill.badge-purple.column-badge(v-if="badgeCount > 0") {{ badgeCount }}
.filters.d-flex.justify-content-end
.filter.small-text(
v-for="filter in types[type].filters",
:class="{active: activeFilters[type].label === filter.label}",
v-for="filter in typeFilters",
:class="{active: activeFilter.label === filter}",
@click="activateFilter(type, filter)",
) {{ $t(filter.label) }}
) {{ $t(filter) }}
.tasks-list(ref="tasksWrapper")
textarea.quick-add(
:rows="quickAddRows",
@@ -26,19 +26,19 @@
)
transition(name="quick-add-tip-slide")
.quick-add-tip.small-text(v-show="quickAddFocused", v-html="$t('addMultipleTip')")
clear-completed-todos(v-if="activeFilters[type].label === 'complete2'")
clear-completed-todos(v-if="activeFilter.label === 'complete2' && isUser === true")
.column-background(
v-if="isUser === true",
:class="{'initial-description': initialColumnDescription}",
ref="columnBackground",
)
.svg-icon(v-html="icons[type]", :class="`icon-${type}`", v-once)
h3(v-once) {{$t('theseAreYourTasks', {taskType: $t(types[type].label)})}}
h3(v-once) {{$t('theseAreYourTasks', {taskType: $t(typeLabel)})}}
.small-text {{$t(`${type}sDesc`)}}
draggable.sortable-tasks(
ref="tasksList",
@update='sorted',
:options='{disabled: activeFilters[type].label === "scheduled"}',
:options='{disabled: activeFilter.label === "scheduled"}',
)
task(
v-for="task in taskList",
@@ -245,10 +245,10 @@
<script>
import Task from './task';
import ClearCompletedTodos from './clearCompletedTodos';
import sortBy from 'lodash/sortBy';
import throttle from 'lodash/throttle';
import isEmpty from 'lodash/isEmpty';
import buyMixin from 'client/mixins/buy';
import { mapState, mapActions } from 'client/libs/store';
import { mapState, mapActions, mapGetters } from 'client/libs/store';
import shopItem from '../shops/shopItem';
import BuyQuestModal from 'client/components/shops/quests/buyQuestModal.vue';
@@ -258,6 +258,12 @@ import inAppRewards from 'common/script/libs/inAppRewards';
import spells from 'common/script/content/spells';
import taskDefaults from 'common/script/libs/taskDefaults';
import {
getTypeLabel,
getFilterLabels,
getActiveFilter,
} from 'client/libs/store/helpers/filterTasks.js';
import svgPin from 'assets/svg/pin.svg';
import habitIcon from 'assets/svg/habit.svg';
import dailyIcon from 'assets/svg/daily.svg';
@@ -274,44 +280,21 @@ export default {
shopItem,
draggable,
},
props: ['type', 'isUser', 'searchText', 'selectedTags', 'taskListOverride', 'group'], // @TODO: maybe we should store the group on state?
// Set default values for props
// allows for better control of props values
// allows for better control of where this component is called
props: {
type: {},
isUser: {
type: Boolean,
default: false,
},
searchText: {},
selectedTags: {},
taskListOverride: {},
group: {},
}, // @TODO: maybe we should store the group on state?
data () {
// @TODO refactor this so that filter functions aren't in data
const types = Object.freeze({
habit: {
label: 'habits',
filters: [
{label: 'all', filter: () => true, default: true},
{label: 'yellowred', filter: t => t.value < 1}, // weak
{label: 'greenblue', filter: t => t.value >= 1}, // strong
],
},
daily: {
label: 'dailies',
filters: [
{label: 'all', filter: () => true, default: true},
{label: 'due', filter: t => !t.completed && shouldDo(new Date(), t, this.userPreferences)},
{label: 'notDue', filter: t => t.completed || !shouldDo(new Date(), t, this.userPreferences)},
],
},
todo: {
label: 'todos',
filters: [
{label: 'remaining', filter: t => !t.completed, default: true}, // active
{label: 'scheduled', filter: t => !t.completed && t.date, sort: t => t.date},
{label: 'complete2', filter: t => t.completed},
],
},
reward: {
label: 'rewards',
filters: [
{label: 'all', filter: () => true, default: true},
{label: 'custom', filter: () => true}, // all rewards made by the user
{label: 'wishlist', filter: () => false}, // not user tasks
],
},
});
const icons = Object.freeze({
habit: habitIcon,
daily: dailyIcon,
@@ -320,14 +303,15 @@ export default {
pin: svgPin,
});
let activeFilters = {};
for (let type in types) {
activeFilters[type] = types[type].filters.find(f => f.default === true);
}
let typeLabel = '';
let typeFilters = [];
let activeFilter = {};
return {
types,
activeFilters,
typeLabel,
typeFilters,
activeFilter,
icons,
openedCompletedTodos: false,
@@ -339,45 +323,37 @@ export default {
selectedItemToBuy: {},
};
},
created () {
// Set Task Column Label
this.typeLabel = getTypeLabel(this.type);
// Get Category Filter Labels
this.typeFilters = getFilterLabels(this.type);
// Set default filter for task column
this.activateFilter(this.type);
},
computed: {
...mapState({
tasks: 'tasks.data',
user: 'user.data',
userPreferences: 'user.data.preferences',
}),
onUserPage () {
let onUserPage = Boolean(this.taskList.length) && (!this.taskListOverride || this.taskListOverride.length === 0);
if (!onUserPage) {
this.activateFilter('daily', this.types.daily.filters[0]);
this.types.reward.filters = [];
}
return onUserPage;
},
...mapGetters({
getFilteredTaskList: 'tasks:getFilteredTaskList',
getUnfilteredTaskList: 'tasks:getUnfilteredTaskList',
getUserPreferences: 'user:preferences',
getUserBuffs: 'user:buffs',
}),
taskList () {
// @TODO: This should not default to user's tasks. It should require that you pass options in
const filter = this.activeFilters[this.type];
let filteredTaskList = this.isUser ?
this.getFilteredTaskList({
type: this.type,
filterType: this.activeFilter.label,
}) :
this.taskListOverride;
let taskList = this.tasks[`${this.type}s`];
if (this.taskListOverride) taskList = this.taskListOverride;
let taggedList = this.filterByTagList(filteredTaskList, this.selectedTags);
let searchedList = this.filterBySearchText(taggedList, this.searchText);
if (taskList.length > 0 && ['scheduled', 'due'].indexOf(filter.label) === -1) {
let taskListSorted = this.$store.dispatch('tasks:order', [
taskList,
this.user.tasksOrder,
]);
taskList = taskListSorted[`${this.type}s`];
}
if (filter.sort) {
taskList = sortBy(taskList, filter.sort);
}
return taskList.filter(t => {
return this.filterTask(t);
});
return searchedList;
},
inAppRewards () {
let watchRefresh = this.forceRefresh; // eslint-disable-line
@@ -393,7 +369,7 @@ export default {
};
for (let key in seasonalSkills) {
if (this.user.stats.buffs[key]) {
if (this.getUserBuffs(key)) {
let debuff = seasonalSkills[key];
let item = Object.assign({}, spells.special[debuff]);
item.text = item.text();
@@ -406,7 +382,7 @@ export default {
return rewards;
},
hasRewardsList () {
return this.isUser === true && this.type === 'reward' && this.activeFilters[this.type].label !== 'custom';
return this.isUser === true && this.type === 'reward' && this.activeFilter.label !== 'custom';
},
initialColumnDescription () {
// Show the column description in the middle only if there are no elements (tasks or in app items)
@@ -414,14 +390,7 @@ export default {
if (this.inAppRewards && this.inAppRewards.length >= 0) return false;
}
return this.tasks[`${this.type}s`].length === 0;
},
dailyDueDefaultView () {
if (this.user.preferences.dailyDueDefaultView) {
this.activateFilter('daily', this.types.daily.filters[1]);
}
return this.user.preferences.dailyDueDefaultView;
return this.taskList.length === 0;
},
quickAddPlaceholder () {
const type = this.$t(this.type);
@@ -431,16 +400,14 @@ export default {
// 0 means the badge will not be shown
// It is shown for the all and due views of dailies
// and for the active and scheduled views of todos.
if (this.type === 'todo') {
if (this.activeFilters.todo.label !== 'complete2') return this.taskList.length;
if (this.type === 'todo' && this.activeFilter.label !== 'complete2') {
return this.taskList.length;
} else if (this.type === 'daily') {
const activeFilter = this.activeFilters.daily.label;
if (activeFilter === 'due') {
if (this.activeFilter.label === 'due') {
return this.taskList.length;
} else if (activeFilter === 'all') {
} else if (this.activeFilter.label === 'all') {
return this.taskList.reduce((count, t) => {
return !t.completed && shouldDo(new Date(), t, this.userPreferences) ? count + 1 : count;
return !t.completed && shouldDo(new Date(), t, this.getUserPreferences) ? count + 1 : count;
}, 0);
}
}
@@ -455,10 +422,6 @@ export default {
}, 250),
deep: true,
},
dailyDueDefaultView () {
if (!this.dailyDueDefaultView) return;
this.activateFilter('daily', this.types.daily.filters[1]);
},
quickAddFocused (newValue) {
if (newValue) this.quickAddRows = this.quickAddText.split('\n').length;
if (!newValue) this.quickAddRows = 1;
@@ -491,7 +454,7 @@ export default {
const filteredList = this.taskList;
const taskToMove = filteredList[data.oldIndex];
const taskIdToMove = taskToMove._id;
let originTasks = this.tasks[`${this.type}s`];
let originTasks = this.getUnfilteredTaskList(this.type);
if (this.taskListOverride) originTasks = this.taskListOverride;
// Server
@@ -518,7 +481,7 @@ export default {
},
async moveTo (task, where) { // where is 'top' or 'bottom'
const taskIdToMove = task._id;
const list = this.tasks[`${this.type}s`];
const list = this.getUnfilteredTaskList(this.type);
const oldPosition = list.findIndex(t => t._id === taskIdToMove);
const moved = list.splice(oldPosition, 1);
@@ -551,19 +514,27 @@ export default {
return task;
});
this.quickAddText = null;
this.quickAddText = '';
this.quickAddRows = 1;
this.createTask(tasks);
},
editTask (task) {
this.$emit('editTask', task);
},
activateFilter (type, filter) {
if (type === 'todo' && filter.label === 'complete2') {
activateFilter (type, filter = '') {
// Needs a separate API call as this data may not reside in store
if (type === 'todo' && filter === 'complete2') {
this.loadCompletedTodos();
}
this.activeFilters[type] = filter;
// the only time activateFilter is called with filter==='' is when the component is first created
// this can be used to check If the user has set 'due' as default filter for daily
// and set the filter as 'due' only when the component first loads and not on subsequent reloads.
if (type === 'daily' && filter === '' && this.user.preferences.dailyDueDefaultView) {
filter = 'due';
}
this.activeFilter = getActiveFilter(type, filter);
},
setColumnBackgroundVisibility () {
this.$nextTick(() => {
@@ -591,35 +562,36 @@ export default {
}
});
},
filterTask (task) {
// View
if (!this.activeFilters[task.type].filter(task)) return false;
// Tags
const selectedTags = this.selectedTags;
if (selectedTags && selectedTags.length > 0) {
const hasAllSelectedTag = selectedTags.every(tagId => {
return task.tags.indexOf(tagId) !== -1;
});
if (!hasAllSelectedTag) return false;
filterByTagList (taskList, tagList = []) {
let filteredTaskList = taskList;
// fitler requested tasks by tags
if (!isEmpty(tagList)) {
filteredTaskList = taskList.filter(
task => tagList.every(tag => task.tags.indexOf(tag) !== -1)
);
}
// Text
const searchText = this.searchText;
if (!searchText) return true;
if (task.text.toLowerCase().indexOf(searchText) !== -1) return true;
if (task.notes.toLowerCase().indexOf(searchText) !== -1) return true;
if (task.checklist && task.checklist.length) {
const checklistItemIndex = task.checklist.findIndex(({text}) => {
return text.toLowerCase().indexOf(searchText) !== -1;
});
return checklistItemIndex !== -1;
return filteredTaskList;
},
filterBySearchText (taskList, searchText = '') {
let filteredTaskList = taskList;
// filter requested tasks by search text
if (searchText) {
// to ensure broadest case insensitive search matching
let searchTextLowerCase = searchText.toLowerCase();
filteredTaskList = taskList.filter(
task => {
// eslint rule disabled for block to allow nested binary expression
/* eslint-disable no-extra-parens */
return (
task.text.toLowerCase().indexOf(searchTextLowerCase) > -1 ||
(task.note && task.note.toLowerCase().indexOf(searchTextLowerCase) > -1) ||
(task.checklist && task.checklist.length > 0 &&
task.checklist.some(checkItem => checkItem.text.toLowerCase().indexOf(searchTextLowerCase) > -1))
);
/* eslint-enable no-extra-parens */
});
}
return filteredTaskList;
},
openBuyDialog (rewardItem) {
if (rewardItem.locked) return;

View File

@@ -726,6 +726,11 @@ export default {
// @TODO: This whole component is mutating a prop and that causes issues. We need to not copy the prop similar to group modals
if (this.task) this.checklist = clone(this.task.checklist);
},
'task.startDate' () {
if (this.task && this.repeatsOn) {
this.calculateMonthlyRepeatDays(this.repeatsOn);
}
},
},
computed: {
...mapGetters({
@@ -792,23 +797,7 @@ export default {
return repeatsOn;
},
set (newValue) {
const task = this.task;
if (task.frequency === 'monthly' && newValue === 'dayOfMonth') {
const date = moment(task.startDate).date();
task.weeksOfMonth = [];
task.daysOfMonth = [date];
} else if (task.frequency === 'monthly' && newValue === 'dayOfWeek') {
const week = Math.ceil(moment(task.startDate).date() / 7) - 1;
const dayOfWeek = moment(task.startDate).day();
const shortDay = this.dayMapping[dayOfWeek];
task.daysOfMonth = [];
task.weeksOfMonth = [week];
for (let key in task.repeat) {
task.repeat[key] = false;
}
task.repeat[shortDay] = true;
}
this.calculateMonthlyRepeatDays(newValue);
},
},
selectedTags () {
@@ -870,6 +859,27 @@ export default {
weekdaysMin (dayNumber) {
return moment.weekdaysMin(dayNumber);
},
calculateMonthlyRepeatDays (repeatsOn) {
const task = this.task;
if (task.frequency === 'monthly') {
if (repeatsOn === 'dayOfMonth') {
const date = moment(task.startDate).date();
task.weeksOfMonth = [];
task.daysOfMonth = [date];
} else if (repeatsOn === 'dayOfWeek') {
const week = Math.ceil(moment(task.startDate).date() / 7) - 1;
const dayOfWeek = moment(task.startDate).day();
const shortDay = this.dayMapping[dayOfWeek];
task.daysOfMonth = [];
task.weeksOfMonth = [week];
for (let key in task.repeat) {
task.repeat[key] = false;
}
task.repeat[shortDay] = true;
}
}
},
async submit () {
if (this.newChecklistItem) this.addChecklistItem();

View File

@@ -27,7 +27,7 @@ div
.header
h1 {{user.profile.name}}
h4
strong {{ $t('userId') }}:
strong {{ $t('userId') }}:&nbsp;
| {{user._id}}
.col-12.col-md-4
button.btn.btn-secondary(v-if='user._id === userLoggedIn._id', @click='editing = !editing') {{ $t('edit') }}
@@ -131,10 +131,10 @@ div
)
div(:class="`shop_${equippedItems[key]}`")
b-popover(
v-if="label !== 'skip' && equippedItems[key] && equippedItems[key].indexOf(\"base_0\") === -1",
v-if="label !== 'skip' && equippedItems[key] && equippedItems[key].indexOf('base_0') === -1",
:target="key",
triggers="hover",
:placement="'right'",
:placement="'bottom'",
:preventOverflow="false",
)
h4.gearTitle {{ getGearTitle(equippedItems[key]) }}
@@ -146,42 +146,35 @@ div
.col-12.col-md-6
h2.text-center {{$t('costume')}}
.well
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.eyewear && costumeItems.eyewear.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.eyewear}`")
h3 {{$t('eyewear')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.head && costumeItems.head.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.head}`")
h3 {{$t('headgearCapitalized')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.headAccessory && costumeItems.headAccessory.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.headAccessory}`")
h3 {{$t('headAccess')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.back && costumeItems.back.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.back}`")
h3 {{$t('backAccess')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.armor && costumeItems.armor.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.armor}`")
h3 {{$t('armorCapitalized')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.body && costumeItems.body.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.body}`")
h3 {{$t('bodyAccess')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.weapon && costumeItems.weapon.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.weapon}`")
h3 {{$t('mainHand')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: user.preferences.background}', style="overflow:hidden")
// Use similar for loop for costume items, except show background if label is 'skip'.
.col-12.col-md-4.item-wrapper(v-for="(label, key) in equipTypes")
// Append a "C" to the key name since HTML IDs have to be unique.
.box(
:id="key + 'C'",
v-if="label !== 'skip'",
:class='{white: costumeItems[key] && costumeItems[key].indexOf("base_0") === -1}'
)
div(:class="`shop_${costumeItems[key]}`")
// Show background on 8th tile rather than a piece of equipment.
.box(v-if="label === 'skip'",
:class='{white: user.preferences.background}', style="overflow:hidden"
)
div(:class="'icon_background_' + user.preferences.background")
h3 {{$t('background')}}
.col-12.col-md-4.item-wrapper
.box(:class='{white: costumeItems.shield && costumeItems.shield.indexOf("base_0") === -1}')
div(:class="`shop_${costumeItems.shield}`")
h3 {{$t('offHand')}}
b-popover(
v-if="label !== 'skip' && costumeItems[key] && costumeItems[key].indexOf('base_0') === -1",
:target="key + 'C'",
triggers="hover",
:placement="'bottom'",
:preventOverflow="false",
)
h4.gearTitle {{ getGearTitle(costumeItems[key]) }}
attributesGrid.attributesGrid(
:item="content.gear.flat[costumeItems[key]]",
)
h3(v-if="label !== 'skip'") {{ label }}
h3(v-else) {{ $t('background') }}
.row.pet-mount-row
.col-12.col-md-6
h2.text-center(v-once) {{ $t('pets') }}
@@ -189,7 +182,7 @@ div
.row.col-12
.col-12.col-md-4
.box(:class='{white: user.items.currentPet}')
.pet(:class="`Pet-${user.items.currentPet}`")
.Pet(:class="`Pet-${user.items.currentPet}`")
.col-12.col-md-8
div
| {{ formatAnimal(user.items.currentPet, 'pet') }}
@@ -224,7 +217,7 @@ div
span.hint(:popover-title='$t(statInfo.title)', popover-placement='right',
:popover='$t(statInfo.popover)', popover-trigger='mouseenter')
.stat-title(:class='stat') {{ $t(statInfo.title) }}
strong.number {{ statsComputed[stat] }}
strong.number {{ statsComputed[stat] | floorWholeNumber }}
.col-12.col-md-6
ul.bonus-stats
li
@@ -340,10 +333,6 @@ div
margin-bottom: 2em;
}
.pet {
margin-top: -1.4em !important;
}
.mount {
margin-top: -0.2em !important;
}
@@ -574,6 +563,7 @@ import each from 'lodash/each';
import { mapState } from 'client/libs/store';
import size from 'lodash/size';
import keys from 'lodash/keys';
import cloneDeep from 'lodash/cloneDeep';
import { beastMasterProgress, mountMasterProgress } from '../../../common/script/count';
import statsComputed from '../../../common/script/libs/statsComputed';
import autoAllocate from '../../../common/script/fns/autoAllocate';
@@ -786,10 +776,13 @@ export default {
save () {
let values = {};
each(this.editingProfile, (value, key) => {
let edits = cloneDeep(this.editingProfile);
each(edits, (value, key) => {
// Using toString because we need to compare two arrays (websites)
let curVal = this.user.profile[key];
if (!curVal || this.editingProfile[key].toString() !== curVal.toString()) {
if (!curVal || value.toString() !== curVal.toString()) {
values[`profile.${key}`] = value;
this.$set(this.user.profile, key, value);
}

View File

@@ -0,0 +1,3 @@
export default function floorWholeNumber (val) {
return Math.floor(val);
}

View File

@@ -2,7 +2,9 @@ import Vue from 'vue';
import round from './round';
import floor from './floor';
import roundBigNumber from './roundBigNumber';
import floorWholeNumber from './floorWholeNumber';
Vue.filter('round', round);
Vue.filter('floor', floor);
Vue.filter('roundBigNumber', roundBigNumber);
Vue.filter('roundBigNumber', roundBigNumber);
Vue.filter('floorWholeNumber', floorWholeNumber);

View File

@@ -0,0 +1,68 @@
import { shouldDo } from 'common/script/cron';
// Task filter data
// @TODO find a way to include user preferences w.r.t sort and defaults
const taskFilters = {
habit: {
label: 'habits',
filters: [
{ label: 'all', filterFn: () => true, default: true },
{ label: 'yellowred', filterFn: t => t.value < 1 }, // weak
{ label: 'greenblue', filterFn: t => t.value >= 1 }, // strong
],
},
daily: {
label: 'dailies',
filters: [
{ label: 'all', filterFn: () => true, default: true },
{ label: 'due', filterFn: userPrefs => t => !t.completed && shouldDo(new Date(), t, userPrefs) },
{ label: 'notDue', filterFn: userPrefs => t => t.completed || !shouldDo(new Date(), t, userPrefs) },
],
},
todo: {
label: 'todos',
filters: [
{ label: 'remaining', filterFn: t => !t.completed, default: true }, // active
{ label: 'scheduled', filterFn: t => !t.completed && t.date, sort: t => t.date },
{ label: 'complete2', filterFn: t => t.completed },
],
},
reward: {
label: 'rewards',
filters: [
{ label: 'all', filterFn: () => true, default: true },
{ label: 'custom', filterFn: () => true }, // all rewards made by the user
{ label: 'wishlist', filterFn: () => false }, // not user tasks
],
},
};
function typeLabel (filterList) {
return (type) => filterList[type].label;
}
export const getTypeLabel = typeLabel(taskFilters);
function filterLabel (filterList) {
return (type) => {
let filterListByType = filterList[type].filters;
let filterListOfLabels = new Array(filterListByType.length);
filterListByType.forEach(({ label }, i) => filterListOfLabels[i] = label);
return filterListOfLabels;
};
}
export const getFilterLabels = filterLabel(taskFilters);
function activeFilter (filterList) {
return (type, filterType = '') => {
let filterListByType = filterList[type].filters;
if (filterType) {
return filterListByType.find(f => f.label === filterType);
}
return filterListByType.find(f => f.default === true);
};
}
export const getActiveFilter = activeFilter(taskFilters);

View File

@@ -0,0 +1,31 @@
import compact from 'lodash/compact';
// sets task order for single task type only.
// Accepts task list and corresponding taskorder for its task type.
export function orderSingleTypeTasks (rawTasks, taskOrder) {
// if there is no predefined task order return task list as is.
if (!taskOrder) return rawTasks;
const orderedTasks = new Array(rawTasks.length);
const unorderedTasks = []; // What we want to add later
rawTasks.forEach((task, index) => {
const taskId = task._id;
const i = taskOrder[index] === taskId ? index : taskOrder.indexOf(taskId);
if (i === -1) {
unorderedTasks.push(task);
} else {
orderedTasks[i] = task;
}
});
return compact(orderedTasks).concat(unorderedTasks);
}
export function orderMultipleTypeTasks (rawTasks, tasksOrder) {
return {
habits: orderSingleTypeTasks(rawTasks.habits, tasksOrder.habits),
dailys: orderSingleTypeTasks(rawTasks.dailys, tasksOrder.dailys),
todos: orderSingleTypeTasks(rawTasks.todos, tasksOrder.todos),
rewards: orderSingleTypeTasks(rawTasks.rewards, tasksOrder.rewards),
};
}

View File

@@ -55,7 +55,13 @@ export async function join (store, payload) {
const user = store.state.user.data;
const invitations = user.invitations;
let response = await axios.post(`/api/v3/groups/${groupId}/join`);
let response;
try {
response = await axios.post(`/api/v3/groups/${groupId}/join`);
} catch (err) {
alert(err.response.data.message);
return;
}
if (type === 'guild') {
const invitationI = invitations.guilds.findIndex(i => i.id === groupId);

View File

@@ -56,7 +56,11 @@ export async function set (store, changes) {
// .catch((err) => console.error('set', err));
}
export async function sleep () {
export async function sleep (store) {
const user = store.state.user.data;
user.preferences.sleep = !user.preferences.sleep;
let response = await axios.post('/api/v3/user/sleep');
return response.data.data;
}

View File

@@ -1,5 +1,11 @@
import { shouldDo } from 'common/script/cron';
// Library / Utility function
import { orderSingleTypeTasks } from 'client/libs/store/helpers/orderTasks.js';
import { getActiveFilter } from 'client/libs/store/helpers/filterTasks.js';
import sortBy from 'lodash/sortBy';
// Return all the tags belonging to an user task
export function getTagsFor (store) {
return (task) => {
@@ -109,3 +115,47 @@ export function getTaskClasses (store) {
}
};
}
// Returns all list for given task type
export function getUnfilteredTaskList ({state}) {
return (type) => state.tasks.data[`${type}s`];
}
// Returns filtered, sorted, ordered, tag filtered, and search filtered task list
// @TODO: sort task list based on used preferences
export function getFilteredTaskList ({state, getters}) {
return ({
type,
filterType = '',
}) => {
// get requested tasks
// check if task list has been passed as override props
// assumption: type will always be passed as param
let requestedTasks = getters['tasks:getUnfilteredTaskList'](type);
let userPreferences = state.user.data.preferences;
let taskOrderForType = state.user.data.tasksOrder[type];
// order tasks based on user set task order
// Still needs unit test for this..
if (requestedTasks.length > 0 && ['scheduled', 'due'].indexOf(filterType.label) === -1) {
requestedTasks = orderSingleTypeTasks(requestedTasks, taskOrderForType);
}
let selectedFilter = getActiveFilter(type, filterType);
// Pass user preferences to the filter function which uses currying
if (type === 'daily' && (filterType === 'due' || filterType === 'notDue')) {
selectedFilter = {
...selectedFilter,
filterFn: selectedFilter.filterFn(userPreferences),
};
}
requestedTasks = requestedTasks.filter(selectedFilter.filterFn);
if (selectedFilter.sort) {
requestedTasks = sortBy(requestedTasks, selectedFilter.sort);
}
return requestedTasks;
};
}

View File

@@ -4,4 +4,16 @@ export function data (store) {
export function gems (store) {
return store.state.user.data.balance * 4;
}
}
export function buffs (store) {
return (key) => store.state.user.data.stats.buffs[key];
}
export function preferences (store) {
return store.state.user.data.preferences;
}
export function tasksOrder (store) {
return (type) => store.state.user.tasksOrder[`${type}s`];
}

View File

@@ -5,6 +5,8 @@
"innCheckIn": "Rest in the Inn",
"innText": "You're resting in the Inn! While checked-in, your Dailies won't hurt you at the day's end, but they will still refresh every day. Be warned: If you are participating in a Boss Quest, the Boss will still damage you for your Party mates' missed Dailies unless they are also in the Inn! Also, your own damage to the Boss (or items collected) will not be applied until you check out of the Inn.",
"innTextBroken": "You're resting in the Inn, I guess... While checked-in, your Dailies won't hurt you at the day's end, but they will still refresh every day... If you are participating in a Boss Quest, the Boss will still damage you for your Party mates' missed Dailies... unless they are also in the Inn... Also, your own damage to the Boss (or items collected) will not be applied until you check out of the Inn... so tired...",
"innCheckOutBanner": "You are currently checked into the Inn. Your Dailies won't damage you and you won't make progress towards Quests.",
"resumeDamage": "Resume Damage",
"helpfulLinks": "Helpful Links",
"communityGuidelinesLink": "Community Guidelines",
"lookingForGroup": "Looking for Group (Party Wanted) Posts",
@@ -226,6 +228,7 @@
"inviteMustNotBeEmpty": "Invite must not be empty.",
"partyMustbePrivate": "Parties must be private",
"userAlreadyInGroup": "UserID: <%= userId %>, User \"<%= username %>\" already in that group.",
"youAreAlreadyInGroup": "You are already a member of this group.",
"cannotInviteSelfToGroup": "You cannot invite yourself to a group.",
"userAlreadyInvitedToGroup": "UserID: <%= userId %>, User \"<%= username %>\" already invited to that group.",
"userAlreadyPendingInvitation": "UserID: <%= userId %>, User \"<%= username %>\" already pending invitation.",

View File

@@ -60,6 +60,7 @@
"messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!",
"messageCannotFlagSystemMessages": "You cannot flag a system message. If you need to report a violation of the Community Guidelines related to this message, please email a screenshot and explanation to Lemoness at <%= communityManagerEmail %>.",
"messageGroupChatSpam": "Whoops, looks like you're posting too many messages! Please wait a minute and try again. The Tavern chat only holds 200 messages at a time, so Habitica encourages posting longer, more thoughtful messages and consolidating replies. Can't wait to hear what you have to say. :)",
"messageCannotLeaveWhileQuesting": "You cannot accept this party invitation while you are in a quest. If you'd like to join this party, you must first abort your quest, which you can do from your party screen. You will be given back the quest scroll.",
"messageUserOperationProtected": "path `<%= operation %>` was not saved, as it's a protected path.",
"messageUserOperationNotFound": "<%= operation %> operation not found",

View File

@@ -72,11 +72,13 @@
"questVice1Text": "Vice, Part 1: Free Yourself of the Dragon's Influence",
"questVice1Notes": "<p>They say there lies a terrible evil in the caverns of Mt. Habitica. A monster whose presence twists the wills of the strong heroes of the land, turning them towards bad habits and laziness! The beast is a grand dragon of immense power and comprised of the shadows themselves: Vice, the treacherous Shadow Wyrm. Brave Habiteers, stand up and defeat this foul beast once and for all, but only if you believe you can stand against its immense power. </p><h3>Vice Part 1: </h3><p>How can you expect to fight the beast if it already has control over you? Don't fall victim to laziness and vice! Work hard to fight against the dragon's dark influence and dispel his hold on you!</p>",
"questVice1Boss": "Vice's Shade",
"questVice1Completion": "With Vice's influence over you dispelled, you feel a surge of strength you didn't know you had return to you. Congratulations! But a more frightening foe awaits...",
"questVice1DropVice2Quest": "Vice Part 2 (Scroll)",
"questVice2Text": "Vice, Part 2: Find the Lair of the Wyrm",
"questVice2Notes": "With Vice's influence over you dispelled, you feel a surge of strength you didn't know you had return to you. Confident in yourselves and your ability to withstand the wyrm's influence, your party makes its way to Mt. Habitica. You approach the entrance to the mountain's caverns and pause. Swells of shadows, almost like fog, wisp out from the opening. It is near impossible to see anything in front of you. The light from your lanterns seem to end abruptly where the shadows begin. It is said that only magical light can pierce the dragon's infernal haze. If you can find enough light crystals, you could make your way to the dragon.",
"questVice2Notes": "Confident in yourselves and your ability to withstand the influence of Vice the Shadow Wyrm, your Party makes its way to Mt. Habitica. You approach the entrance to the mountain's caverns and pause. Swells of shadows, almost like fog, wisp out from the opening. It is near impossible to see anything in front of you. The light from your lanterns seem to end abruptly where the shadows begin. It is said that only magical light can pierce the dragon's infernal haze. If you can find enough light crystals, you could make your way to the dragon.",
"questVice2CollectLightCrystal": "Light Crystals",
"questVice2Completion": "As you lift the final crystal aloft, the shadows are dispelled, and your path forward is clear. With a quickening heart, you step forward into the cavern.",
"questVice2DropVice3Quest": "Vice Part 3 (Scroll)",
"questVice3Text": "Vice, Part 3: Vice Awakens",
@@ -91,15 +93,17 @@
"questMoonstone1Text": "Recidivate, Part 1: The Moonstone Chain",
"questMoonstone1Notes": "A terrible affliction has struck Habiticans. Bad Habits thought long-dead are rising back up with a vengeance. Dishes lie unwashed, textbooks linger unread, and procrastination runs rampant!<br><br>You track some of your own returning Bad Habits to the Swamps of Stagnation and discover the culprit: the ghostly Necromancer, Recidivate. You rush in, weapons swinging, but they slide through her specter uselessly.<br><br>\"Dont bother,\" she hisses with a dry rasp. \"Without a chain of moonstones, nothing can harm me and master jeweler @aurakami scattered all the moonstones across Habitica long ago!\" Panting, you retreat... but you know what you must do.",
"questMoonstone1CollectMoonstone": "Moonstones",
"questMoonstone1Completion": "At last, you manage to pull the final moonstone from the swampy sludge. Its time to go fashion your collection into a weapon that can finally defeat Recidivate!",
"questMoonstone1DropMoonstone2Quest": "Recidivate, Part 2: Recidivate the Necromancer (Scroll)",
"questMoonstone2Text": "Recidivate, Part 2: Recidivate the Necromancer",
"questMoonstone2Notes": "The brave weaponsmith @Inventrix helps you fashion the enchanted moonstones into a chain. Youre ready to confront Recidivate at last, but as you enter the Swamps of Stagnation, a terrible chill sweeps over you.<br><br>Rotting breath whispers in your ear. \"Back again? How delightful...\" You spin and lunge, and under the light of the moonstone chain, your weapon strikes solid flesh. \"You may have bound me to the world once more,\" Recidivate snarls, \"but now it is time for you to leave it!\"",
"questMoonstone2Boss": "The Necromancer",
"questMoonstone2Completion": "Recidivate staggers backwards under your final blow, and for a moment, your heart brightens but then she throws back her head and lets out a horrible laugh. Whats happening?",
"questMoonstone2DropMoonstone3Quest": "Recidivate, Part 3: Recidivate Transformed (Scroll)",
"questMoonstone3Text": "Recidivate, Part 3: Recidivate Transformed",
"questMoonstone3Notes": "Recidivate crumples to the ground, and you strike at her with the moonstone chain. To your horror, Recidivate seizes the gems, eyes burning with triumph.<br><br>\"Foolish creature of flesh!\" she shouts. \"These moonstones will restore me to a physical form, true, but not as you imagined. As the full moon waxes from the dark, so too does my power flourish, and from the shadows I summon the specter of your most feared foe!\"<br><br>A sickly green fog rises from the swamp, and Recidivates body writhes and contorts into a shape that fills you with dread the undead body of Vice, horribly reborn.",
"questMoonstone3Notes": "Laughing wickedly, Recidivate crumples to the ground, and you strike at her again with the moonstone chain. To your horror, Recidivate seizes the gems, eyes burning with triumph.<br><br>\"Foolish creature of flesh!\" she shouts. \"These moonstones will restore me to a physical form, true, but not as you imagined. As the full moon waxes from the dark, so too does my power flourish, and from the shadows I summon the specter of your most feared foe!\"<br><br>A sickly green fog rises from the swamp, and Recidivates body writhes and contorts into a shape that fills you with dread the undead body of Vice, horribly reborn.",
"questMoonstone3Completion": "Your breath comes hard and sweat stings your eyes as the undead Wyrm collapses. The remains of Recidivate dissipate into a thin grey mist that clears quickly under the onslaught of a refreshing breeze, and you hear the distant, rallying cries of Habiticans defeating their Bad Habits for once and for all.<br><br>@Baconsaur the beast master swoops down on a gryphon. \"I saw the end of your battle from the sky, and I was greatly moved. Please, take this enchanted tunic your bravery speaks of a noble heart, and I believe you were meant to have it.\"",
"questMoonstone3Boss": "Necro-Vice",
"questMoonstone3DropRottenMeat": "Rotten Meat (Food)",
@@ -109,11 +113,13 @@
"questGoldenknight1Text": "The Golden Knight, Part 1: A Stern Talking-To",
"questGoldenknight1Notes": "The Golden Knight has been getting on poor Habiticans' cases. Didn't do all of your Dailies? Checked off a negative Habit? She will use this as a reason to harass you about how you should follow her example. She is the shining example of a perfect Habitican, and you are naught but a failure. Well, that is not nice at all! Everyone makes mistakes. They should not have to be met with such negativity for it. Perhaps it is time you gather some testimonies from hurt Habiticans and give the Golden Knight a stern talking-to!",
"questGoldenknight1CollectTestimony": "Testimonies",
"questGoldenknight1Completion": "Look at all these testimonies! Surely this will be enough to convince the Golden Knight. Now all you need to do is find her.",
"questGoldenknight1DropGoldenknight2Quest": "The Golden Knight Part 2: Gold Knight (Scroll)",
"questGoldenknight2Text": "The Golden Knight, Part 2: Gold Knight",
"questGoldenknight2Notes": "Armed with dozens of Habiticans' testimonies, you finally confront the Golden Knight. You begin to recite the Habitcans' complaints to her, one by one. \"And @Pfeffernusse says that your constant bragging-\" The knight raises her hand to silence you and scoffs, \"Please, these people are merely jealous of my success. Instead of complaining, they should simply work as hard as I! Perhaps I shall show you the power you can attain through diligence such as mine!\" She raises her morningstar and prepares to attack you!",
"questGoldenknight2Boss": "Gold Knight",
"questGoldenknight2Completion": "The Golden Knight lowers her Morningstar in consternation. “I apologize for my rash outburst,” she says. “The truth is, its painful to think that Ive been inadvertently hurting others, and it made me lash out in defense… but perhaps I can still apologize?",
"questGoldenknight2DropGoldenknight3Quest": "The Golden Knight Part 3: The Iron Knight (Scroll)",
"questGoldenknight3Text": "The Golden Knight, Part 3: The Iron Knight",
@@ -160,14 +166,16 @@
"questAtom1Notes": "You reach the shores of Washed-Up Lake for some well-earned relaxation... But the lake is polluted with unwashed dishes! How did this happen? Well, you simply cannot allow the lake to be in this state. There is only one thing you can do: clean the dishes and save your vacation spot! Better find some soap to clean up this mess. A lot of soap...",
"questAtom1CollectSoapBars": "Bars of Soap",
"questAtom1Drop": "The SnackLess Monster (Scroll)",
"questAtom1Completion": "After some thorough scrubbing, all the dishes are stacked safely on the shore! You stand back and proudly survey your hard work.",
"questAtom2Text": "Attack of the Mundane, Part 2: The SnackLess Monster",
"questAtom2Notes": "Phew, this place is looking a lot nicer with all these dishes cleaned. Maybe, you can finally have some fun now. Oh - there seems to be a pizza box floating in the lake. Well, what's one more thing to clean really? But alas, it is no mere pizza box! With a sudden rush the box lifts from the water to reveal itself to be the head of a monster. It cannot be! The fabled SnackLess Monster?! It is said it has existed hidden in the lake since prehistoric times: a creature spawned from the leftover food and trash of the ancient Habiticans. Yuck!",
"questAtom2Boss": "The SnackLess Monster",
"questAtom2Drop": "The Laundromancer (Scroll)",
"questAtom2Completion": "With a deafening cry, and five delicious types of cheese bursting from its mouth, the Snackless Monster falls to pieces. Well done, brave adventurer! But wait... is there something else wrong with the lake?",
"questAtom3Text": "Attack of the Mundane, Part 3: The Laundromancer",
"questAtom3Notes": "With a deafening cry, and five delicious types of cheese bursting from its mouth, the SnackLess Monster falls to pieces. \"HOW DARE YOU!\" booms a voice from beneath the water's surface. A robed, blue figure emerges from the water, wielding a magic toilet brush. Filthy laundry begins to bubble up to the surface of the lake. \"I am the Laundromancer!\" he angrily announces. \"You have some nerve - washing my delightfully dirty dishes, destroying my pet, and entering my domain with such clean clothes. Prepare to feel the soggy wrath of my anti-laundry magic!\"",
"questAtom3Notes": "Just when you thought that your trials had ended, Washed-Up Lake begins to froth violently. “HOW DARE YOU! booms a voice from beneath the water's surface. A robed, blue figure emerges from the water, wielding a magic toilet brush. Filthy laundry begins to bubble up to the surface of the lake. \"I am the Laundromancer!\" he angrily announces. \"You have some nerve - washing my delightfully dirty dishes, destroying my pet, and entering my domain with such clean clothes. Prepare to feel the soggy wrath of my anti-laundry magic!\"",
"questAtom3Completion": "The wicked Laundromancer has been defeated! Clean laundry falls in piles all around you. Things are looking much better around here. As you begin to wade through the freshly pressed armor, a glint of metal catches your eye, and your gaze falls upon a gleaming helm. The original owner of this shining item may be unknown, but as you put it on, you feel the warming presence of a generous spirit. Too bad they didn't sew on a nametag.",
"questAtom3Boss": "The Laundromancer",
"questAtom3DropPotion": "Base Hatching Potion",

View File

@@ -411,6 +411,7 @@ let quests = {
vice1: {
text: t('questVice1Text'),
notes: t('questVice1Notes'),
completion: t('questVice1Completion'),
group: 'questGroupVice',
value: 4,
lvl: 30,
@@ -436,6 +437,7 @@ let quests = {
vice2: {
text: t('questVice2Text'),
notes: t('questVice2Notes'),
completion: t('questVice2Completion'),
group: 'questGroupVice',
value: 4,
lvl: 30,
@@ -664,6 +666,7 @@ let quests = {
atom1: {
text: t('questAtom1Text'),
notes: t('questAtom1Notes'),
completion: t('questAtom1Completion'),
group: 'questGroupAtom',
value: 4,
lvl: 15,
@@ -690,6 +693,7 @@ let quests = {
atom2: {
text: t('questAtom2Text'),
notes: t('questAtom2Notes'),
completion: t('questAtom2Completion'),
group: 'questGroupAtom',
previous: 'atom1',
value: 4,
@@ -846,6 +850,7 @@ let quests = {
moonstone1: {
text: t('questMoonstone1Text'),
notes: t('questMoonstone1Notes'),
completion: t('questMoonstone1Completion'),
group: 'questGroupMoonstone',
value: 4,
lvl: 60,
@@ -872,6 +877,7 @@ let quests = {
moonstone2: {
text: t('questMoonstone2Text'),
notes: t('questMoonstone2Notes'),
completion: t('questMoonstone2Completion'),
group: 'questGroupMoonstone',
value: 4,
lvl: 60,
@@ -956,6 +962,7 @@ let quests = {
goldenknight1: {
text: t('questGoldenknight1Text'),
notes: t('questGoldenknight1Notes'),
completion: t('questGoldenknight1Completion'),
group: 'questGroupGoldenknight',
value: 4,
lvl: 40,
@@ -982,6 +989,7 @@ let quests = {
goldenknight2: {
text: t('questGoldenknight2Text'),
notes: t('questGoldenknight2Notes'),
completion: t('questGoldenknight2Completion'),
group: 'questGroupGoldenknight',
value: 4,
previous: 'goldenknight1',

View File

@@ -41,9 +41,9 @@ module.exports = function randomDrop (user, options, req = {}, analytics) {
(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));
(user._tmp.crit || 1) * (1 + 0.5 * (reduce(task.checklist, (m, i) => { // +50% per checklist item complete. TODO: make this into X individual drop chances instead
return m + (i.completed ? 1 : 0); // eslint-disable-line indent
}, 0) || 0)); // eslint-disable-line indent
chance = diminishingReturns(chance, 0.75);
if (predictableRandom() < chance) {

View File

@@ -40,3 +40,12 @@ export class NotFound extends CustomError {
this.message = customMessage || 'Not found.';
}
}
export class NotImplementedError extends CustomError {
constructor (str) {
super();
this.name = this.constructor.name;
this.message = `Method: '${str}' not implemented`;
}
}

View File

@@ -22,6 +22,7 @@ module.exports = {
],
availableQuests: [
'egg',
],
featuredSet: 'comfortingKittySet',

View File

@@ -478,7 +478,7 @@ shops.getSeasonalShopCategories = function getSeasonalShopCategories (user, lang
};
category.items = map(quests, (quest) => {
return getItemInfo(user, 'seasonalQuest', quest, language);
return getItemInfo(user, 'seasonalQuest', quest, officialPinnedItems, language);
});
categories.push(category);

View File

@@ -0,0 +1,127 @@
import i18n from '../../i18n';
import {
NotAuthorized, NotImplementedError,
} from '../../libs/errors';
import _merge from 'lodash/merge';
import _get from 'lodash/get';
export class AbstractBuyOperation {
/**
* @param {User} user - the User-Object
* @param {Request} req - the Request-Object
* @param {analytics} analytics
*/
constructor (user, req, analytics) {
this.user = user;
this.req = req || {};
this.analytics = analytics;
this.quantity = _get(req, 'quantity', 1);
}
/**
* Shortcut to get the translated string without passing `req.language`
* @param {String} key - translation key
* @param {*=} params
* @returns {*|string}
*/
// eslint-disable-next-line no-unused-vars
i18n (key, params = {}) {
return i18n.t.apply(null, [...arguments, this.req.language]);
}
/**
* If the Operation allows purchasing items by quantity
* @returns Boolean
*/
multiplePurchaseAllowed () {
throw new NotImplementedError('multiplePurchaseAllowed');
}
/**
* Method is called to save the params as class-fields in order to access them
*/
extractAndValidateParams () {
throw new NotImplementedError('extractAndValidateParams');
}
executeChanges () {
throw new NotImplementedError('executeChanges');
}
analyticsData () {
throw new NotImplementedError('sendToAnalytics');
}
purchase () {
if (!this.multiplePurchaseAllowed() && this.quantity > 1) {
throw new NotAuthorized(this.i18n('messageNotAbleToBuyInBulk'));
}
this.extractAndValidateParams(this.user, this.req);
let resultObj = this.executeChanges(this.user, this.item, this.req);
if (this.analytics) {
this.sendToAnalytics(this.analyticsData());
}
return resultObj;
}
sendToAnalytics (additionalData = {}) {
// spread-operator produces an "unexpected token" error
let analyticsData = _merge(additionalData, {
// ...additionalData,
uuid: this.user._id,
category: 'behavior',
headers: this.req.headers,
});
if (this.multiplePurchaseAllowed()) {
analyticsData.quantityPurchased = this.quantity;
}
this.analytics.track('acquire item', analyticsData);
}
}
export class AbstractGoldItemOperation extends AbstractBuyOperation {
constructor (user, req, analytics) {
super(user, req, analytics);
}
getItemValue (item) {
return item.value;
}
canUserPurchase (user, item) {
this.item = item;
let itemValue = this.getItemValue(item);
let userGold = user.stats.gp;
if (userGold < itemValue * this.quantity) {
throw new NotAuthorized(this.i18n('messageNotEnoughGold'));
}
if (item.canOwn && !item.canOwn(user)) {
throw new NotAuthorized(this.i18n('cannotBuyItem'));
}
}
substractCurrency (user, item, quantity = 1) {
let itemValue = this.getItemValue(item);
user.stats.gp -= itemValue * quantity;
}
analyticsData () {
return {
itemKey: this.item.key,
itemType: 'Market',
acquireMethod: 'Gold',
goldCost: this.getItemValue(this.item),
};
}
}

View File

@@ -5,7 +5,7 @@ import {
} from '../../libs/errors';
import buyHealthPotion from './buyHealthPotion';
import buyArmoire from './buyArmoire';
import buyGear from './buyGear';
import {BuyMarketGearOperation} from './buyMarketGear';
import buyMysterySet from './buyMysterySet';
import buyQuest from './buyQuest';
import buySpecialSpell from './buySpecialSpell';
@@ -58,9 +58,12 @@ module.exports = function buy (user, req = {}, analytics) {
case 'special':
buyRes = buySpecialSpell(user, req, analytics);
break;
default:
buyRes = buyGear(user, req, analytics);
default: {
const buyOp = new BuyMarketGearOperation(user, req, analytics);
buyRes = buyOp.purchase();
break;
}
}
return buyRes;

View File

@@ -1,82 +0,0 @@
import content from '../../content/index';
import i18n from '../../i18n';
import get from 'lodash/get';
import pick from 'lodash/pick';
import splitWhitespace from '../../libs/splitWhitespace';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../../libs/errors';
import handleTwoHanded from '../../fns/handleTwoHanded';
import ultimateGear from '../../fns/ultimateGear';
import { removePinnedGearAddPossibleNewOnes } from '../pinnedGearUtils';
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));
}
let itemIndex = Number(item.index);
if (Number.isInteger(itemIndex) && content.classes.includes(item.klass)) {
let previousLevelGear = key.replace(/[0-9]/, itemIndex - 1);
let hasPreviousLevelGear = user.items.gear.owned[previousLevelGear];
let checkIndexToType = itemIndex > (item.type === 'weapon' || item.type === 'shield' && item.klass === 'rogue' ? 0 : 1);
if (checkIndexToType && !hasPreviousLevelGear) {
throw new NotAuthorized(i18n.t('previousGearNotOwned', req.language));
}
}
if (user.preferences.autoEquip) {
user.items.gear.equipped[item.type] = item.key;
message = handleTwoHanded(user, item, undefined, req);
}
removePinnedGearAddPossibleNewOnes(user, `gear.flat.${item.key}`, item.key);
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',
headers: req.headers,
});
}
return [
pick(user, splitWhitespace('items achievements stats flags pinnedItems')),
message,
];
};

View File

@@ -0,0 +1,78 @@
import content from '../../content/index';
import get from 'lodash/get';
import pick from 'lodash/pick';
import splitWhitespace from '../../libs/splitWhitespace';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../../libs/errors';
import handleTwoHanded from '../../fns/handleTwoHanded';
import ultimateGear from '../../fns/ultimateGear';
import {removePinnedGearAddPossibleNewOnes} from '../pinnedGearUtils';
import { AbstractGoldItemOperation } from './abstractBuyOperation';
export class BuyMarketGearOperation extends AbstractGoldItemOperation {
constructor (user, req, analytics) {
super(user, req, analytics);
}
multiplePurchaseAllowed () {
return false;
}
extractAndValidateParams (user, req) {
let key = this.key = get(req, 'params.key');
if (!key) throw new BadRequest(this.i18n('missingKeyParam'));
let item = content.gear.flat[key];
if (!item) throw new NotFound(this.i18n('itemNotFound', {key}));
this.canUserPurchase(user, item);
if (user.items.gear.owned[item.key]) {
throw new NotAuthorized(this.i18n('equipmentAlreadyOwned'));
}
let itemIndex = Number(item.index);
if (Number.isInteger(itemIndex) && content.classes.includes(item.klass)) {
let previousLevelGear = key.replace(/[0-9]/, itemIndex - 1);
let hasPreviousLevelGear = user.items.gear.owned[previousLevelGear];
let checkIndexToType = itemIndex > (item.type === 'weapon' || item.type === 'shield' && item.klass === 'rogue' ? 0 : 1);
if (checkIndexToType && !hasPreviousLevelGear) {
throw new NotAuthorized(this.i18n('previousGearNotOwned'));
}
}
}
executeChanges (user, item, req) {
let message;
if (user.preferences.autoEquip) {
user.items.gear.equipped[item.type] = item.key;
message = handleTwoHanded(user, item, undefined, req);
}
removePinnedGearAddPossibleNewOnes(user, `gear.flat.${item.key}`, item.key);
if (item.last) ultimateGear(user);
this.substractCurrency(user, item);
if (!message) {
message = this.i18n('messageBought', {
itemText: item.text(req.language),
});
}
return [
pick(user, splitWhitespace('items achievements stats flags pinnedItems')),
message,
];
}
}

View File

@@ -7,7 +7,7 @@ import {
import splitWhitespace from '../libs/splitWhitespace';
import pick from 'lodash/pick';
module.exports = function releaseBoth (user, req = {}, analytics) {
module.exports = function releaseBoth (user, req = {}) {
let animal;
if (!user.achievements.triadBingo) {
@@ -22,19 +22,20 @@ module.exports = function releaseBoth (user, req = {}, analytics) {
let giveBeastMasterAchievement = true;
let giveMountMasterAchievement = true;
if (!user.achievements.triadBingo) {
if (analytics) {
analytics.track('release pets & mounts', {
uuid: user._id,
acquireMethod: 'Gems',
gemCost: 6,
category: 'behavior',
headers: req.headers,
});
}
user.balance -= 1.5;
}
// @TODO: We are only offering the free version now
// if (!user.achievements.triadBingo) {
// if (analytics) {
// analytics.track('release pets & mounts', {
// uuid: user._id,
// acquireMethod: 'Gems',
// gemCost: 6,
// category: 'behavior',
// headers: req.headers,
// });
// }
//
// user.balance -= 1.5;
// }
let mountInfo = content.mountInfo[user.items.currentMount];

View File

@@ -629,7 +629,7 @@ api.updateEmail = {
if (validationErrors) throw validationErrors;
let emailAlreadyInUse = await User.findOne({
'auth.local.email': req.body.newEmail,
'auth.local.email': req.body.newEmail.toLowerCase(),
}).select({_id: 1}).lean().exec();
if (emailAlreadyInUse) throw new NotAuthorized(res.t('cannotFulfillReq', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL }));
@@ -643,7 +643,7 @@ api.updateEmail = {
await passwordUtils.convertToBcrypt(user, password);
}
user.auth.local.email = req.body.newEmail;
user.auth.local.email = req.body.newEmail.toLowerCase();
await user.save();
return res.respond(200, { email: user.auth.local.email });

View File

@@ -16,7 +16,6 @@ import {
NotAuthorized,
} from '../../libs/errors';
import * as Tasks from '../../models/task';
import Bluebird from 'bluebird';
import csvStringify from '../../libs/csvStringify';
import {
createTasks,
@@ -254,7 +253,7 @@ api.joinChallenge = {
addUserJoinChallengeNotification(user);
// Add all challenge's tasks to user's tasks and save the challenge
let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
let results = await Promise.all([challenge.syncToUser(user), challenge.save()]);
let response = results[1].toJSON();
response.group = getChallengeGroupResponse(group);
@@ -306,7 +305,7 @@ api.leaveChallenge = {
if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound'));
// Unlink challenge's tasks from user's tasks and save the challenge
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
await Promise.all([challenge.unlinkTasks(user, keep), challenge.save()]);
res.analytics.track('challenge leave', {
uuid: user._id,
@@ -356,16 +355,21 @@ api.getUserChallenges = {
let challenges = await Challenge.find({
$or: orOptions,
})
.sort('-official -createdAt')
.sort('-createdAt')
// see below why we're not using populate
// .populate('group', basicGroupFields)
// .populate('leader', nameFields)
.exec();
let resChals = challenges.map(challenge => challenge.toJSON());
resChals = _.orderBy(resChals, [challenge => {
return challenge.categories.map(category => category.slug).includes('habitica_official');
}], ['desc']);
// 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([
await Promise.all(resChals.map((chal, index) => {
return Promise.all([
User.findById(chal.leader).select(nameFields).exec(),
Group.findById(chal.group).select(basicGroupFields).exec(),
]).then(populatedData => {
@@ -413,13 +417,18 @@ api.getGroupChallenges = {
if (!group) throw new NotFound(res.t('groupNotFound'));
let challenges = await Challenge.find({group: groupId})
.sort('-official -createdAt')
.sort('-createdAt')
// .populate('leader', nameFields) // Only populate the leader as the group is implicit
.exec();
let resChals = challenges.map(challenge => challenge.toJSON());
resChals = _.orderBy(resChals, [challenge => {
return challenge.categories.map(category => category.slug).includes('habitica_official');
}], ['desc']);
// 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) => {
await Promise.all(resChals.map((chal, index) => {
return User
.findById(chal.leader)
.select(nameFields)
@@ -511,7 +520,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 Bluebird.all([
let [members, tasks] = await Promise.all([
User.find({challenges: challengeId})
.select(nameFields)
.sort({_id: 1})

View File

@@ -14,7 +14,6 @@ import pusher from '../../libs/pusher';
import { getAuthorEmailFromMessage } from '../../libs/chat';
import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory';
import nconf from 'nconf';
import Bluebird from 'bluebird';
import bannedWords from '../../libs/bannedWords';
import guildsAllowingBannedWords from '../../libs/guildsAllowingBannedWords';
import { getMatchesByWordArray } from '../../libs/stringUtils';
@@ -184,7 +183,7 @@ api.postChat = {
toSave.push(user.save());
}
let [savedGroup] = await Bluebird.all(toSave);
let [savedGroup] = await Promise.all(toSave);
// realtime chat is only enabled for private groups (for now only for parties)
if (savedGroup.privacy === 'private' && savedGroup.type === 'party') {

View File

@@ -1,17 +1,17 @@
import common from '../../../common';
import _ from 'lodash';
import { langCodes } from '../../libs/i18n';
import Bluebird from 'bluebird';
import fsCallback from 'fs';
import path from 'path';
import logger from '../../libs/logger';
import util from 'util';
// 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}),
readFile: util.promisify(fsCallback.readFile).bind(fsCallback),
writeFile: util.promisify(fsCallback.writeFile).bind(fsCallback),
stat: util.promisify(fsCallback.stat).bind(fsCallback),
mkdir: util.promisify(fsCallback.mkdir).bind(fsCallback),
};
let api = {};

View File

@@ -1,5 +1,4 @@
import { authWithHeaders } from '../../middlewares/auth';
import Bluebird from 'bluebird';
import _ from 'lodash';
import nconf from 'nconf';
import {
@@ -137,7 +136,7 @@ api.createGroup = {
user.party._id = group._id;
}
let results = await Bluebird.all([user.save(), group.save()]);
let results = await Promise.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
@@ -194,7 +193,7 @@ api.createGroupPlan = {
group.leader = user._id;
user.guilds.push(group._id);
let results = await Bluebird.all([user.save(), group.save()]);
let results = await Promise.all([user.save(), group.save()]);
let savedGroup = results[1];
// Analytics
@@ -337,7 +336,7 @@ api.getGroups = {
if (req.query.search) {
filters.$or = [];
const searchWords = req.query.search.split(' ').join('|');
const searchWords = _.escapeRegExp(req.query.search).split(' ').join('|');
const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') };
filters.$or.push({name: searchQuery});
filters.$or.push({description: searchQuery});
@@ -519,6 +518,18 @@ api.joinGroup = {
if (inviterParty) {
inviter = inviterParty.inviter;
// If user was in a different party (when partying solo you can be invited to a new party)
// make them leave that party before doing anything
if (user.party._id) {
let userPreviousParty = await Group.getGroup({user, groupId: user.party._id});
if (userPreviousParty.memberCount === 1 && user.party.quest.key) {
throw new NotAuthorized(res.t('messageCannotLeaveWhileQuesting'));
}
if (userPreviousParty) await userPreviousParty.leave(user);
}
// Clear all invitations of new user
user.invitations.parties = [];
user.invitations.party = {};
@@ -531,13 +542,6 @@ api.joinGroup = {
group.markModified('quest.members');
}
// If user was in a different party (when partying solo you can be invited to a new party)
// make them 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;
@@ -555,7 +559,7 @@ api.joinGroup = {
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'));
throw new NotAuthorized(res.t('youAreAlreadyInGroup'));
}
user.guilds.push(group._id); // Add group to user's guilds
if (!user.achievements.joinedGuild) {
@@ -617,7 +621,7 @@ api.joinGroup = {
}
}
promises = await Bluebird.all(promises);
promises = await Promise.all(promises);
let response = Group.toJSONCleanChat(promises[0], user);
let leader = await User.findById(response.leader).select(nameFields).exec();
@@ -915,7 +919,7 @@ api.removeGroupMember = {
let message = req.query.message || req.body.message;
_sendMessageToRemoved(group, member, message, isInGroup);
await Bluebird.all([
await Promise.all([
member.save(),
group.save(),
]);
@@ -1167,7 +1171,7 @@ api.inviteToGroup = {
if (uuids) {
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
let uuidResults = await Bluebird.all(uuidInvites);
let uuidResults = await Promise.all(uuidInvites);
results.push(...uuidResults);
}
@@ -1175,7 +1179,7 @@ api.inviteToGroup = {
let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
user.invitesSent += emails.length;
await user.save();
let emailResults = await Bluebird.all(emailInvites);
let emailResults = await Promise.all(emailInvites);
results.push(...emailResults);
}

View File

@@ -18,7 +18,6 @@ import {
getUserInfo,
sendTxn as sendTxnEmail,
} from '../../libs/email';
import Bluebird from 'bluebird';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import { achievements } from '../../../../website/common/';
@@ -552,7 +551,7 @@ api.transferGems = {
receiver.balance += amount;
sender.balance -= amount;
let promises = [receiver.save(), sender.save()];
await Bluebird.all(promises);
await Promise.all(promises);
// generate the message in both languages, so both users can understand it
let receiverLang = receiver.preferences.language;

View File

@@ -1,5 +1,4 @@
import _ from 'lodash';
import Bluebird from 'bluebird';
import { authWithHeaders } from '../../middlewares/auth';
import analytics from '../../libs/analyticsService';
import {
@@ -109,7 +108,7 @@ api.inviteToQuest = {
await group.startQuest(user);
}
let [savedGroup] = await Bluebird.all([
let [savedGroup] = await Promise.all([
group.save(),
user.save(),
]);
@@ -312,7 +311,7 @@ api.forceStart = {
await group.startQuest(user);
let [savedGroup] = await Bluebird.all([
let [savedGroup] = await Promise.all([
group.save(),
user.save(),
]);
@@ -372,7 +371,7 @@ api.cancelQuest = {
group.quest = Group.cleanGroupQuest();
group.markModified('quest');
let [savedGroup] = await Bluebird.all([
let [savedGroup] = await Promise.all([
group.save(),
User.update(
{'party._id': groupId},
@@ -441,7 +440,7 @@ api.abortQuest = {
group.quest = Group.cleanGroupQuest();
group.markModified('quest');
let [groupSaved] = await Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]);
let [groupSaved] = await Promise.all([group.save(), memberUpdates, questLeaderUpdate]);
res.respond(200, groupSaved.quest);
},
@@ -486,7 +485,7 @@ api.leaveQuest = {
user.party.quest = Group.cleanQuestProgress();
user.markModified('party.quest');
let [savedGroup] = await Bluebird.all([
let [savedGroup] = await Promise.all([
group.save(),
user.save(),
]);

View File

@@ -20,7 +20,6 @@ import {
setNextDue,
} from '../../libs/taskManager';
import common from '../../../common';
import Bluebird from 'bluebird';
import _ from 'lodash';
import logger from '../../libs/logger';
import moment from 'moment';
@@ -598,7 +597,7 @@ api.scoreTask = {
});
managerPromises.push(task.save());
await Bluebird.all(managerPromises);
await Promise.all(managerPromises);
throw new NotAuthorized(res.t('taskApprovalHasBeenRequested'));
}
@@ -647,7 +646,7 @@ api.scoreTask = {
task.save(),
];
if (taskOrderPromise) promises.push(taskOrderPromise);
let results = await Bluebird.all(promises);
let results = await Promise.all(promises);
let savedUser = results[0];
@@ -1132,7 +1131,7 @@ api.unlinkAllTasks = {
if (!validTasks) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (keep === 'keep-all') {
await Bluebird.all(tasks.map(task => {
await Promise.all(tasks.map(task => {
task.challenge = {};
return task.save();
}));
@@ -1149,7 +1148,7 @@ api.unlinkAllTasks = {
toSave.push(user.save());
await Bluebird.all(toSave);
await Promise.all(toSave);
}
res.respond(200, {});
@@ -1199,7 +1198,7 @@ api.unlinkOneTask = {
} 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()]);
await Promise.all([user.save(), task.remove()]);
} else {
await task.remove();
}
@@ -1317,7 +1316,7 @@ api.deleteTask = {
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
if (!challenge) user._v++;
await Bluebird.all([taskOrderUpdate, task.remove()]);
await Promise.all([taskOrderUpdate, task.remove()]);
} else {
await task.remove();
}

View File

@@ -1,5 +1,4 @@
import { authWithHeaders } from '../../../middlewares/auth';
import Bluebird from 'bluebird';
import * as Tasks from '../../../models/task';
import { model as Group } from '../../../models/group';
import { model as User } from '../../../models/user';
@@ -205,7 +204,7 @@ api.assignTask = {
let promises = [];
promises.push(group.syncTask(task, assignedUser));
promises.push(group.save());
await Bluebird.all(promises);
await Promise.all(promises);
res.respond(200, task);
},
@@ -350,7 +349,7 @@ api.approveTask = {
managerPromises.push(task.save());
managerPromises.push(assignedUser.save());
await Bluebird.all(managerPromises);
await Promise.all(managerPromises);
res.respond(200, task);
},

Some files were not shown because too many files have changed in this diff Show More