Merge branch 'develop' into release

This commit is contained in:
Sabe Jones
2019-05-30 15:13:57 -05:00
35 changed files with 760 additions and 148 deletions

View File

@@ -1,18 +1,5 @@
FROM node:10 FROM node:10
WORKDIR /code
# Install global packages COPY package*.json /code/
RUN npm install -g gulp-cli mocha RUN npm install
RUN npm install -g gulp-cli mocha
# Clone Habitica repo and install dependencies
RUN mkdir -p /usr/src/habitrpg
WORKDIR /usr/src/habitrpg
RUN git clone https://github.com/HabitRPG/habitica.git /usr/src/habitrpg
RUN cp config.json.example config.json
RUN npm install
# Create Build dir
RUN mkdir -p ./website/build
# Start Habitica
EXPOSE 3000
CMD ["npm", "start"]

View File

@@ -1,14 +1,45 @@
version: "3" version: "3"
services: services:
client: client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev"]
depends_on:
- server
environment: environment:
- NODE_ENV=development - BASE_URL=http://server:3000
image: habitica
networks:
- habitica
ports:
- "8080:8080"
volumes: volumes:
- '.:/usr/src/habitrpg' - .:/code
- /code/node_modules
server: server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
- mongo
environment: environment:
- NODE_ENV=development - NODE_DB_URI=mongodb://mongo/habitrpg
image: habitica
networks:
- habitica
ports:
- "3000:3000"
volumes: volumes:
- '.:/usr/src/habitrpg' - .:/code
- /code/node_modules
mongo:
image: mongo:3.4
networks:
- habitica
ports:
- "27017:27017"
networks:
habitica:
driver: bridge

View File

@@ -16,7 +16,7 @@ describe('auth middleware', () => {
describe('auth with headers', () => { describe('auth with headers', () => {
it('allows to specify a list of user field that we do not want to load', (done) => { it('allows to specify a list of user field that we do not want to load', (done) => {
const authWithHeaders = authWithHeadersFactory({ const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: ['items', 'flags', 'auth.timestamps'], userFieldsToExclude: ['items'],
}); });
req.headers['x-api-user'] = user._id; req.headers['x-api-user'] = user._id;
@@ -27,11 +27,34 @@ describe('auth middleware', () => {
const userToJSON = res.locals.user.toJSON(); const userToJSON = res.locals.user.toJSON();
expect(userToJSON.items).to.not.exist; expect(userToJSON.items).to.not.exist;
expect(userToJSON.flags).to.not.exist; expect(userToJSON.auth).to.exist;
expect(userToJSON.auth.timestamps).to.not.exist;
done();
});
});
it('makes sure some fields are always included', (done) => {
const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: [
'items', 'auth.timestamps',
'preferences', 'notifications', '_id', 'flags', 'auth', // these are always loaded
],
});
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = user.apiToken;
authWithHeaders(req, res, (err) => {
if (err) return done(err);
const userToJSON = res.locals.user.toJSON();
expect(userToJSON.items).to.not.exist;
expect(userToJSON.auth.timestamps).to.exist;
expect(userToJSON.auth).to.exist; expect(userToJSON.auth).to.exist;
expect(userToJSON.notifications).to.exist; expect(userToJSON.notifications).to.exist;
expect(userToJSON.preferences).to.exist; expect(userToJSON.preferences).to.exist;
expect(userToJSON._id).to.exist;
expect(userToJSON.flags).to.exist;
done(); done();
}); });

View File

@@ -1,7 +1,7 @@
import moment from 'moment'; import moment from 'moment';
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
import validator from 'validator'; import validator from 'validator';
import { sleep } from '../../../helpers/api-unit.helper'; import { sleep, translationCheck } from '../../../helpers/api-unit.helper';
import { import {
SPAM_MESSAGE_LIMIT, SPAM_MESSAGE_LIMIT,
SPAM_MIN_EXEMPT_CONTRIB_LEVEL, SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
@@ -271,7 +271,16 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member attacks Wailing Whale for 5.0 damage.` `Wailing Whale attacks party for 7.5 damage.`'); expect(Group.prototype.sendChat).to.be.calledWith({
message: '`Participating Member attacks Wailing Whale for 5.0 damage. Wailing Whale attacks party for 7.5 damage.`',
info: {
bossDamage: '7.5',
quest: 'whale',
type: 'boss_damage',
user: 'Participating Member',
userDamage: '5.0',
},
});
}); });
it('applies damage only to participating members of party', async () => { it('applies damage only to participating members of party', async () => {
@@ -344,7 +353,10 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledTwice; expect(Group.prototype.sendChat).to.be.calledTwice;
expect(Group.prototype.sendChat).to.be.calledWith('`You defeated Wailing Whale! Questing party members receive the rewards of victory.`'); expect(Group.prototype.sendChat).to.be.calledWith({
message: '`You defeated Wailing Whale! Questing party members receive the rewards of victory.`',
info: { quest: 'whale', type: 'boss_defeated' },
});
}); });
it('calls finishQuest when boss has <= 0 hp', async () => { it('calls finishQuest when boss has <= 0 hp', async () => {
@@ -387,7 +399,10 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledWith(quest.boss.rage.effect('en')); expect(Group.prototype.sendChat).to.be.calledWith({
message: quest.boss.rage.effect('en'),
info: { quest: 'trex_undead', type: 'boss_rage' },
});
expect(party.quest.progress.hp).to.eql(383.5); expect(party.quest.progress.hp).to.eql(383.5);
expect(party.quest.progress.rage).to.eql(0); expect(party.quest.progress.rage).to.eql(0);
}); });
@@ -437,7 +452,10 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledWith(quest.boss.rage.effect('en')); expect(Group.prototype.sendChat).to.be.calledWith({
message: quest.boss.rage.effect('en'),
info: { quest: 'lostMasterclasser4', type: 'boss_rage' },
});
expect(party.quest.progress.rage).to.eql(0); expect(party.quest.progress.rage).to.eql(0);
let drainedUser = await User.findById(participatingMember._id); let drainedUser = await User.findById(participatingMember._id);
@@ -488,7 +506,15 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member found 5 Bars of Soap.`'); expect(Group.prototype.sendChat).to.be.calledWith({
message: '`Participating Member found 5 Bars of Soap.`',
info: {
items: { soapBars: 5 },
quest: 'atom1',
type: 'user_found_items',
user: 'Participating Member',
},
});
}); });
it('sends a chat message if no progress is made', async () => { it('sends a chat message if no progress is made', async () => {
@@ -499,7 +525,15 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member found 0 Bars of Soap.`'); expect(Group.prototype.sendChat).to.be.calledWith({
message: '`Participating Member found 0 Bars of Soap.`',
info: {
items: { soapBars: 0 },
quest: 'atom1',
type: 'user_found_items',
user: 'Participating Member',
},
});
}); });
it('sends a chat message if no progress is made on quest with multiple items', async () => { it('sends a chat message if no progress is made on quest with multiple items', async () => {
@@ -516,9 +550,15 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWithMatch(/`Participating Member found/); expect(Group.prototype.sendChat).to.be.calledWith({
expect(Group.prototype.sendChat).to.be.calledWithMatch(/0 Blue Fins/); message: '`Participating Member found 0 Fire Coral, 0 Blue Fins.`',
expect(Group.prototype.sendChat).to.be.calledWithMatch(/0 Fire Coral/); info: {
items: { blueFins: 0, fireCoral: 0 },
quest: 'dilatoryDistress1',
type: 'user_found_items',
user: 'Participating Member',
},
});
}); });
it('handles collection quests with multiple items', async () => { it('handles collection quests with multiple items', async () => {
@@ -535,8 +575,14 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWithMatch(/`Participating Member found/); expect(Group.prototype.sendChat).to.be.calledWithMatch({
expect(Group.prototype.sendChat).to.be.calledWithMatch(/\d* (Tracks|Broken Twigs)/); message: sinon.match(/`Participating Member found/).and(sinon.match(/\d* (Tracks|Broken Twigs)/)),
info: {
quest: 'evilsanta2',
type: 'user_found_items',
user: 'Participating Member',
},
});
}); });
it('sends message about victory', async () => { it('sends message about victory', async () => {
@@ -547,7 +593,10 @@ describe('Group Model', () => {
party = await Group.findOne({_id: party._id}); party = await Group.findOne({_id: party._id});
expect(Group.prototype.sendChat).to.be.calledTwice; expect(Group.prototype.sendChat).to.be.calledTwice;
expect(Group.prototype.sendChat).to.be.calledWith('`All items found! Party has received their rewards.`'); expect(Group.prototype.sendChat).to.be.calledWith({
message: '`All items found! Party has received their rewards.`',
info: { type: 'all_items_found' },
});
}); });
it('calls finishQuest when all items are found', async () => { it('calls finishQuest when all items are found', async () => {
@@ -718,6 +767,258 @@ describe('Group Model', () => {
expect(res.t).to.not.be.called; expect(res.t).to.not.be.called;
}); });
}); });
describe('translateSystemMessages', () => {
it('translate quest_start', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'quest_start',
quest: 'basilist',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate boss_damage', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'boss_damage',
user: questLeader.profile.name,
quest: 'basilist',
userDamage: 15.3,
bossDamage: 3.7,
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate boss_dont_attack', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'boss_dont_attack',
user: questLeader.profile.name,
quest: 'basilist',
userDamage: 15.3,
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate boss_rage', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'boss_rage',
quest: 'lostMasterclasser3',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate boss_defeated', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'boss_defeated',
quest: 'lostMasterclasser3',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate user_found_items', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'user_found_items',
user: questLeader.profile.name,
quest: 'lostMasterclasser1',
items: {
ancientTome: 3,
forbiddenTome: 2,
hiddenTome: 1,
},
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate all_items_found', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'all_items_found',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate spell_cast_party', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'spell_cast_party',
user: questLeader.profile.name,
class: 'wizard',
spell: 'earth',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate spell_cast_user', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'spell_cast_user',
user: questLeader.profile.name,
class: 'special',
spell: 'snowball',
target: participatingMember.profile.name,
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate quest_cancel', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'quest_cancel',
user: questLeader.profile.name,
quest: 'basilist',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate quest_abort', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'quest_abort',
user: questLeader.profile.name,
quest: 'basilist',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate tavern_quest_completed', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'tavern_quest_completed',
quest: 'stressbeast',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate tavern_boss_rage_tired', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'tavern_boss_rage_tired',
quest: 'stressbeast',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate tavern_boss_rage', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'tavern_boss_rage',
quest: 'dysheartener',
scene: 'market',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate tavern_boss_desperation', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'tavern_boss_desperation',
quest: 'stressbeast',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
it('translate claim_task', async () => {
questLeader.preferences.language = 'en';
party.chat = [{
info: {
type: 'claim_task',
user: questLeader.profile.name,
task: 'Feed the pet',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
translationCheck(toJSON.chat[0].text);
});
});
describe('toJSONCleanChat', () => {
it('shows messages with 1 flag to non-admins', async () => {
party.chat = [{
flagCount: 1,
info: {
type: 'quest_start',
quest: 'basilist',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
expect(toJSON.chat.length).to.equal(1);
});
it('shows messages with >= 2 flag to admins', async () => {
party.chat = [{
flagCount: 3,
info: {
type: 'quest_start',
quest: 'basilist',
},
}];
const admin = new User({'contributor.admin': true});
let toJSON = await Group.toJSONCleanChat(party, admin);
expect(toJSON.chat.length).to.equal(1);
});
it('doesn\'t show flagged messages to non-admins', async () => {
party.chat = [{
flagCount: 3,
info: {
type: 'quest_start',
quest: 'basilist',
},
}];
let toJSON = await Group.toJSONCleanChat(party, questLeader);
expect(toJSON.chat.length).to.equal(0);
});
});
}); });
context('Instance Methods', () => { context('Instance Methods', () => {
@@ -1007,20 +1308,22 @@ describe('Group Model', () => {
}); });
it('formats message', () => { it('formats message', () => {
const chatMessage = party.sendChat('a new message', { const chatMessage = party.sendChat({
_id: 'user-id', message: 'a new message', user: {
profile: { name: 'user name' }, _id: 'user-id',
contributor: { profile: { name: 'user name' },
toObject () { contributor: {
return 'contributor object'; toObject () {
return 'contributor object';
},
}, },
}, backer: {
backer: { toObject () {
toObject () { return 'backer object';
return 'backer object'; },
}, },
}, }}
}); );
const chat = chatMessage; const chat = chatMessage;
@@ -1037,7 +1340,7 @@ describe('Group Model', () => {
}); });
it('formats message as system if no user is passed in', () => { it('formats message as system if no user is passed in', () => {
const chat = party.sendChat('a system message'); const chat = party.sendChat({message: 'a system message'});
expect(chat.text).to.eql('a system message'); expect(chat.text).to.eql('a system message');
expect(validator.isUUID(chat.id)).to.eql(true); expect(validator.isUUID(chat.id)).to.eql(true);
@@ -1052,7 +1355,7 @@ describe('Group Model', () => {
}); });
it('updates users about new messages in party', () => { it('updates users about new messages in party', () => {
party.sendChat('message'); party.sendChat({message: 'message'});
expect(User.update).to.be.calledOnce; expect(User.update).to.be.calledOnce;
expect(User.update).to.be.calledWithMatch({ expect(User.update).to.be.calledWithMatch({
@@ -1066,7 +1369,7 @@ describe('Group Model', () => {
type: 'guild', type: 'guild',
}); });
group.sendChat('message'); group.sendChat({message: 'message'});
expect(User.update).to.be.calledOnce; expect(User.update).to.be.calledOnce;
expect(User.update).to.be.calledWithMatch({ expect(User.update).to.be.calledWithMatch({
@@ -1076,7 +1379,7 @@ describe('Group Model', () => {
}); });
it('does not send update to user that sent the message', () => { it('does not send update to user that sent the message', () => {
party.sendChat('message', {_id: 'user-id', profile: { name: 'user' }}); party.sendChat({message: 'message', user: {_id: 'user-id', profile: { name: 'user' }}});
expect(User.update).to.be.calledOnce; expect(User.update).to.be.calledOnce;
expect(User.update).to.be.calledWithMatch({ expect(User.update).to.be.calledWithMatch({
@@ -1088,7 +1391,7 @@ describe('Group Model', () => {
it('skips sending new message notification for guilds with > 5000 members', () => { it('skips sending new message notification for guilds with > 5000 members', () => {
party.memberCount = 5001; party.memberCount = 5001;
party.sendChat('message'); party.sendChat({message: 'message'});
expect(User.update).to.not.be.called; expect(User.update).to.not.be.called;
}); });
@@ -1096,7 +1399,7 @@ describe('Group Model', () => {
it('skips sending messages to the tavern', () => { it('skips sending messages to the tavern', () => {
party._id = TAVERN_ID; party._id = TAVERN_ID;
party.sendChat('message'); party.sendChat({message: 'message'});
expect(User.update).to.not.be.called; expect(User.update).to.not.be.called;
}); });
@@ -1928,7 +2231,7 @@ describe('Group Model', () => {
await guild.save(); await guild.save();
const groupMessage = guild.sendChat('Test message.'); const groupMessage = guild.sendChat({message: 'Test message.'});
await groupMessage.save(); await groupMessage.save();
await sleep(); await sleep();

View File

@@ -149,7 +149,7 @@ describe('POST /group', () => {
).to.eventually.be.rejected.and.eql({ ).to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('cannotCreatePublicGuildWhenMuted'), message: t('chatPrivilegesRevoked'),
}); });
}); });
}); });

View File

@@ -100,7 +100,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({ .to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('cannotInviteWhenMuted'), message: t('chatPrivilegesRevoked'),
}); });
}); });
@@ -262,7 +262,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({ .to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('cannotInviteWhenMuted'), message: t('chatPrivilegesRevoked'),
}); });
}); });
@@ -436,7 +436,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({ .to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('cannotInviteWhenMuted'), message: t('chatPrivilegesRevoked'),
}); });
}); });
@@ -526,7 +526,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({ .to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('cannotInviteWhenMuted'), message: t('chatPrivilegesRevoked'),
}); });
}); });

View File

@@ -127,7 +127,13 @@ describe('POST /groups/:groupId/quests/abort', () => {
members: {}, members: {},
}); });
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWithMatch(/aborted the party quest Wail of the Whale.`/); expect(Group.prototype.sendChat).to.be.calledWithMatch({
message: sinon.match(/aborted the party quest Wail of the Whale.`/),
info: {
quest: 'whale',
type: 'quest_abort',
},
});
stub.restore(); stub.restore();
}); });

View File

@@ -141,7 +141,14 @@ describe('POST /groups/:groupId/quests/cancel', () => {
members: {}, members: {},
}); });
expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWithMatch(/cancelled the party quest Wail of the Whale.`/); expect(Group.prototype.sendChat).to.be.calledWithMatch({
message: sinon.match(/cancelled the party quest Wail of the Whale.`/),
info: {
quest: 'whale',
type: 'quest_cancel',
user: sinon.match.any,
},
});
stub.restore(); stub.restore();
}); });

View File

@@ -8,6 +8,7 @@ import mongo from './mongo'; // eslint-disable-line
import moment from 'moment'; import moment from 'moment';
import i18n from '../../website/common/script/i18n'; import i18n from '../../website/common/script/i18n';
import * as Tasks from '../../website/server/models/task'; import * as Tasks from '../../website/server/models/task';
export { translationCheck } from './translate';
afterEach((done) => { afterEach((done) => {
sandbox.restore(); sandbox.restore();

View File

@@ -16,3 +16,9 @@ export function translate (key, variables, language) {
return translatedString; return translatedString;
} }
export function translationCheck (translatedString) {
expect(translatedString).to.not.be.empty;
expect(translatedString).to.not.eql(STRING_ERROR_MSG);
expect(translatedString).to.not.match(STRING_DOES_NOT_EXIST_MSG);
}

View File

@@ -35,7 +35,7 @@ sidebar-section(:title="$t('questDetailsTitle')")
.grey-progress-bar .grey-progress-bar
.collect-progress-bar(:style="{width: (group.quest.progress.collect[key] / value.count) * 100 + '%'}") .collect-progress-bar(:style="{width: (group.quest.progress.collect[key] / value.count) * 100 + '%'}")
strong {{group.quest.progress.collect[key]}} / {{value.count}} strong {{group.quest.progress.collect[key]}} / {{value.count}}
div.text-right {{parseFloat(user.party.quest.progress.collectedItems) || 0}} items found div.text-right(v-if='userIsOnQuest') {{parseFloat(user.party.quest.progress.collectedItems) || 0}} items found
.boss-info(v-if='questData.boss') .boss-info(v-if='questData.boss')
.row .row
.col-6 .col-6

View File

@@ -6,12 +6,12 @@ div
report-flag-modal report-flag-modal
send-gems-modal send-gems-modal
b-navbar.topbar.navbar-inverse.static-top(toggleable="lg", type="dark", :class="navbarZIndexClass") b-navbar.topbar.navbar-inverse.static-top(toggleable="lg", type="dark", :class="navbarZIndexClass")
b-navbar-brand.brand b-navbar-brand.brand(aria-label="Habitica")
.logo.svg-icon.d-none.d-xl-block(v-html="icons.logo") .logo.svg-icon.d-none.d-xl-block(v-html="icons.logo")
.svg-icon.gryphon.d-xs-block.d-xl-none .svg-icon.gryphon.d-xs-block.d-xl-none
b-navbar-toggle(target='menu_collapse').menu-toggle b-navbar-toggle(target='menu_collapse').menu-toggle
.quick-menu.mobile-only.form-inline .quick-menu.mobile-only.form-inline
a.item-with-icon(@click="sync", v-b-tooltip.hover.bottom="$t('sync')") a.item-with-icon(@click="sync", v-b-tooltip.hover.bottom="$t('sync')", :aria-label="$t('sync')")
.top-menu-icon.svg-icon(v-html="icons.sync") .top-menu-icon.svg-icon(v-html="icons.sync")
notification-menu.item-with-icon notification-menu.item-with-icon
user-dropdown.item-with-icon user-dropdown.item-with-icon
@@ -64,13 +64,13 @@ div
.top-menu-icon.svg-icon(v-html="icons.hourglasses", v-b-tooltip.hover.bottom="$t('mysticHourglassesTooltip')") .top-menu-icon.svg-icon(v-html="icons.hourglasses", v-b-tooltip.hover.bottom="$t('mysticHourglassesTooltip')")
span {{ userHourglasses }} span {{ userHourglasses }}
.item-with-icon .item-with-icon
.top-menu-icon.svg-icon.gem(v-html="icons.gem", @click='showBuyGemsModal("gems")', v-b-tooltip.hover.bottom="$t('gems')") a.top-menu-icon.svg-icon.gem(:aria-label="$t('gems')", href="#buy-gems" v-html="icons.gem", @click.prevent='showBuyGemsModal("gems")', v-b-tooltip.hover.bottom="$t('gems')")
span {{userGems}} span {{userGems}}
.item-with-icon.gold .item-with-icon.gold
.top-menu-icon.svg-icon(v-html="icons.gold", v-b-tooltip.hover.bottom="$t('gold')") .top-menu-icon.svg-icon(:aria-label="$t('gold')", v-html="icons.gold", v-b-tooltip.hover.bottom="$t('gold')")
span {{Math.floor(user.stats.gp * 100) / 100}} span {{Math.floor(user.stats.gp * 100) / 100}}
.form-inline.desktop-only .form-inline.desktop-only
a.item-with-icon(@click="sync", v-b-tooltip.hover.bottom="$t('sync')") a.item-with-icon(@click="sync", @keyup.enter="sync", role="link", :aria-label="$t('sync')", tabindex="0", v-b-tooltip.hover.bottom="$t('sync')")
.top-menu-icon.svg-icon(v-html="icons.sync") .top-menu-icon.svg-icon(v-html="icons.sync")
notification-menu.item-with-icon notification-menu.item-with-icon
user-dropdown.item-with-icon user-dropdown.item-with-icon
@@ -290,6 +290,7 @@ div
margin-right: 24px; margin-right: 24px;
} }
&:focus /deep/ .top-menu-icon.svg-icon,
&:hover /deep/ .top-menu-icon.svg-icon { &:hover /deep/ .top-menu-icon.svg-icon {
color: $white; color: $white;
} }

View File

@@ -1,7 +1,7 @@
<template lang="pug"> <template lang="pug">
menu-dropdown.item-notifications(:right="true", @toggled="handleOpenStatusChange", :openStatus="openStatus") menu-dropdown.item-notifications(:right="true", @toggled="handleOpenStatusChange", :openStatus="openStatus")
div(slot="dropdown-toggle") div(slot="dropdown-toggle")
div(v-b-tooltip.hover.bottom="$t('notifications')") div(:aria-label="$t('notifications')", v-b-tooltip.hover.bottom="$t('notifications')")
message-count( message-count(
v-if='notificationsCount > 0', v-if='notificationsCount > 0',
:count="notificationsCount", :count="notificationsCount",

View File

@@ -1,7 +1,7 @@
<template lang="pug"> <template lang="pug">
menu-dropdown.item-user(:right="true") menu-dropdown.item-user(:right="true")
div(slot="dropdown-toggle") div(slot="dropdown-toggle")
div(v-b-tooltip.hover.bottom="$t('user')") div(:aria-label="$t('user')", v-b-tooltip.hover.bottom="$t('user')")
message-count(v-if='user.inbox.newMessages > 0', :count="user.inbox.newMessages", :top="true") message-count(v-if='user.inbox.newMessages > 0', :count="user.inbox.newMessages", :top="true")
.top-menu-icon.svg-icon.user(v-html="icons.user") .top-menu-icon.svg-icon.user(v-html="icons.user")
.user-dropdown(slot="dropdown-content") .user-dropdown(slot="dropdown-content")

View File

@@ -3,7 +3,7 @@ A simplified dropdown component that doesn't rely on buttons as toggles like bo
--> -->
<template lang="pug"> <template lang="pug">
.habitica-menu-dropdown.dropdown(@click="toggleDropdown()", :class="{open: isOpen}") .habitica-menu-dropdown.dropdown(@click="toggleDropdown()", @keypress.enter.space.stop.prevent="toggleDropdown()", role="button", tabindex="0", :class="{open: isOpen}", :aria-pressed="isPressed")
.habitica-menu-dropdown-toggle .habitica-menu-dropdown-toggle
slot(name="dropdown-toggle") slot(name="dropdown-toggle")
.dropdown-menu(:class="{'dropdown-menu-right': right}") .dropdown-menu(:class="{'dropdown-menu-right': right}")
@@ -12,10 +12,16 @@ A simplified dropdown component that doesn't rely on buttons as toggles like bo
<style lang="scss"> <style lang="scss">
@import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/colors.scss';
.habitica-menu-dropdown {
&:hover,
&:focus { // NB focus styles match the hover styles for .svg-icon
outline: none;
}
.habitica-menu-dropdown.open { &.open {
.habitica-menu-dropdown-toggle .svg-icon { .habitica-menu-dropdown-toggle .svg-icon {
color: $white !important; color: $white !important;
}
} }
} }
</style> </style>
@@ -66,6 +72,9 @@ export default {
if (this.openStatus !== undefined) return this.openStatus === 1 ? true : false; if (this.openStatus !== undefined) return this.openStatus === 1 ? true : false;
return this.isDropdownOpen; return this.isDropdownOpen;
}, },
isPressed () {
return this.isOpen ? 'true' : 'false';
},
}, },
mounted () { mounted () {
document.documentElement.addEventListener('click', this._clickOutListener); document.documentElement.addEventListener('click', this._clickOutListener);

View File

@@ -400,7 +400,7 @@ export default {
if (this.user.flags.chatRevoked) { if (this.user.flags.chatRevoked) {
return { return {
title: this.$t('PMPlaceholderTitleRevoked'), title: this.$t('PMPlaceholderTitleRevoked'),
description: this.$t('PMPlaceholderDescriptionRevoked'), description: this.$t('chatPrivilegesRevoked'),
}; };
} }
return { return {

View File

@@ -5,6 +5,9 @@ export default function markdown (el, {value, oldValue}) {
if (value) { if (value) {
el.innerHTML = habiticaMarkdown.render(String(value)); el.innerHTML = habiticaMarkdown.render(String(value));
} else {
el.innerHTML = '';
} }
el.classList.add('markdown'); el.classList.add('markdown');
} }

View File

@@ -1,15 +1,14 @@
HabitRPG's translations are managed on [Transifex](https://www.transifex.com/projects/p/habitrpg/). HabitRPG's translations are managed at http://translate.habitica.com/
The files in this folder are automatically pulled from Transifex, with The files in this folder are automatically pulled from there, with
exception of the original American-English strings which are managed exception of the original American-English strings which are managed
directly through GitHub in `locales/en`. directly through GitHub in `locales/en`.
When you need to change any text, edit only the files in `locales/en`. When you need to change any text, edit only the files in `locales/en`.
Do not edit files in any other locales directory. You do not need to Do not edit files in any other locales directory. You do not need to
request that your changes be translated; changes are automatically request that your changes be translated; changes are automatically
copied to Transifex on a regular basis. copied to the translation website on a regular basis.
If you want to help with translations, please first read [Guidance for If you want to help with translations, please first read [Guidance for
Linguists](http://habitica.fandom.com/wiki/Guidance_for_Linguists) and Linguists](http://habitica.fandom.com/wiki/Guidance_for_Linguists) and
note especially its information about the [Translations Trello note especially its information about the [Translations Trello card](https://trello.com/c/SvTsLdRF/12-translations).
card](https://trello.com/c/SvTsLdRF/12-translations).

View File

@@ -175,6 +175,8 @@
"youCast": "You cast <%= spell %>.", "youCast": "You cast <%= spell %>.",
"youCastTarget": "You cast <%= spell %> on <%= target %>.", "youCastTarget": "You cast <%= spell %> on <%= target %>.",
"youCastParty": "You cast <%= spell %> for the party.", "youCastParty": "You cast <%= spell %> for the party.",
"chatCastSpellParty": "<%= username %> casts <%= spell %> for the party.",
"chatCastSpellUser": "<%= username %> casts <%= spell %> on <%= target %>.",
"critBonus": "Critical Hit! Bonus: ", "critBonus": "Critical Hit! Bonus: ",
"gainedGold": "You gained some Gold", "gainedGold": "You gained some Gold",
"gainedMana": "You gained some Mana", "gainedMana": "You gained some Mana",

View File

@@ -267,7 +267,7 @@
"missingNewPassword": "Missing new password.", "missingNewPassword": "Missing new password.",
"invalidEmailDomain": "You cannot register with emails with the following domains: <%= domains %>", "invalidEmailDomain": "You cannot register with emails with the following domains: <%= domains %>",
"wrongPassword": "Wrong password.", "wrongPassword": "Wrong password.",
"incorrectDeletePhrase": "Please type <%= magicWord %> in all caps to delete your account.", "incorrectDeletePhrase": "Please type <%= magicWord %> in all capital letters to delete your account.",
"notAnEmail": "Invalid email address.", "notAnEmail": "Invalid email address.",
"emailTaken": "Email address is already used in an account.", "emailTaken": "Email address is already used in an account.",
"newEmailRequired": "Missing new email address.", "newEmailRequired": "Missing new email address.",
@@ -283,7 +283,7 @@
"passwordResetEmailHtml": "If you requested a password reset for <strong><%= username %></strong> on Habitica, <a href=\"<%= passwordResetLink %>\">click here</a> to set a new one. The link will expire after 24 hours.<br/><br>If you haven't requested a password reset, please ignore this email.", "passwordResetEmailHtml": "If you requested a password reset for <strong><%= username %></strong> on Habitica, <a href=\"<%= passwordResetLink %>\">click here</a> to set a new one. The link will expire after 24 hours.<br/><br>If you haven't requested a password reset, please ignore this email.",
"invalidLoginCredentialsLong": "Uh-oh - your email address / username or password is incorrect.\n- Make sure they are typed correctly. Your username and password are case-sensitive.\n- You may have signed up with Facebook or Google-sign-in, not email so double-check by trying them.\n- If you forgot your password, click \"Forgot Password\".", "invalidLoginCredentialsLong": "Uh-oh - your email address / username or password is incorrect.\n- Make sure they are typed correctly. Your username and password are case-sensitive.\n- You may have signed up with Facebook or Google-sign-in, not email so double-check by trying them.\n- If you forgot your password, click \"Forgot Password\".",
"invalidCredentials": "There is no account that uses those credentials.", "invalidCredentials": "There is no account that uses those credentials.",
"accountSuspended": "This account, User ID \"<%= userId %>\", has been blocked for breaking the [Community Guidelines](https://habitica.com/static/community-guidelines) or [Terms of Service](https://habitica.com/static/terms). For details or to ask to be unblocked, please email our Community Manager at <%= communityManagerEmail %> or ask your parent or guardian to email them. Please copy your User ID into the email and include your username.", "accountSuspended": "This account, User ID \"<%= userId %>\", has been blocked for breaking the Community Guidelines (https://habitica.com/static/community-guidelines) or Terms of Service (https://habitica.com/static/terms). For details or to ask to be unblocked, please email our Community Manager at <%= communityManagerEmail %> or ask your parent or guardian to email them. Please include your @Username in the email.",
"accountSuspendedTitle": "Account has been suspended", "accountSuspendedTitle": "Account has been suspended",
"unsupportedNetwork": "This network is not currently supported.", "unsupportedNetwork": "This network is not currently supported.",
"cantDetachSocial": "Account lacks another authentication method; can't detach this authentication method.", "cantDetachSocial": "Account lacks another authentication method; can't detach this authentication method.",

View File

@@ -137,7 +137,6 @@
"PMPlaceholderTitle": "Nothing Here Yet", "PMPlaceholderTitle": "Nothing Here Yet",
"PMPlaceholderDescription": "Select a conversation on the left", "PMPlaceholderDescription": "Select a conversation on the left",
"PMPlaceholderTitleRevoked": "Your chat privileges have been revoked", "PMPlaceholderTitleRevoked": "Your chat privileges have been revoked",
"PMPlaceholderDescriptionRevoked": "You are not able to send private messages because your chat privileges have been revoked. If you have questions or concerns about this, please email <a href=\"mailto:admin@habitica.com\">admin@habitica.com</a> to discuss it with the staff.",
"PMReceive": "Receive Private Messages", "PMReceive": "Receive Private Messages",
"PMEnabledOptPopoverText": "Private Messages are enabled. Users can contact you via your profile.", "PMEnabledOptPopoverText": "Private Messages are enabled. Users can contact you via your profile.",
"PMDisabledOptPopoverText": "Private Messages are disabled. Enable this option to allow users to contact you via your profile.", "PMDisabledOptPopoverText": "Private Messages are disabled. Enable this option to allow users to contact you via your profile.",
@@ -266,9 +265,7 @@
"userRequestsApproval": "<%= userName %> requests approval", "userRequestsApproval": "<%= userName %> requests approval",
"userCountRequestsApproval": "<%= userCount %> members request approval", "userCountRequestsApproval": "<%= userCount %> members request approval",
"youAreRequestingApproval": "You are requesting approval", "youAreRequestingApproval": "You are requesting approval",
"chatPrivilegesRevoked": "You cannot do that because your chat privileges have been revoked.", "chatPrivilegesRevoked": "You cannot do this because your chat privileges have been removed. For details or to ask if your privileges can be returned, please email our Community Manager at admin@habitica.com or ask your parent or guardian to email them. Please include your @Username in the email. If a moderator has already told you that your chat ban is temporary, you do not need to send an email.",
"cannotCreatePublicGuildWhenMuted": "You cannot create a public guild because your chat privileges have been revoked.",
"cannotInviteWhenMuted": "You cannot invite anyone to a guild or party because your chat privileges have been revoked.",
"newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!", "newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!",
"newChatMessageTitle": "New message in <%= groupName %>", "newChatMessageTitle": "New message in <%= groupName %>",
"exportInbox": "Export Messages", "exportInbox": "Export Messages",

View File

@@ -56,7 +56,7 @@
"messageGroupChatFlagAlreadyReported": "You have already reported this message", "messageGroupChatFlagAlreadyReported": "You have already reported this message",
"messageGroupChatNotFound": "Message not found!", "messageGroupChatNotFound": "Message not found!",
"messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!", "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 %>.", "messageCannotFlagSystemMessages": "You cannot report 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 our Community Manager 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. :)", "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.", "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.",

View File

@@ -127,5 +127,14 @@
"bossHealth": "<%= currentHealth %> / <%= maxHealth %> Health", "bossHealth": "<%= currentHealth %> / <%= maxHealth %> Health",
"rageAttack": "Rage Attack:", "rageAttack": "Rage Attack:",
"bossRage": "<%= currentRage %> / <%= maxRage %> Rage", "bossRage": "<%= currentRage %> / <%= maxRage %> Rage",
"rageStrikes": "Rage Strikes" "rageStrikes": "Rage Strikes",
"chatQuestStarted": "Your quest, <%= questName %>, has started.",
"chatBossDamage": "<%= username %> attacks <%= bossName %> for <%= userDamage %> damage. <%= bossName %> attacks party for <%= bossDamage %> damage.",
"chatBossDontAttack": "<%= username %> attacks <%= bossName %> for <%= userDamage %> damage. <%= bossName %> does not attack, because it respects the fact that there are some bugs post-maintenance, and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!",
"chatBossDefeated": "You defeated <%= bossName %>! Questing party members receive the rewards of victory.",
"chatFindItems": "<%= username %> found <%= items %>.",
"chatItemQuestFinish": "All items found! Party has received their rewards.",
"chatQuestAborted": "<%= username %> aborted the party quest <%= questName %>.",
"chatQuestCancelled": "<%= username %> cancelled the party quest <%= questName %>.",
"tavernBossTired": "<%= bossName %> tries to unleash <%= rageName %> but is too tired."
} }

View File

@@ -442,7 +442,8 @@ api.getGroupChallenges = {
method: 'GET', method: 'GET',
url: '/challenges/groups/:groupId', url: '/challenges/groups/:groupId',
middlewares: [authWithHeaders({ middlewares: [authWithHeaders({
userFieldsToInclude: ['_id', 'party', 'guilds'], // Some fields (including _id) are always loaded (see middlewares/auth)
userFieldsToInclude: ['party', 'guilds'], // Some fields are always loaded (see middlewares/auth)
})], })],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;

View File

@@ -186,7 +186,7 @@ api.postChat = {
if (client) { if (client) {
client = client.replace('habitica-', ''); client = client.replace('habitica-', '');
} }
const newChatMessage = group.sendChat(req.body.message, user, null, client); const newChatMessage = group.sendChat({message: req.body.message, user, metaData: null, client});
let toSave = [newChatMessage.save()]; let toSave = [newChatMessage.save()];
if (group.type === 'party') { if (group.type === 'party') {

View File

@@ -82,7 +82,7 @@ let api = {};
* @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4) * @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4)
* @apiError (401) {NotAuthorized} partyMustbePrivate Party must have privacy set to private * @apiError (401) {NotAuthorized} partyMustbePrivate Party must have privacy set to private
* @apiError (401) {NotAuthorized} messageGroupAlreadyInParty * @apiError (401) {NotAuthorized} messageGroupAlreadyInParty
* @apiError (401) {NotAuthorized} cannotCreatePublicGuildWhenMuted You cannot create a public guild because your chat privileges have been revoked. * @apiError (401) {NotAuthorized} chatPrivilegesRevoked You cannot do this because your chat privileges have been removed...
* *
* @apiSuccess (201) {Object} data The created group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>) * @apiSuccess (201) {Object} data The created group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
* *
@@ -117,7 +117,7 @@ api.createGroup = {
group.leader = user._id; group.leader = user._id;
if (group.type === 'guild') { if (group.type === 'guild') {
if (group.privacy === 'public' && user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotCreatePublicGuildWhenMuted')); if (group.privacy === 'public' && user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems')); if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
group.balance = 1; group.balance = 1;
@@ -375,7 +375,8 @@ api.getGroup = {
method: 'GET', method: 'GET',
url: '/groups/:groupId', url: '/groups/:groupId',
middlewares: [authWithHeaders({ middlewares: [authWithHeaders({
userFieldsToInclude: ['_id', 'party', 'guilds', 'contributor'], // Some fields (including _id, preferences) are always loaded (see middlewares/auth)
userFieldsToInclude: ['party', 'guilds', 'contributor'],
})], })],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
@@ -1011,7 +1012,7 @@ api.inviteToGroup = {
async handler (req, res) { async handler (req, res) {
const user = res.locals.user; const user = res.locals.user;
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted')); if (user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();

View File

@@ -371,7 +371,14 @@ api.cancelQuest = {
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
let questName = questScrolls[group.quest.key].text('en'); let questName = questScrolls[group.quest.key].text('en');
const newChatMessage = group.sendChat(`\`${user.profile.name} cancelled the party quest ${questName}.\``); const newChatMessage = group.sendChat({
message: `\`${user.profile.name} cancelled the party quest ${questName}.\``,
info: {
type: 'quest_cancel',
user: user.profile.name,
quest: group.quest.key,
},
});
group.quest = Group.cleanGroupQuest(); group.quest = Group.cleanGroupQuest();
group.markModified('quest'); group.markModified('quest');
@@ -427,7 +434,14 @@ api.abortQuest = {
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest')); if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
let questName = questScrolls[group.quest.key].text('en'); let questName = questScrolls[group.quest.key].text('en');
const newChatMessage = group.sendChat(`\`${user.profile.name} aborted the party quest ${questName}.\``); const newChatMessage = group.sendChat({
message: `\`${common.i18n.t('chatQuestAborted', {username: user.profile.name, questName}, 'en')}\``,
info: {
type: 'quest_abort',
user: user.profile.name,
quest: group.quest.key,
},
});
await newChatMessage.save(); await newChatMessage.save();
let memberUpdates = User.update({ let memberUpdates = User.update({

View File

@@ -285,7 +285,8 @@ api.getUserTasks = {
method: 'GET', method: 'GET',
url: '/tasks/user', url: '/tasks/user',
middlewares: [authWithHeaders({ middlewares: [authWithHeaders({
userFieldsToInclude: ['_id', 'tasksOrder', 'preferences'], // Some fields (including _id, preferences) are always loaded (see middlewares/auth)
userFieldsToInclude: ['tasksOrder'],
})], })],
async handler (req, res) { async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`); let types = Tasks.tasksTypes.map(type => `${type}s`);

View File

@@ -204,7 +204,14 @@ api.assignTask = {
// User is claiming the task // User is claiming the task
if (user._id === assignedUserId) { if (user._id === assignedUserId) {
let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text}); let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text});
const newMessage = group.sendChat(message); const newMessage = group.sendChat({
message,
info: {
type: 'claim_task',
user: user.profile.name,
task: task.text,
},
});
promises.push(newMessage.save()); promises.push(newMessage.save());
} else { } else {
const taskText = task.text; const taskText = task.text;

View File

@@ -91,6 +91,11 @@ let bannedWords = [
'buggery', 'buggery',
'buggering', 'buggering',
'buggered', 'buggered',
'bullshit',
'bullshiter',
'bullshitter',
'bullshiting',
'bullshitting',
'shiz', 'shiz',
'shit', 'shit',
'shitty', 'shitty',

View File

@@ -1,6 +1,10 @@
import { chatModel as Chat } from '../../models/message'; import { chatModel as Chat } from '../../models/message';
import shared from '../../../common';
import _ from 'lodash';
import { MAX_CHAT_COUNT, MAX_SUBBED_GROUP_CHAT_COUNT } from '../../models/group'; import { MAX_CHAT_COUNT, MAX_SUBBED_GROUP_CHAT_COUNT } from '../../models/group';
const questScrolls = shared.content.quests;
// @TODO: Don't use this method when the group can be saved. // @TODO: Don't use this method when the group can be saved.
export async function getGroupChat (group) { export async function getGroupChat (group) {
const maxChatCount = group.isSubscribed() ? MAX_SUBBED_GROUP_CHAT_COUNT : MAX_CHAT_COUNT; const maxChatCount = group.isSubscribed() ? MAX_SUBBED_GROUP_CHAT_COUNT : MAX_CHAT_COUNT;
@@ -22,3 +26,82 @@ export async function getGroupChat (group) {
return previous; return previous;
}, []); }, []);
} }
export function translateMessage (lang, info) {
let msg;
let foundText = '';
let spells = shared.content.spells;
let quests = shared.content.quests;
switch (info.type) {
case 'quest_start':
msg = `\`${shared.i18n.t('chatQuestStarted', {questName: questScrolls[info.quest].text(lang)}, lang)}\``;
break;
case 'boss_damage':
msg = `\`${shared.i18n.t('chatBossDamage', {username: info.user, bossName: questScrolls[info.quest].boss.name(lang), userDamage: info.userDamage, bossDamage: info.bossDamage}, lang)}\``;
break;
case 'boss_dont_attack':
msg = `\`${shared.i18n.t('chatBossDontAttack', {username: info.user, bossName: questScrolls[info.quest].boss.name(lang), userDamage: info.userDamage}, lang)}\``;
break;
case 'boss_rage':
msg = `\`${questScrolls[info.quest].boss.rage.effect(lang)}\``;
break;
case 'boss_defeated':
msg = `\`${shared.i18n.t('chatBossDefeated', {bossName: questScrolls[info.quest].boss.name(lang)}, lang)}\``;
break;
case 'user_found_items':
foundText = _.reduce(info.items, (m, v, k) => {
m.push(`${v} ${questScrolls[info.quest].collect[k].text(lang)}`);
return m;
}, []).join(', ');
msg = `\`${shared.i18n.t('chatFindItems', {username: info.user, items: foundText}, lang)}\``;
break;
case 'all_items_found':
msg = `\`${shared.i18n.t('chatItemQuestFinish', lang)}\``;
break;
case 'spell_cast_party':
msg = `\`${shared.i18n.t('chatCastSpellParty', {username: info.user, spell: spells[info.class][info.spell].text(lang)}, lang)}\``;
break;
case 'spell_cast_user':
msg = `\`${shared.i18n.t('chatCastSpellUser', {username: info.user, spell: spells[info.class][info.spell].text(lang), target: info.target}, lang)}\``;
break;
case 'quest_cancel':
msg = `\`${shared.i18n.t('chatQuestCancelled', {username: info.user, questName: questScrolls[info.quest].text(lang)}, lang)}\``;
break;
case 'quest_abort':
msg = `\`${shared.i18n.t('chatQuestAborted', {username: info.user, questName: questScrolls[info.quest].text(lang)}, lang)}\``;
break;
case 'tavern_quest_completed':
msg = `\`${quests[info.quest].completionChat(lang)}\``;
break;
case 'tavern_boss_rage_tired':
msg = `\`${shared.i18n.t('tavernBossTired', {rageName: quests[info.quest].boss.rage.title(lang), bossName: quests[info.quest].boss.name(lang)}, lang)}\``;
break;
case 'tavern_boss_rage':
msg = `\`${quests[info.quest].boss.rage[info.scene](lang)}\``;
break;
case 'tavern_boss_desperation':
msg = `\`${quests[info.quest].boss.desperation.text(lang)}\``;
break;
case 'claim_task':
msg = `${shared.i18n.t('userIsClamingTask', {username: info.user, task: info.task}, lang)}`;
break;
}
return msg;
}

View File

@@ -197,9 +197,30 @@ async function castSpell (req, res, {isV3 = false}) {
}); });
if (party && !spell.silent) { if (party && !spell.silent) {
let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; if (targetType === 'user') {
const newChatMessage = party.sendChat(message); const newChatMessage = party.sendChat({
await newChatMessage.save(); message: `\`${common.i18n.t('chatCastSpellUser', {username: user.profile.name, spell: spell.text(), target: partyMembers.profile.name}, 'en')}\``,
info: {
type: 'spell_cast_user',
user: user.profile.name,
class: klass,
spell: spellId,
target: partyMembers.profile.name,
},
});
await newChatMessage.save();
} else {
const newChatMessage = party.sendChat({
message: `\`${common.i18n.t('chatCastSpellParty', {username: user.profile.name, spell: spell.text()}, 'en')}\``,
info: {
type: 'spell_cast_party',
user: user.profile.name,
class: klass,
spell: spellId,
},
});
await newChatMessage.save();
}
} }
} }
} }

View File

@@ -9,22 +9,27 @@ import url from 'url';
import gcpStackdriverTracer from '../libs/gcpTraceAgent'; import gcpStackdriverTracer from '../libs/gcpTraceAgent';
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'); const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags'];
function getUserFields (options, req) { function getUserFields (options, req) {
// A list of user fields that aren't needed for the route and are not loaded from the db. // A list of user fields that aren't needed for the route and are not loaded from the db.
// Must be an array // Must be an array
if (options.userFieldsToExclude) { if (options.userFieldsToExclude) {
return options.userFieldsToExclude.map(field => { return options.userFieldsToExclude
return `-${field}`; // -${field} means exclude ${field} in mongodb .filter(field => {
}).join(' '); return !USER_FIELDS_ALWAYS_LOADED.find(fieldToInclude => field.startsWith(fieldToInclude));
})
.map(field => {
return `-${field}`; // -${field} means exclude ${field} in mongodb
})
.join(' ');
} }
if (options.userFieldsToInclude) { if (options.userFieldsToInclude) {
return options.userFieldsToInclude.join(' '); return options.userFieldsToInclude.concat(USER_FIELDS_ALWAYS_LOADED).join(' ');
} }
// Allows GET requests to /user to specify a list of user fields to return instead of the entire doc // Allows GET requests to /user to specify a list of user fields to return instead of the entire doc
// Notifications are always included
const urlPath = url.parse(req.url).pathname; const urlPath = url.parse(req.url).pathname;
const userFields = req.query.userFields; const userFields = req.query.userFields;
if (!userFields || urlPath !== '/user') return ''; if (!userFields || urlPath !== '/user') return '';
@@ -32,7 +37,7 @@ function getUserFields (options, req) {
const userFieldOptions = userFields.split(','); const userFieldOptions = userFields.split(',');
if (userFieldOptions.length === 0) return ''; if (userFieldOptions.length === 0) return '';
return `notifications ${userFieldOptions.join(' ')}`; return userFieldOptions.concat(USER_FIELDS_ALWAYS_LOADED).join(' ');
} }
// Make sure stackdriver traces are storing the user id // Make sure stackdriver traces are storing the user id

View File

@@ -37,7 +37,7 @@ import {
} from './subscriptionPlan'; } from './subscriptionPlan';
import amazonPayments from '../libs/payments/amazon'; import amazonPayments from '../libs/payments/amazon';
import stripePayments from '../libs/payments/stripe'; import stripePayments from '../libs/payments/stripe';
import { getGroupChat } from '../libs/chat/group-chat'; import { getGroupChat, translateMessage } from '../libs/chat/group-chat';
import { model as UserNotification } from './userNotification'; import { model as UserNotification } from './userNotification';
const questScrolls = shared.content.quests; const questScrolls = shared.content.quests;
@@ -344,26 +344,38 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
await getGroupChat(group); await getGroupChat(group);
} }
let toJSON = group.toJSON(); const groupToJson = group.toJSON();
const userLang = user.preferences.language;
if (!user.contributor.admin) { groupToJson.chat = groupToJson.chat
_.remove(toJSON.chat, chatMsg => { .map(chatMsg => {
chatMsg.flags = {}; // Translate system messages
if (chatMsg._meta) chatMsg._meta = undefined; if (!_.isEmpty(chatMsg.info)) {
return user._id !== chatMsg.uuid && chatMsg.flagCount >= 2; chatMsg.text = translateMessage(userLang, chatMsg.info);
}); }
}
// Convert to timestamps because Android expects it // Convert to timestamps because Android expects it
toJSON.chat.forEach(chat => { // old chats are saved with a numeric timestamp
// old chats are saved with a numeric timestamp // new chats use `Date` which then has to be converted to the numeric timestamp
// new chats use `Date` which then has to be converted to the numeric timestamp if (chatMsg.timestamp && chatMsg.timestamp.getTime) {
if (chat.timestamp && chat.timestamp.getTime) { chatMsg.timestamp = chatMsg.timestamp.getTime();
chat.timestamp = chat.timestamp.getTime(); }
}
});
return toJSON; if (!user.contributor.admin) {
// Flags are hidden to non admins
chatMsg.flags = {};
if (chatMsg._meta) chatMsg._meta = undefined;
// Messages with >= 2 flags are hidden to non admins and non authors
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= 2) return undefined;
}
return chatMsg;
})
// Used to filter for undefined chat messages that should not be shown to non-admins
.filter(chatMsg => chatMsg !== undefined);
return groupToJson;
}; };
function getInviteError (uuids, emails, usernames) { function getInviteError (uuids, emails, usernames) {
@@ -496,8 +508,9 @@ schema.methods.getMemberCount = async function getMemberCount () {
return await User.count(query).exec(); return await User.count(query).exec();
}; };
schema.methods.sendChat = function sendChat (message, user, metaData, client) { schema.methods.sendChat = function sendChat (options = {}) {
let newMessage = messageDefaults(message, user, client); const {message, user, metaData, client, info = {}} = options;
let newMessage = messageDefaults(message, user, client, info);
let newChatMessage = new Chat(); let newChatMessage = new Chat();
newChatMessage = Object.assign(newChatMessage, newMessage); newChatMessage = Object.assign(newChatMessage, newMessage);
newChatMessage.groupId = this._id; newChatMessage.groupId = this._id;
@@ -653,8 +666,15 @@ schema.methods.startQuest = async function startQuest (user) {
}, _cleanQuestParty(), }, _cleanQuestParty(),
{ multi: true }).exec(); { multi: true }).exec();
const newMessage = this.sendChat(`\`Your quest, ${quest.text('en')}, has started.\``, null, { const newMessage = this.sendChat({
participatingMembers: this.getParticipatingQuestMembers().join(', '), message: `\`${shared.i18n.t('chatQuestStarted', {questName: quest.text('en')}, 'en')}\``,
metaData: {
participatingMembers: this.getParticipatingQuestMembers().join(', '),
},
info: {
type: 'quest_start',
quest: quest.key,
},
}); });
await newMessage.save(); await newMessage.save();
@@ -913,18 +933,42 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
const promises = []; const promises = [];
group.quest.progress.hp -= progress.up; group.quest.progress.hp -= progress.up;
// TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests! if (CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE) {
let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`; const groupMessage = group.sendChat({
let bossAttack = CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE ? `${quest.boss.name('en')} does not attack, because it respects the fact that there are some bugs\` \`post-maintenance and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`; message: `\`${shared.i18n.t('chatBossDontAttack', {bossName: quest.boss.name('en')}, 'en')}\``,
// TODO Consider putting the safe mode boss attack message in an ENV var info: {
const groupMessage = group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``); type: 'boss_dont_attack',
promises.push(groupMessage.save()); user: user.profile.name,
quest: group.quest.key,
userDamage: progress.up.toFixed(1),
},
});
promises.push(groupMessage.save());
} else {
const groupMessage = group.sendChat({
message: `\`${shared.i18n.t('chatBossDamage', {username: user.profile.name, bossName: quest.boss.name('en'), userDamage: progress.up.toFixed(1), bossDamage: Math.abs(down).toFixed(1)}, user.preferences.language)}\``,
info: {
type: 'boss_damage',
user: user.profile.name,
quest: group.quest.key,
userDamage: progress.up.toFixed(1),
bossDamage: Math.abs(down).toFixed(1),
},
});
promises.push(groupMessage.save());
}
// If boss has Rage, increment Rage as well // If boss has Rage, increment Rage as well
if (quest.boss.rage) { if (quest.boss.rage) {
group.quest.progress.rage += Math.abs(down); group.quest.progress.rage += Math.abs(down);
if (group.quest.progress.rage >= quest.boss.rage.value) { if (group.quest.progress.rage >= quest.boss.rage.value) {
const rageMessage = group.sendChat(quest.boss.rage.effect('en')); const rageMessage = group.sendChat({
message: quest.boss.rage.effect('en'),
info: {
type: 'boss_rage',
quest: quest.key,
},
});
promises.push(rageMessage.save()); promises.push(rageMessage.save());
group.quest.progress.rage = 0; group.quest.progress.rage = 0;
@@ -952,7 +996,13 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
// Boss slain, finish quest // Boss slain, finish quest
if (group.quest.progress.hp <= 0) { if (group.quest.progress.hp <= 0) {
const questFinishChat = group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); const questFinishChat = group.sendChat({
message: `\`${shared.i18n.t('chatBossDefeated', {bossName: quest.boss.name('en')}, 'en')}\``,
info: {
type: 'boss_defeated',
quest: quest.key,
},
});
promises.push(questFinishChat.save()); promises.push(questFinishChat.save());
// Participants: Grant rewards & achievements, finish quest // Participants: Grant rewards & achievements, finish quest
@@ -1005,7 +1055,15 @@ schema.methods._processCollectionQuest = async function processCollectionQuest (
}, []); }, []);
foundText = foundText.join(', '); foundText = foundText.join(', ');
const foundChat = group.sendChat(`\`${user.profile.name} found ${foundText}.\``); const foundChat = group.sendChat({
message: `\`${shared.i18n.t('chatFindItems', {username: user.profile.name, items: foundText}, 'en')}\``,
info: {
type: 'user_found_items',
user: user.profile.name,
quest: quest.key,
items: itemsFound,
},
});
group.markModified('quest.progress.collect'); group.markModified('quest.progress.collect');
// Still needs completing // Still needs completing
@@ -1018,7 +1076,12 @@ schema.methods._processCollectionQuest = async function processCollectionQuest (
} }
await group.finishQuest(quest); await group.finishQuest(quest);
const allItemsFoundChat = group.sendChat('`All items found! Party has received their rewards.`'); const allItemsFoundChat = group.sendChat({
message: `\`${shared.i18n.t('chatItemQuestFinish', 'en')}\``,
info: {
type: 'all_items_found',
},
});
const promises = [group.save(), foundChat.save(), allItemsFoundChat.save()]; const promises = [group.save(), foundChat.save(), allItemsFoundChat.save()];
@@ -1082,7 +1145,13 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
const chatPromises = []; const chatPromises = [];
if (tavern.quest.progress.hp <= 0) { if (tavern.quest.progress.hp <= 0) {
const completeChat = tavern.sendChat(quest.completionChat('en')); const completeChat = tavern.sendChat({
message: quest.completionChat('en'),
info: {
type: 'tavern_quest_completed',
quest: quest.key,
},
});
chatPromises.push(completeChat.save()); chatPromises.push(completeChat.save());
await tavern.finishQuest(quest); await tavern.finishQuest(quest);
_.assign(tavernQuest, {extra: null}); _.assign(tavernQuest, {extra: null});
@@ -1111,11 +1180,24 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
} }
if (!scene) { if (!scene) {
const tiredChat = tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); const tiredChat = tavern.sendChat({
message: `\`${shared.i18n.t('tavernBossTired', {rageName: quest.boss.rage.title('en'), bossName: quest.boss.name('en')}, 'en')}\``,
info: {
type: 'tavern_boss_rage_tired',
quest: quest.key,
},
});
chatPromises.push(tiredChat.save()); chatPromises.push(tiredChat.save());
tavern.quest.progress.rage = 0; // quest.boss.rage.value; tavern.quest.progress.rage = 0; // quest.boss.rage.value;
} else { } else {
const rageChat = tavern.sendChat(quest.boss.rage[scene]('en')); const rageChat = tavern.sendChat({
message: quest.boss.rage[scene]('en'),
info: {
type: 'tavern_boss_rage',
quest: quest.key,
scene,
},
});
chatPromises.push(rageChat.save()); chatPromises.push(rageChat.save());
tavern.quest.extra.worldDmg[scene] = true; tavern.quest.extra.worldDmg[scene] = true;
tavern.markModified('quest.extra.worldDmg'); tavern.markModified('quest.extra.worldDmg');
@@ -1127,7 +1209,13 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
} }
if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) {
const progressChat = tavern.sendChat(quest.boss.desperation.text('en')); const progressChat = tavern.sendChat({
message: quest.boss.desperation.text('en'),
info: {
type: 'tavern_boss_desperation',
quest: quest.key,
},
});
chatPromises.push(progressChat.save()); chatPromises.push(progressChat.save());
tavern.quest.extra.desperate = true; tavern.quest.extra.desperate = true;
tavern.quest.extra.def = quest.boss.desperation.def; tavern.quest.extra.def = quest.boss.desperation.def;

View File

@@ -7,6 +7,7 @@ const defaultSchema = () => ({
id: String, id: String,
timestamp: Date, timestamp: Date,
text: String, text: String,
info: {$type: mongoose.Schema.Types.Mixed},
// sender properties // sender properties
user: String, // profile name (unfortunately) user: String, // profile name (unfortunately)
@@ -101,12 +102,13 @@ export function setUserStyles (newMessage, user) {
newMessage.markModified('userStyles'); newMessage.markModified('userStyles');
} }
export function messageDefaults (msg, user, client) { export function messageDefaults (msg, user, client, info = {}) {
const id = uuid(); const id = uuid();
const message = { const message = {
id, id,
_id: id, _id: id,
text: msg.substring(0, 3000), text: msg.substring(0, 3000),
info,
timestamp: Number(new Date()), timestamp: Number(new Date()),
likes: {}, likes: {},
flags: {}, flags: {},