diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json
index a435098a1d..649adf8c94 100644
--- a/common/locales/en/api-v3.json
+++ b/common/locales/en/api-v3.json
@@ -67,5 +67,23 @@
"emailsMustBeAnArray": "Email invites must be a an Array.",
"canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
"cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.",
- "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!"
+ "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
+ "questInviteNotFound": "No quest invitation found.",
+ "guildQuestsNotSupported": "Guilds cannot be invited on quests.",
+ "questNotFound": "Quest \"<%= key %>\" not found.",
+ "questNotOwned": "You don't own that quest scroll.",
+ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.",
+ "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.",
+ "questAlreadyAccepted": "You already accepted the quest invitation.",
+ "noActiveQuestToLeave": "No active quest to leave",
+ "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest",
+ "notPartOfQuest": "You are not part of the quest",
+ "noActiveQuestToAbort": "There is no active quest to abort.",
+ "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest.",
+ "questAlreadyRejected": "You already rejected the quest invitation.",
+ "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.",
+ "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.",
+ "questInvitationDoesNotExist": "No quest invitation has been sent out yet.",
+ "questNotPending": "There is no quest to start.",
+ "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest"
}
diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json
index 7b3fe7af43..5edae258e5 100644
--- a/common/locales/en/quests.json
+++ b/common/locales/en/quests.json
@@ -78,5 +78,6 @@
"whichQuestStart": "Which quest do you want to start?",
"getMoreQuests": "Get more quests",
"unlockedAQuest": "You unlocked a quest!",
- "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!"
+ "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!",
+ "questInvitationDoesNotExist": "No quest invitation has been sent out yet."
}
diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js
deleted file mode 100644
index 6f8626c7d8..0000000000
--- a/common/script/api-v3/cron.js
+++ /dev/null
@@ -1,264 +0,0 @@
-import moment from 'moment';
-import _ from 'lodash';
-import scoreTask from './scoreTask';
-import { preenUserHistory } from './preening';
-import common from '../../';
-import {
- shouldDo,
-} from '../cron';
-
-let clearBuffs = {
- str: 0,
- int: 0,
- per: 0,
- con: 0,
- stealth: 0,
- streaks: false,
-};
-
-// At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
-// For incomplete Dailys, deduct experience
-// Make sure to run this function once in a while as server will not take care of overnight calculations.
-// And you have to run it every time client connects.
-export default function cron (options = {}) {
- let {user, tasksByType, analytics, now, daysMissed} = options;
-
- user.auth.timestamps.loggedin = now;
- user.lastCron = now;
- // Reset the lastDrop count to zero
- if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
-
- // "Perfect Day" achievement for perfect-days
- let perfect = true;
-
- // end-of-month perks for subscribers
- let plan = user.purchased.plan;
- if (user.isSubscribed()) {
- if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) {
- plan.gemsBought = 0; // reset gem-cap
- plan.dateUpdated = now;
- // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
- // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
- // TODO use month diff instead of ++ / --?
- _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317
- plan.consecutive.count++;
- if (plan.consecutive.offset > 0) {
- plan.consecutive.offset--;
- } else if (plan.consecutive.count % 3 === 0) { // every 3 months
- plan.consecutive.trinkets++;
- plan.consecutive.gemCapExtra += 5;
- if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
- }
- }
-
- // If user cancelled subscription, we give them until 30day's end until it terminates
- if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) {
- _.merge(plan, {
- planId: null,
- customerId: null,
- paymentMethod: null,
- });
-
- _.merge(plan.consecutive, {
- count: 0,
- offset: 0,
- gemCapExtra: 0,
- });
-
- user.markModified('purchased.plan');
- }
- }
-
- // User is resting at the inn.
- // On cron, buffs are cleared and all dailies are reset without performing damage
- if (user.preferences.sleep === true) {
- user.stats.buffs = _.cloneDeep(clearBuffs);
-
- tasksByType.dailys.forEach((daily) => {
- let completed = daily.completed;
- let thatDay = moment(now).subtract({days: 1});
-
- if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
- daily.checklist.forEach(box => box.completed = false);
- }
- daily.completed = false;
- });
-
- return;
- }
-
- let multiDaysCountAsOneDay = true;
- // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day.
- // When site-wide difficulty settings are introduced, this can be a user preference option.
-
- // Tally each task
- let todoTally = 0;
-
- tasksByType.todos.forEach(task => { // make uncompleted todos redder
- scoreTask({
- task,
- user,
- direction: 'down',
- cron: true,
- times: multiDaysCountAsOneDay ? 1 : daysMissed,
- // TODO pass req for analytics?
- });
-
- todoTally += task.value;
- });
-
- let dailyChecked = 0; // how many dailies were checked?
- let dailyDueUnchecked = 0; // how many dailies were cun-hecked?
- if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
-
- tasksByType.dailys.forEach((task) => {
- let completed = task.completed;
- // Deduct points for missed Daily tasks
- let EvadeTask = 0;
- let scheduleMisses = daysMissed;
-
- if (completed) {
- dailyChecked += 1;
- } else {
- // dailys repeat, so need to calculate how many they've missed according to their own schedule
- scheduleMisses = 0;
-
- for (let i = 0; i < daysMissed; i++) {
- let thatDay = moment(now).subtract({days: i + 1});
-
- if (shouldDo(thatDay.toDate(), task, user.preferences)) {
- scheduleMisses++;
- if (user.stats.buffs.stealth) {
- user.stats.buffs.stealth--;
- EvadeTask++;
- }
- if (multiDaysCountAsOneDay) break;
- }
- }
-
- if (scheduleMisses > EvadeTask) {
- perfect = false;
-
- if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
- let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
- dailyDueUnchecked += 1 - fractionChecked;
- dailyChecked += fractionChecked;
- } else {
- dailyDueUnchecked += 1;
- }
-
- let delta = scoreTask({
- user,
- task,
- direction: 'down',
- times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask,
- cron: true,
- });
-
- // Apply damage from a boss, less damage for Trivial priority (difficulty)
- user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1);
- // NB: Medium and Hard priorities do not increase damage from boss. This was by accident
- // initially, and when we realised, we could not fix it because users are used to
- // their Medium and Hard Dailies doing an Easy amount of damage from boss.
- // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future
- // setting between Trivial and Easy.
- }
- }
-
- task.history.push({
- date: Number(new Date()),
- value: task.value,
- });
- task.completed = false;
-
- if (completed || scheduleMisses > 0) {
- task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed
- }
- });
-
- tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0
- if (task.up === false || task.down === false) {
- task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2;
- }
- });
-
- // Finished tallying
- user.history.todos.push({date: now, value: todoTally});
-
- // tally experience
- let expTally = user.stats.exp;
- let lvl = 0; // iterator
- while (lvl < user.stats.lvl - 1) {
- lvl++;
- expTally += common.tnl(lvl);
- }
- user.history.exp.push({date: now, value: expTally});
-
- // preen user history so that it doesn't become a performance problem
- // also for subscribed users but differentyly
- // premium subscribers can keep their full history.
- preenUserHistory(user, tasksByType, user.preferences.timezoneOffset);
-
- if (perfect) {
- user.achievements.perfect++;
- let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2);
- user.stats.buffs = {
- str: lvlDiv2,
- int: lvlDiv2,
- per: lvlDiv2,
- con: lvlDiv2,
- stealth: 0,
- streaks: false,
- };
- } else {
- user.stats.buffs = _.cloneDeep(clearBuffs);
- }
-
- // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit
- // Adjust for fraction of dailies completed
- user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
- if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP;
-
- if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1;
- user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
- if (user.stats.mp > user._statsComputed.maxMP) {
- user.stats.mp = user._statsComputed.maxMP;
- }
-
- // After all is said and done, progress up user's effect on quest, return those values & reset the user's
- let progress = user.party.quest.progress;
- let _progress = _.cloneDeep(progress);
- _.merge(progress, {down: 0, up: 0});
- progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
-
- // Clean PMs - keep 200 for subscribers and 50 for free users
- // TODO tests
- let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too
- let numberOfPMs = Object.keys(user.inbox.messages).length;
- if (Object.keys(user.inbox.messages).length > maxPMs) {
- _(user.inbox.messages)
- .sortBy('timestamp')
- .takeRight(numberOfPMs - maxPMs)
- .each(pm => {
- user.inbox.messages[pm.id] = undefined;
- }).value();
-
- user.markModified('inbox.messages');
- }
-
- // Analytics
- user.flags.cronCount++;
- analytics.track('Cron', {
- category: 'behavior',
- gaLabel: 'Cron Count',
- gaValue: user.flags.cronCount,
- uuid: user._id,
- user, // TODO is it really necessary passing the whole user object?
- resting: user.preferences.sleep,
- cronCount: user.flags.cronCount,
- progressUp: _.min([_progress.up, 900]),
- progressDown: _progress.down,
- });
-
- return _progress;
-}
diff --git a/common/script/cron.js b/common/script/cron.js
index 639bbf6b65..7eca56cb54 100644
--- a/common/script/cron.js
+++ b/common/script/cron.js
@@ -1,3 +1,4 @@
+// TODO what can be moved to /website/src?
/*
------------------------------------------------------
Cron and time / day functions
diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js
index b8ccf23f68..6256440998 100644
--- a/tasks/gulp-tests.js
+++ b/tasks/gulp-tests.js
@@ -356,6 +356,7 @@ gulp.task('test:api-v3:unit', (done) => {
gulp.task('test:api-v3:integration', (done) => {
let runner = exec(
testBin('mocha test/api/v3/integration --recursive'),
+ {maxBuffer: 500*1024},
(err, stdout, stderr) => done(err)
)
@@ -365,6 +366,7 @@ gulp.task('test:api-v3:integration', (done) => {
gulp.task('test:api-v3:integration:separate-server', (done) => {
let runner = exec(
testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'),
+ {maxBuffer: 500*1024},
(err, stdout, stderr) => done(err)
)
diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js
new file mode 100644
index 0000000000..665a185279
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js
@@ -0,0 +1,119 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /groups/:groupId/quests/accept', () => {
+ const PET_QUEST = 'whale';
+
+ let questingGroup;
+ let leader;
+ let partyMembers;
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ });
+
+ context('failure conditions', () => {
+ it('does not accept quest without an invite', async () => {
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questInviteNotFound'),
+ });
+ });
+
+ it('does not accept quest for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not accept quest for a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('does not accept invite twice', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('questAlreadyAccepted'),
+ });
+ });
+
+ it('does not accept invite for a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+ });
+
+ context('successfully accepting a quest invitation', () => {
+ it('joins a quest from an invitation', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await Promise.all([partyMembers[0].sync(), questingGroup.sync()]);
+ expect(leader.party.quest.RSVPNeeded).to.equal(false);
+ expect(questingGroup.quest.members[partyMembers[0]._id]);
+ });
+
+ it('does not begin the quest if pending invitations remain', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await questingGroup.sync();
+ expect(questingGroup.quest.active).to.equal(false);
+ });
+
+ it('begins the quest if accepting the last pending invite', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await questingGroup.sync();
+ expect(questingGroup.quest.active).to.equal(true);
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js
new file mode 100644
index 0000000000..b6d43f826b
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js
@@ -0,0 +1,126 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /groups/:groupId/quests/force-start', () => {
+ const PET_QUEST = 'whale';
+
+ let questingGroup;
+ let leader;
+ let partyMembers;
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ });
+
+ context('failure conditions', () => {
+ it('does not force start a quest for a group in which user is not a member', async () => {
+ let nonMember = await generateUser();
+
+ await expect(nonMember.post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not force start quest for a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('does not force start for a party without a pending quest', async () => {
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questNotPending'),
+ });
+ });
+
+ it('does not force start for a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+
+ it('does not allow non-quest leader or non-group leader to force start a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questOrGroupLeaderOnlyStartQuest'),
+ });
+ });
+ });
+
+ context('successfully force starting a quest', () => {
+ it('allows quest leader to force start quest', async () => {
+ let questLeader = partyMembers[0];
+ await questLeader.update({[`items.quests.${PET_QUEST}`]: 1});
+ await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await questLeader.post(`/groups/${questingGroup._id}/quests/force-start`);
+
+ await questingGroup.sync();
+
+ expect(questingGroup.quest.active).to.eql(true);
+ });
+
+ it('allows group leader to force start quest', async () => {
+ let questLeader = partyMembers[0];
+ await questLeader.update({[`items.quests.${PET_QUEST}`]: 1});
+ await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
+
+ await questingGroup.sync();
+
+ expect(questingGroup.quest.active).to.eql(true);
+ });
+
+ it('sends back the quest object', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ let quest = await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
+
+ expect(quest.active).to.eql(true);
+ expect(quest.key).to.eql(PET_QUEST);
+ expect(quest.members).to.eql({
+ [`${leader._id}`]: true,
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js
new file mode 100644
index 0000000000..973a51a1a5
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js
@@ -0,0 +1,192 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ sleep,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+import { quests as questScrolls } from '../../../../../common/script/content';
+
+describe('POST /groups/:groupId/quests/invite/:questKey', () => {
+ let questingGroup;
+ let leader;
+ let member;
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 1,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ member = members[0];
+ });
+
+ context('failure conditions', () => {
+ it('does not issue invites with an invalid group ID', async () => {
+ await expect(leader.post(`/groups/${generateUUID()}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not issue invites for a group in which user is not a member', async () => {
+ let { group } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 1,
+ });
+
+ let alternateGroup = group;
+
+ await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not issue invites for Guilds', async () => {
+ let { group } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'public' },
+ members: 1,
+ });
+
+ let alternateGroup = group;
+
+ await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('does not issue invites with an invalid quest key', async () => {
+ const FAKE_QUEST = 'herkimer';
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${FAKE_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questNotFound', {key: FAKE_QUEST}),
+ });
+ });
+
+ it('does not issue invites for a quest the user does not own', async () => {
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questNotOwned'),
+ });
+ });
+
+ it('does not issue invites if the user is of insufficient Level', async () => {
+ const LEVELED_QUEST = 'atom1';
+ const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl;
+ const leaderUpdate = {};
+ leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1;
+ leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1;
+
+ await leader.update(leaderUpdate);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}),
+ });
+ });
+
+ it('does not issue invites if a quest is already underway', async () => {
+ const QUEST_IN_PROGRESS = 'atom1';
+ const leaderUpdate = {};
+ leaderUpdate[`items.quests.${PET_QUEST}`] = 1;
+
+ await leader.update(leaderUpdate);
+ await questingGroup.update({ 'quest.key': QUEST_IN_PROGRESS });
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+ });
+
+ context('successfully issuing a quest invitation', () => {
+ beforeEach(async () => {
+ const memberUpdate = {};
+ memberUpdate[`items.quests.${PET_QUEST}`] = 1;
+
+ await Promise.all([
+ leader.update(memberUpdate),
+ member.update(memberUpdate),
+ ]);
+ });
+
+ it('adds quest details to group object', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await questingGroup.sync();
+
+ let quest = questingGroup.quest;
+
+ expect(quest.key).to.eql(PET_QUEST);
+ expect(quest.active).to.eql(false);
+ expect(quest.leader).to.eql(leader._id);
+ expect(quest.members).to.have.property(leader._id, true);
+ expect(quest.members).to.have.property(member._id, null);
+ expect(quest).to.have.property('progress');
+ });
+
+ it('adds quest details to user objects', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await sleep(0.1); // member updates happen in the background
+
+ await Promise.all([
+ leader.sync(),
+ member.sync(),
+ ]);
+
+ expect(leader.party.quest.key).to.eql(PET_QUEST);
+ expect(member.party.quest.key).to.eql(PET_QUEST);
+ expect(leader.party.quest.RSVPNeeded).to.eql(false);
+ expect(member.party.quest.RSVPNeeded).to.eql(true);
+ });
+
+ it('sends back the quest object', async () => {
+ let inviteResponse = await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ expect(inviteResponse.key).to.eql(PET_QUEST);
+ expect(inviteResponse.active).to.eql(false);
+ expect(inviteResponse.leader).to.eql(leader._id);
+ expect(inviteResponse.members).to.have.property(leader._id, true);
+ expect(inviteResponse.members).to.have.property(member._id, null);
+ expect(inviteResponse).to.have.property('progress');
+ });
+
+ it('allows non-party-leader party members to send invites', async () => {
+ let inviteResponse = await member.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await questingGroup.sync();
+
+ expect(inviteResponse.key).to.eql(PET_QUEST);
+ expect(questingGroup.quest.key).to.eql(PET_QUEST);
+ });
+
+ it('starts quest automatically if user is in a solo party', async () => {
+ let leaderDetails = { balance: 10 };
+ leaderDetails[`items.quests.${PET_QUEST}`] = 1;
+ let { group, groupLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ leaderDetails,
+ });
+
+ await groupLeader.post(`/groups/${group._id}/quests/invite/${PET_QUEST}`);
+
+ await group.sync();
+
+ expect(group.quest.active).to.eql(true);
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js
new file mode 100644
index 0000000000..850cf53646
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js
@@ -0,0 +1,126 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/abort', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when quest is not active', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('noActiveQuestToAbort'),
+ });
+ });
+
+ it('returns an error when non quest leader attempts to abort', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderAbortQuest'),
+ });
+ });
+ });
+
+ it('aborts a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`);
+ await Promise.all([
+ leader.sync(),
+ questingGroup.sync(),
+ partyMembers[0].sync(),
+ partyMembers[1].sync(),
+ ]);
+
+ let cleanUserQuestObj = {
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ };
+
+ expect(leader.party.quest).to.eql(cleanUserQuestObj);
+ expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj);
+ expect(partyMembers[1].party.quest).to.eql(cleanUserQuestObj);
+ expect(leader.items.quests[PET_QUEST]).to.equal(1);
+ expect(questingGroup.quest).to.deep.equal(res);
+ expect(questingGroup.quest).to.eql({
+ key: null,
+ active: false,
+ leader: null,
+ progress: {
+ collect: {},
+ },
+ members: {},
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js
new file mode 100644
index 0000000000..f3bd03a180
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js
@@ -0,0 +1,138 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/cancel', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not reject quest for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when group is not on a quest', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questInvitationDoesNotExist'),
+ });
+ });
+
+ it('only the leader can cancel the quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderCancelQuest'),
+ });
+ });
+
+ it('does not cancel a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('cantCancelActiveQuest'),
+ });
+ });
+ });
+
+ it('cancels a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ let res = await leader.post(`/groups/${questingGroup._id}/quests/cancel`);
+
+ await Promise.all([
+ leader.sync(),
+ partyMembers[0].sync(),
+ partyMembers[1].sync(),
+ questingGroup.sync(),
+ ]);
+
+ let clean = {
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ };
+
+ expect(leader.party.quest).to.eql(clean);
+ expect(partyMembers[1].party.quest).to.eql(clean);
+ expect(partyMembers[0].party.quest).to.eql(clean);
+
+ expect(res).to.eql(questingGroup.quest);
+ expect(questingGroup.quest).to.eql({
+ key: null,
+ active: false,
+ leader: null,
+ progress: {
+ collect: {},
+ },
+ members: {},
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js
new file mode 100644
index 0000000000..65d781c163
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js
@@ -0,0 +1,124 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/leave', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when quest is not active', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('noActiveQuestToLeave'),
+ });
+ });
+
+ it('returns an error when quest leader attempts to leave', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questLeaderCannotLeaveQuest'),
+ });
+ });
+
+ it('returns an error when non quest member attempts to leave', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`);
+
+ await expect(partyMembers[1].post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('notPartOfQuest'),
+ });
+ });
+ });
+
+ it('leaves a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ let leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`);
+ await Promise.all([
+ partyMembers[0].sync(),
+ questingGroup.sync(),
+ ]);
+
+ expect(partyMembers[0].party.quest).to.eql({
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ });
+ expect(questingGroup.quest).to.deep.equal(leaveResult);
+ expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false;
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js
new file mode 100644
index 0000000000..1eb62aa0c6
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js
@@ -0,0 +1,146 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/reject', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not accept quest for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when group is not on a quest', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questInvitationDoesNotExist'),
+ });
+ });
+
+ it('return an error when an user rejects an invite twice', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('questAlreadyRejected'),
+ });
+ });
+
+ it('return an error when an user rejects an invite already accepted', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('questAlreadyAccepted'),
+ });
+ });
+
+ it('does not reject invite for a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+ });
+
+ context('successfully quest rejection', () => {
+ let cleanUserQuestObj = {
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ };
+
+ it('rejects a quest invitation', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
+ await partyMembers[0].sync();
+ await questingGroup.sync();
+
+ expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj);
+ expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false;
+ expect(questingGroup.quest.active).to.be.false;
+ expect(res).to.eql(questingGroup.quest);
+ });
+
+ it('starts the quest when the last user reject', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`);
+ await questingGroup.sync();
+
+ expect(questingGroup.quest.active).to.be.true;
+ });
+ });
+});
diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js
new file mode 100644
index 0000000000..ef76e7b984
--- /dev/null
+++ b/test/api/v3/unit/models/group.test.js
@@ -0,0 +1,300 @@
+import { sleep } from '../../../../helpers/api-unit.helper';
+import { model as Group } from '../../../../../website/src/models/group';
+import { model as User } from '../../../../../website/src/models/user';
+import { quests as questScrolls } from '../../../../../common/script/content';
+import * as email from '../../../../../website/src/libs/api-v3/email';
+
+describe('Group Model', () => {
+ context('Instance Methods', () => {
+ describe('#startQuest', () => {
+ let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember;
+
+ beforeEach(async () => {
+ sandbox.stub(email, 'sendTxn');
+
+ party = new Group({
+ name: 'test party',
+ type: 'party',
+ privacy: 'private',
+ });
+
+ questLeader = new User({
+ party: { _id: party._id },
+ items: {
+ quests: {
+ whale: 1,
+ },
+ },
+ });
+
+ party.leader = questLeader._id;
+
+ participatingMember = new User({
+ party: { _id: party._id },
+ });
+ nonParticipatingMember = new User({
+ party: { _id: party._id },
+ });
+ undecidedMember = new User({
+ party: { _id: party._id },
+ });
+
+ await Promise.all([
+ party.save(),
+ questLeader.save(),
+ participatingMember.save(),
+ nonParticipatingMember.save(),
+ undecidedMember.save(),
+ ]);
+ });
+
+ context('Failure Conditions', () => {
+ it('throws an error if group is not a party', async () => {
+ let guild = new Group({
+ type: 'guild',
+ });
+
+ await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected;
+ });
+
+ it('throws an error if party is not on a quest', async () => {
+ await expect(party.startQuest(participatingMember)).to.eventually.be.rejected;
+ });
+
+ it('throws an error if quest is already active', async () => {
+ party.quest.key = 'whale';
+ party.quest.active = true;
+
+ await expect(party.startQuest(participatingMember)).to.eventually.be.rejected;
+ });
+ });
+
+ context('Successes', () => {
+ beforeEach(() => {
+ party.quest.key = 'whale';
+ party.quest.active = false;
+ party.quest.leader = questLeader._id;
+ party.quest.members = { };
+ party.quest.members[questLeader._id] = true;
+ party.quest.members[participatingMember._id] = true;
+ party.quest.members[nonParticipatingMember._id] = false;
+ party.quest.members[undecidedMember._id] = null;
+ });
+
+ it('activates quest', () => {
+ party.startQuest(participatingMember);
+
+ expect(party.quest.active).to.eql(true);
+ });
+
+ it('sets up boss quest', () => {
+ let bossQuest = questScrolls.whale;
+ party.quest.key = bossQuest.key;
+
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp);
+ });
+
+ it('sets up rage meter for rage boss quest', () => {
+ let rageBossQuest = questScrolls.trex_undead;
+ party.quest.key = rageBossQuest.key;
+
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.rage).to.eql(0);
+ });
+
+ it('sets up collection quest', () => {
+ let collectionQuest = questScrolls.vice2;
+ party.quest.key = collectionQuest.key;
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.collect).to.eql({
+ lightCrystal: 0,
+ });
+ });
+
+ it('sets up collection quest with multiple items', () => {
+ let collectionQuest = questScrolls.evilsanta2;
+ party.quest.key = collectionQuest.key;
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.collect).to.eql({
+ tracks: 0,
+ branches: 0,
+ });
+ });
+
+ it('prunes non-participating members from quest members object', () => {
+ party.startQuest(participatingMember);
+
+ let expectedQuestMembers = {};
+ expectedQuestMembers[questLeader._id] = true;
+ expectedQuestMembers[participatingMember._id] = true;
+
+ expect(party.quest.members).to.eql(expectedQuestMembers);
+ });
+
+ it('applies updates to user object directly if user is participating', async () => {
+ await party.startQuest(participatingMember);
+
+ expect(participatingMember.party.quest.key).to.eql('whale');
+ expect(participatingMember.party.quest.progress.down).to.eql(0);
+ expect(participatingMember.party.quest.collect).to.eql({});
+ expect(participatingMember.party.quest.completed).to.eql(null);
+ });
+
+ it('applies updates to other participating members', async () => {
+ await party.startQuest(nonParticipatingMember);
+
+ questLeader = await User.findById(questLeader._id);
+ participatingMember = await User.findById(participatingMember._id);
+
+ expect(participatingMember.party.quest.key).to.eql('whale');
+ expect(participatingMember.party.quest.progress.down).to.eql(0);
+ expect(participatingMember.party.quest.progress.collect).to.eql({});
+ expect(participatingMember.party.quest.completed).to.eql(null);
+
+ expect(questLeader.party.quest.key).to.eql('whale');
+ expect(questLeader.party.quest.progress.down).to.eql(0);
+ expect(questLeader.party.quest.progress.collect).to.eql({});
+ expect(questLeader.party.quest.completed).to.eql(null);
+ });
+
+ it('does not apply updates to nonparticipating members', async () => {
+ await party.startQuest(participatingMember);
+
+ nonParticipatingMember = await User.findById(nonParticipatingMember ._id);
+ undecidedMember = await User.findById(undecidedMember._id);
+
+ expect(nonParticipatingMember.party.quest.key).to.not.eql('whale');
+ expect(undecidedMember.party.quest.key).to.not.eql('whale');
+ });
+
+ it('sends email to participating members that quest has started', async () => {
+ participatingMember.preferences.emailNotifications.questStarted = true;
+ questLeader.preferences.emailNotifications.questStarted = true;
+ await Promise.all([
+ participatingMember.save(),
+ questLeader.save(),
+ ]);
+
+ await party.startQuest(nonParticipatingMember);
+
+ await sleep(0.5);
+
+ expect(email.sendTxn).to.be.calledOnce;
+
+ let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
+ let typeOfEmail = email.sendTxn.args[0][1];
+
+ expect(memberIds).to.have.a.lengthOf(2);
+ expect(memberIds).to.include(participatingMember._id);
+ expect(memberIds).to.include(questLeader._id);
+ expect(typeOfEmail).to.eql('quest-started');
+ });
+
+ it('sends email only to members who have not opted out', async () => {
+ participatingMember.preferences.emailNotifications.questStarted = false;
+ questLeader.preferences.emailNotifications.questStarted = true;
+ await Promise.all([
+ participatingMember.save(),
+ questLeader.save(),
+ ]);
+
+ await party.startQuest(nonParticipatingMember);
+
+ await sleep(0.5);
+
+ expect(email.sendTxn).to.be.calledOnce;
+
+ let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
+
+ expect(memberIds).to.have.a.lengthOf(1);
+ expect(memberIds).to.not.include(participatingMember._id);
+ expect(memberIds).to.include(questLeader._id);
+ });
+
+ it('does not send email to initiating member', async () => {
+ participatingMember.preferences.emailNotifications.questStarted = true;
+ questLeader.preferences.emailNotifications.questStarted = true;
+ await Promise.all([
+ participatingMember.save(),
+ questLeader.save(),
+ ]);
+
+ await party.startQuest(participatingMember);
+
+ await sleep(0.5);
+
+ expect(email.sendTxn).to.be.calledOnce;
+
+ let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
+
+ expect(memberIds).to.have.a.lengthOf(1);
+ expect(memberIds).to.not.include(participatingMember._id);
+ expect(memberIds).to.include(questLeader._id);
+ });
+
+ it('updates participting members (not including user)', async () => {
+ sandbox.spy(User, 'update');
+
+ await party.startQuest(nonParticipatingMember);
+
+ let members = [questLeader._id, participatingMember._id];
+
+ expect(User.update).to.be.calledWith(
+ { _id: { $in: members } },
+ {
+ $set: {
+ 'party.quest.key': 'whale',
+ 'party.quest.progress.down': 0,
+ 'party.quest.collect': {},
+ 'party.quest.completed': null,
+ },
+ }
+ );
+ });
+
+ it('updates non-user quest leader and decrements quest scroll', async () => {
+ sandbox.spy(User, 'update');
+
+ await party.startQuest(participatingMember);
+
+ expect(User.update).to.be.calledWith(
+ { _id: questLeader._id },
+ {
+ $inc: {
+ 'items.quests.whale': -1,
+ },
+ }
+ );
+ });
+
+ it('modifies the participating initiating user directly', async () => {
+ await party.startQuest(participatingMember);
+
+ let userQuest = participatingMember.party.quest;
+
+ expect(userQuest.key).to.eql('whale');
+ expect(userQuest.progress.down).to.eql(0);
+ expect(userQuest.collect).to.eql({});
+ expect(userQuest.completed).to.eql(null);
+ });
+
+ it('does not modify user if not participating', async () => {
+ await party.startQuest(nonParticipatingMember);
+
+ expect(nonParticipatingMember.party.quest.key).to.not.eql('whale');
+ });
+
+ it('removes the quest directly if initiating user is the quest leader', async () => {
+ await party.startQuest(questLeader);
+
+ expect(questLeader.items.quests.whale).to.eql(0);
+ });
+ });
+ });
+ });
+});
diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js
index 4ee2e6cfbe..6ae15d7ca6 100644
--- a/test/helpers/api-integration/v3/index.js
+++ b/test/helpers/api-integration/v3/index.js
@@ -8,11 +8,4 @@ export { requester };
export { translate } from '../translate';
export { checkExistence, resetHabiticaDB } from '../../mongo';
export * from './object-generators';
-
-export async function sleep (seconds) {
- let milliseconds = seconds * 1000;
-
- return new Promise((resolve) => {
- setTimeout(resolve, milliseconds);
- });
-}
+export { sleep } from '../../sleep';
diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js
index 3370bd0887..1a590f8a3a 100644
--- a/test/helpers/api-unit.helper.js
+++ b/test/helpers/api-unit.helper.js
@@ -10,6 +10,8 @@ afterEach((done) => {
mongoose.connection.db.dropDatabase(done);
});
+export { sleep } from './sleep';
+
export function generateUser (options = {}) {
return new User(options).toObject();
}
diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js
new file mode 100644
index 0000000000..f8dd9ab165
--- /dev/null
+++ b/test/helpers/sleep.js
@@ -0,0 +1,7 @@
+export async function sleep (seconds) {
+ let milliseconds = seconds * 1000;
+
+ return new Promise((resolve) => {
+ setTimeout(resolve, milliseconds);
+ });
+}
diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js
new file mode 100644
index 0000000000..e0ad3b86d4
--- /dev/null
+++ b/website/src/controllers/api-v3/quests.js
@@ -0,0 +1,443 @@
+import _ from 'lodash';
+import Q from 'q';
+import { authWithHeaders } from '../../middlewares/api-v3/auth';
+import cron from '../../middlewares/api-v3/cron';
+import analytics from '../../libs/api-v3/analyticsService';
+import {
+ model as Group,
+} from '../../models/group';
+import { model as User } from '../../models/user';
+import {
+ NotFound,
+ NotAuthorized,
+ BadRequest,
+} from '../../libs/api-v3/errors';
+import {
+ getUserInfo,
+ sendTxn as sendTxnEmail,
+} from '../../libs/api-v3/email';
+import { quests as questScrolls } from '../../../../common/script/content';
+
+function canStartQuestAutomatically (group) {
+ // If all members are either true (accepted) or false (rejected) return true
+ // If any member is null/undefined (undecided) return false
+ return _.every(group.quest.members, _.isBoolean);
+}
+
+let api = {};
+
+/**
+ * @api {post} /groups/:groupId/quests/invite Invite users to a quest
+ * @apiVersion 3.0.0
+ * @apiName InviteToQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.inviteToQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/invite/:questKey',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let questKey = req.params.questKey;
+ let quest = questScrolls[questKey];
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey }));
+ if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned'));
+ if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl }));
+ if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+
+ let members = await User.find({
+ 'party._id': group._id,
+ _id: {$ne: user._id},
+ }).select('auth.facebook auth.local preferences.emailNotifications profile.name')
+ .exec();
+
+ group.markModified('quest');
+ group.quest.key = questKey;
+ group.quest.leader = user._id;
+ group.quest.members = {};
+ group.quest.members[user._id] = true;
+
+ user.party.quest.RSVPNeeded = false;
+ user.party.quest.key = questKey;
+
+ await User.update({
+ 'party._id': group._id,
+ _id: {$ne: user._id},
+ }, {
+ $set: {
+ 'party.quest.RSVPNeeded': true,
+ 'party.quest.key': questKey,
+ },
+ }, {multi: true}).exec();
+
+ _.each(members, (member) => {
+ group.quest.members[member._id] = null;
+ });
+
+ if (canStartQuestAutomatically(group)) {
+ await group.startQuest(user);
+ }
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ // send out invites
+ let inviterVars = getUserInfo(user, ['name', 'email']);
+ let membersToEmail = members.filter(member => {
+ return member.preferences.emailNotifications.invitedQuest !== false;
+ });
+ sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [
+ {name: 'QUEST_NAME', content: quest.text()},
+ {name: 'INVITER', content: inviterVars.name},
+ {name: 'REPLY_TO_ADDRESS', content: inviterVars.email},
+ {name: 'PARTY_URL', content: '/#/options/groups/party'},
+ ]);
+
+ // track that the inviting user has accepted the quest
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: true,
+ response: 'accept',
+ gaLabel: 'accept',
+ questName: questKey,
+ uuid: user._id,
+ });
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/accept Accept a pending quest
+ * @apiVersion 3.0.0
+ * @apiName AcceptQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.acceptQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/accept',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound'));
+ if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+ if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
+
+ group.markModified('quest');
+ group.quest.members[user._id] = true;
+ user.party.quest.RSVPNeeded = false;
+
+ if (canStartQuestAutomatically(group)) {
+ await group.startQuest(user);
+ }
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ // track that an user has accepted the quest
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: false,
+ response: 'accept',
+ gaLabel: 'accept',
+ questName: group.quest.key,
+ uuid: user._id,
+ });
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/reject Reject a quest
+ * @apiVersion 3.0.0
+ * @apiName RejectQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.rejectQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/reject',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
+ if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+ if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
+ if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
+
+ group.quest.members[user._id] = false;
+ group.markModified('quest.members');
+
+ user.party.quest = Group.cleanQuestProgress();
+ user.markModified('party.quest');
+
+ if (canStartQuestAutomatically(group)) {
+ await group.startQuest(user);
+ }
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: false,
+ response: 'reject',
+ gaLabel: 'reject',
+ questName: group.quest.key,
+ uuid: user._id,
+ });
+ },
+};
+
+
+/**
+ * @api {post} /groups/:groupId/quests/force-start Accept a pending quest
+ * @apiVersion 3.0.0
+ * @apiName forceStart
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.forceStart = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/force-start',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questNotPending'));
+ if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+ if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest'));
+
+ group.markModified('quest');
+
+ await group.startQuest(user);
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: user._id === group.quest.leader,
+ response: 'force-start',
+ gaLabel: 'force-start',
+ questName: group.quest.key,
+ uuid: user._id,
+ });
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/cancel Cancels a quest
+ * @apiVersion 3.0.0
+ * @apiName CancelQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.cancelQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/cancel',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ // Cancel a quest BEFORE it has begun (i.e., in the invitation stage)
+ // Quest scroll has not yet left quest owner's inventory so no need to return it.
+ // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started.
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'type leader quest'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
+ if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest'));
+ if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
+
+ group.quest = Group.cleanGroupQuest();
+ group.markModified('quest');
+
+ let [savedGroup] = await Promise.all([
+ group.save(),
+ User.update(
+ {'party._id': groupId},
+ {$set: {'party.quest': Group.cleanQuestProgress()}},
+ {multi: true}
+ ),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/abort Abort the current quest
+ * @apiVersion 3.0.0
+ * @apiName AbortQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.abortQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/abort',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ // Abort a quest AFTER it has begun (see questCancel for BEFORE)
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'type quest leader'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort'));
+ if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
+
+ let memberUpdates = User.update({
+ 'party._id': groupId,
+ }, {
+ $set: {'party.quest': Group.cleanQuestProgress()},
+ $inc: {_v: 1}, // TODO update middleware
+ }, {multi: true}).exec();
+
+ let questLeaderUpdate = User.update({
+ _id: group.quest.leader,
+ }, {
+ $inc: {
+ [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader
+ },
+ }).exec();
+
+ group.quest = Group.cleanGroupQuest();
+ group.markModified('quest');
+
+ let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]);
+
+ res.respond(200, groupSaved.quest);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/leave Leaves the active quest
+ * @apiVersion 3.0.0
+ * @apiName LeaveQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.leaveQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/leave',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'type quest'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave'));
+ if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest'));
+ if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest'));
+
+ group.quest.members[user._id] = false;
+ group.markModified('quest.members');
+
+ user.party.quest = Group.cleanQuestProgress();
+ user.markModified('party.quest');
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+ },
+};
+
+export default api;
diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js
index 2b711a5a2a..9ec66296a1 100644
--- a/website/src/controllers/api-v3/tasks.js
+++ b/website/src/controllers/api-v3/tasks.js
@@ -15,7 +15,7 @@ import Q from 'q';
import _ from 'lodash';
import moment from 'moment';
import scoreTask from '../../../../common/script/api-v3/scoreTask';
-import { preenHistory } from '../../../../common/script/api-v3/preening';
+import { preenHistory } from '../../libs/api-v3/preening';
let api = {};
diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js
index ae9ad6f60f..2c435da9dd 100644
--- a/website/src/libs/api-v3/analyticsService.js
+++ b/website/src/libs/api-v3/analyticsService.js
@@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => {
});
};
+// TODO log errors...
function track (eventType, data) {
return Q.all([
_sendDataToAmplitude(eventType, data),
diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js
index ce40085552..95d3981601 100644
--- a/website/src/libs/api-v3/collectionManipulators.js
+++ b/website/src/libs/api-v3/collectionManipulators.js
@@ -1,9 +1,12 @@
-import { findIndex } from 'lodash';
+import {
+ findIndex,
+ isPlainObject,
+} from 'lodash';
export function removeFromArray (array, element) {
let elementIndex;
- if (typeof element === 'object') {
+ if (isPlainObject(element)) {
elementIndex = findIndex(array, element);
} else {
elementIndex = array.indexOf(element);
diff --git a/common/script/api-v3/preening.js b/website/src/libs/api-v3/preening.js
similarity index 100%
rename from common/script/api-v3/preening.js
rename to website/src/libs/api-v3/preening.js
diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js
index 288452e670..d57bfb28f8 100644
--- a/website/src/middlewares/api-v3/cron.js
+++ b/website/src/middlewares/api-v3/cron.js
@@ -2,15 +2,274 @@ import _ from 'lodash';
import moment from 'moment';
import {
daysSince,
+ shouldDo,
} from '../../../../common/script/cron';
-import cron from '../../../../common/script/api-v3/cron';
import common from '../../../../common';
import Task from '../../models/task';
import Q from 'q';
-// import Group from '../../models/group';
+import Group from '../../models/group';
+import User from '../../models/user';
+import scoreTask from '../../../../common/script/api-v3/scoreTask';
+import { preenUserHistory } from '../../libs/api-v3/preening';
+
+let clearBuffs = {
+ str: 0,
+ int: 0,
+ per: 0,
+ con: 0,
+ stealth: 0,
+ streaks: false,
+};
+
+// At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
+// For incomplete Dailys, deduct experience
+// Make sure to run this function once in a while as server will not take care of overnight calculations.
+// And you have to run it every time client connects.
+export function cron (options = {}) {
+ let {user, tasksByType, analytics, now, daysMissed} = options;
+
+ user.auth.timestamps.loggedin = now;
+ user.lastCron = now;
+ // Reset the lastDrop count to zero
+ if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
+
+ // "Perfect Day" achievement for perfect-days
+ let perfect = true;
+
+ // end-of-month perks for subscribers
+ let plan = user.purchased.plan;
+ if (user.isSubscribed()) {
+ if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) {
+ plan.gemsBought = 0; // reset gem-cap
+ plan.dateUpdated = now;
+ // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
+ // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
+ // TODO use month diff instead of ++ / --?
+ _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317
+ plan.consecutive.count++;
+ if (plan.consecutive.offset > 0) {
+ plan.consecutive.offset--;
+ } else if (plan.consecutive.count % 3 === 0) { // every 3 months
+ plan.consecutive.trinkets++;
+ plan.consecutive.gemCapExtra += 5;
+ if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
+ }
+ }
+
+ // If user cancelled subscription, we give them until 30day's end until it terminates
+ if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) {
+ _.merge(plan, {
+ planId: null,
+ customerId: null,
+ paymentMethod: null,
+ });
+
+ _.merge(plan.consecutive, {
+ count: 0,
+ offset: 0,
+ gemCapExtra: 0,
+ });
+
+ user.markModified('purchased.plan');
+ }
+ }
+
+ // User is resting at the inn.
+ // On cron, buffs are cleared and all dailies are reset without performing damage
+ if (user.preferences.sleep === true) {
+ user.stats.buffs = _.cloneDeep(clearBuffs);
+
+ tasksByType.dailys.forEach((daily) => {
+ let completed = daily.completed;
+ let thatDay = moment(now).subtract({days: 1});
+
+ if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
+ daily.checklist.forEach(box => box.completed = false);
+ }
+ daily.completed = false;
+ });
+
+ return;
+ }
+
+ let multiDaysCountAsOneDay = true;
+ // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day.
+ // When site-wide difficulty settings are introduced, this can be a user preference option.
+
+ // Tally each task
+ let todoTally = 0;
+
+ tasksByType.todos.forEach(task => { // make uncompleted todos redder
+ scoreTask({
+ task,
+ user,
+ direction: 'down',
+ cron: true,
+ times: multiDaysCountAsOneDay ? 1 : daysMissed,
+ // TODO pass req for analytics?
+ });
+
+ todoTally += task.value;
+ });
+
+ let dailyChecked = 0; // how many dailies were checked?
+ let dailyDueUnchecked = 0; // how many dailies were cun-hecked?
+ if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
+
+ tasksByType.dailys.forEach((task) => {
+ let completed = task.completed;
+ // Deduct points for missed Daily tasks
+ let EvadeTask = 0;
+ let scheduleMisses = daysMissed;
+
+ if (completed) {
+ dailyChecked += 1;
+ } else {
+ // dailys repeat, so need to calculate how many they've missed according to their own schedule
+ scheduleMisses = 0;
+
+ for (let i = 0; i < daysMissed; i++) {
+ let thatDay = moment(now).subtract({days: i + 1});
+
+ if (shouldDo(thatDay.toDate(), task, user.preferences)) {
+ scheduleMisses++;
+ if (user.stats.buffs.stealth) {
+ user.stats.buffs.stealth--;
+ EvadeTask++;
+ }
+ if (multiDaysCountAsOneDay) break;
+ }
+ }
+
+ if (scheduleMisses > EvadeTask) {
+ perfect = false;
+
+ if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
+ let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
+ dailyDueUnchecked += 1 - fractionChecked;
+ dailyChecked += fractionChecked;
+ } else {
+ dailyDueUnchecked += 1;
+ }
+
+ let delta = scoreTask({
+ user,
+ task,
+ direction: 'down',
+ times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask,
+ cron: true,
+ });
+
+ // Apply damage from a boss, less damage for Trivial priority (difficulty)
+ user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1);
+ // NB: Medium and Hard priorities do not increase damage from boss. This was by accident
+ // initially, and when we realised, we could not fix it because users are used to
+ // their Medium and Hard Dailies doing an Easy amount of damage from boss.
+ // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future
+ // setting between Trivial and Easy.
+ }
+ }
+
+ task.history.push({
+ date: Number(new Date()),
+ value: task.value,
+ });
+ task.completed = false;
+
+ if (completed || scheduleMisses > 0) {
+ task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed
+ }
+ });
+
+ tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0
+ if (task.up === false || task.down === false) {
+ task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2;
+ }
+ });
+
+ // Finished tallying
+ user.history.todos.push({date: now, value: todoTally});
+
+ // tally experience
+ let expTally = user.stats.exp;
+ let lvl = 0; // iterator
+ while (lvl < user.stats.lvl - 1) {
+ lvl++;
+ expTally += common.tnl(lvl);
+ }
+ user.history.exp.push({date: now, value: expTally});
+
+ // preen user history so that it doesn't become a performance problem
+ // also for subscribed users but differentyly
+ // premium subscribers can keep their full history.
+ preenUserHistory(user, tasksByType, user.preferences.timezoneOffset);
+
+ if (perfect) {
+ user.achievements.perfect++;
+ let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2);
+ user.stats.buffs = {
+ str: lvlDiv2,
+ int: lvlDiv2,
+ per: lvlDiv2,
+ con: lvlDiv2,
+ stealth: 0,
+ streaks: false,
+ };
+ } else {
+ user.stats.buffs = _.cloneDeep(clearBuffs);
+ }
+
+ // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit
+ // Adjust for fraction of dailies completed
+ user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
+ if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP;
+
+ if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1;
+ user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
+ if (user.stats.mp > user._statsComputed.maxMP) {
+ user.stats.mp = user._statsComputed.maxMP;
+ }
+
+ // After all is said and done, progress up user's effect on quest, return those values & reset the user's
+ let progress = user.party.quest.progress;
+ let _progress = _.cloneDeep(progress);
+ _.merge(progress, {down: 0, up: 0});
+ progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
+
+ // Clean PMs - keep 200 for subscribers and 50 for free users
+ // TODO tests
+ let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too
+ let numberOfPMs = Object.keys(user.inbox.messages).length;
+ if (Object.keys(user.inbox.messages).length > maxPMs) {
+ _(user.inbox.messages)
+ .sortBy('timestamp')
+ .takeRight(numberOfPMs - maxPMs)
+ .each(pm => {
+ user.inbox.messages[pm.id] = undefined;
+ }).value();
+
+ user.markModified('inbox.messages');
+ }
+
+ // Analytics
+ user.flags.cronCount++;
+ analytics.track('Cron', {
+ category: 'behavior',
+ gaLabel: 'Cron Count',
+ gaValue: user.flags.cronCount,
+ uuid: user._id,
+ user, // TODO is it really necessary passing the whole user object?
+ resting: user.preferences.sleep,
+ cronCount: user.flags.cronCount,
+ progressUp: _.min([_progress.up, 900]),
+ progressDown: _progress.down,
+ });
+
+ return _progress;
+}
// TODO check that it's used everywhere
-export default function cronMiddleware (req, res, next) {
+export default async function cronMiddleware (req, res, next) {
let user = res.locals.user;
let analytics = res.analytics;
@@ -32,7 +291,7 @@ export default function cronMiddleware (req, res, next) {
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
// Run cron
- cron({user, tasksByType, now, daysMissed, analytics});
+ let progress = cron({user, tasksByType, now, daysMissed, analytics});
// Clear old completed todos - 30 days for free users, 90 for subscribers
// Do not delete challenges completed todos TODO unless the task is broken?
@@ -44,44 +303,36 @@ export default function cronMiddleware (req, res, next) {
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'),
},
'challenge.id': {$exists: false},
- }).exec(); // TODO catch error or at least log it
+ }).exec(); // TODO catch error or at least log it, wait before returning?
let ranCron = user.isModified();
let quest = common.content.quests[user.party.quest.key];
// if (ranCron) res.locals.wasModified = true; // TODO remove?
if (!ranCron) return next();
- // TODO Group.tavernBoss(user, progress);
- if (!quest || true /* TODO remove */) {
- // Save user and tasks
- let toSave = [user.save()];
- tasks.forEach(task => {
- if (task.isModified) toSave.push(task.save());
+
+ // Group.tavernBoss(user, progress);
+
+ // Save user and tasks
+ let toSave = [user.save()];
+ tasks.forEach(task => {
+ if (task.isModified) toSave.push(task.save());
+ });
+ Q.all(toSave)
+ .then(saved => {
+ user = res.locals.user = saved[0];
+ if (!quest) return;
+
+ // If user is on a quest, roll for boss & player, or handle collections
+ let questType = quest.boss ? 'boss' : 'collect';
+ // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
+ return Group[`${questType}Quest`](user, progress)
+ .then(() => User.findById(user._id).exec()) // fetch the updated user...
+ .then(updatedUser => {
+ res.locals.user = updatedUser;
});
-
- return Q.all(toSave).then(() => next()).catch(next);
- }
-
- // If user is on a quest, roll for boss & player, or handle collections
- // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
- // TODO do
- /* async.waterfall([
- function(cb){
- user.save(cb); // make sure to save the cron effects
- },
- function(saved, count, cb){
- var type = quest.boss ? 'boss' : 'collect';
- Group[type+'Quest'](user,progress,cb);
- },
- function(){
- var cb = arguments[arguments.length-1];
- // User has been updated in boss-grapple, reload
- User.findById(user._id, cb);
- }
- ], function(err, saved) {
- res.locals.user = saved;
- next(err,saved);
- user = progress = quest = null;
- });*/
+ })
+ .then(() => next())
+ .catch(next);
});
}
diff --git a/website/src/models/group.js b/website/src/models/group.js
index cab711d4a6..f89147f246 100644
--- a/website/src/models/group.js
+++ b/website/src/models/group.js
@@ -8,8 +8,11 @@ import _ from 'lodash';
import { model as Challenge} from './challenge';
import validator from 'validator';
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
+import { InternalServerError } from '../libs/api-v3/errors';
import * as firebase from '../libs/api-v2/firebase';
import baseModel from '../libs/api-v3/baseModel';
+import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
+import { quests as questScrolls } from '../../../common/script/content';
import Q from 'q';
import nconf from 'nconf';
@@ -105,9 +108,8 @@ export let basicFields = 'name type privacy';
// TODO test
schema.pre('remove', true, async function preRemoveGroup (next, done) {
next();
- let group = this;
try {
- await group.removeGroupInvitations();
+ await this.removeGroupInvitations();
done();
} catch (err) {
done(err);
@@ -213,7 +215,7 @@ schema.methods.sendChat = function sendChat (message, user) {
this.chat.splice(200);
// Kick off chat notifications in the background. // TODO refactor
- let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; // TODO standardize this _v inc at the user level
+ let lastSeenUpdate = {$set: {}, $inc: {_v: 1}};
lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true};
if (this._id === 'habitrpg') {
@@ -235,8 +237,87 @@ schema.methods.sendChat = function sendChat (message, user) {
}
};
+schema.methods.startQuest = async function startQuest (user) {
+ // not using i18n strings because these errors are meant for devs who forgot to pass some parameters
+ if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method');
+ if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest');
+ if (this.quest.active) throw new InternalServerError('Quest is already active');
+
+ let userIsParticipating = this.quest.members[user._id];
+ let quest = questScrolls[this.quest.key];
+ let collected = {};
+ if (quest.collect) {
+ collected = _.transform(quest.collect, (result, n, itemToCollect) => {
+ result[itemToCollect] = 0;
+ });
+ }
+
+ this.markModified('quest');
+ this.quest.active = true;
+ if (quest.boss) {
+ this.quest.progress.hp = quest.boss.hp;
+ if (quest.boss.rage) this.quest.progress.rage = 0;
+ } else if (quest.collect) {
+ this.quest.progress.collect = collected;
+ }
+
+ // Changes quest.members to only include participating members
+ // TODO: is that important? What does it matter if the non-participating members
+ // are still on the object?
+ // TODO: is it important to run clean quest progress on non-members like we did in v2?
+ this.quest.members = _.pick(this.quest.members, _.identity);
+ let nonUserQuestMembers = _.keys(this.quest.members);
+ removeFromArray(nonUserQuestMembers, user._id);
+
+ if (userIsParticipating) {
+ user.party.quest.key = this.quest.key;
+ user.party.quest.progress.down = 0;
+ user.party.quest.collect = collected;
+ user.party.quest.completed = null;
+ user.markModified('party.quest');
+ }
+
+ // Remove the quest from the quest leader items (if they are the current user)
+ if (this.quest.leader === user._id) {
+ user.items.quests[this.quest.key] -= 1;
+ user.markModified('items.quests');
+ } else { // another user is starting the quest, update the leader separately
+ await User.update({_id: this.quest.leader}, {
+ $inc: {
+ [`items.quests.${this.quest.key}`]: -1,
+ },
+ }).exec();
+ }
+
+ // update the remaining users
+ await User.update({
+ _id: { $in: nonUserQuestMembers },
+ }, {
+ $set: {
+ 'party.quest.key': this.quest.key,
+ 'party.quest.progress.down': 0,
+ 'party.quest.collect': collected,
+ 'party.quest.completed': null,
+ },
+ }, { multi: true }).exec();
+
+ // send notifications in the background without blocking
+ User.find(
+ { _id: { $in: nonUserQuestMembers } },
+ 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name',
+ ).exec().then(membersToEmail => {
+ membersToEmail = _.filter(membersToEmail, (member) => {
+ return member.preferences.emailNotifications.questStarted !== false &&
+ member._id !== user._id;
+ });
+ sendTxnEmail(membersToEmail, 'quest-started', [
+ { name: 'PARTY_URL', content: '/#/options/groups/party' },
+ ]);
+ });
+};
+
+// return a clean object for user.quest
function _cleanQuestProgress (merge) {
- // TODO clone? (also in sendChat message)
let clean = {
key: null,
progress: {
@@ -245,20 +326,35 @@ function _cleanQuestProgress (merge) {
collect: {},
},
completed: null,
- RSVPNeeded: false, // TODO absolutely change this cryptic name
+ RSVPNeeded: false,
};
- if (merge) { // TODO why does it do 2 merges?
+ if (merge) {
_.merge(clean, _.omit(merge, 'progress'));
- _.merge(clean.progress, merge.progress);
+ if (merge.progress) _.merge(clean.progress, merge.progress);
}
return clean;
}
+// TODO move to User.cleanQuestProgress?
schema.statics.cleanQuestProgress = _cleanQuestProgress;
+// returns a clean object for group.quest
+schema.statics.cleanGroupQuest = function cleanGroupQuest () {
+ return {
+ key: null,
+ active: false,
+ leader: null,
+ progress: {
+ collect: {},
+ },
+ members: {},
+ };
+};
+
// Participants: Grant rewards & achievements, finish quest
+// Returns the promise from update().exec()
schema.methods.finishQuest = function finishQuest (quest) {
let questK = quest.key;
let updates = {$inc: {}, $set: {}};
@@ -300,48 +396,90 @@ schema.methods.finishQuest = function finishQuest (quest) {
let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}};
this.quest = {};
this.markModified('quest');
- return User.update(q, updates, {multi: true});
+ return User.update(q, updates, {multi: true}).exec();
};
function _isOnQuest (user, progress, group) {
return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true;
}
-schema.statics.collectQuest = function collectQuest (user, progress) {
- return this.findOne({
- type: 'party',
- members: {$in: [user._id]},
- }).then(group => {
- if (!_isOnQuest(user, progress, group)) return;
- let quest = shared.content.quests[group.quest.key];
+// Returns a promise
+schema.statics.collectQuest = async function collectQuest (user, progress) {
+ let group = await this.getGroup({user, groupId: 'party'});
- _.each(progress.collect, (v, k) => {
- group.quest.progress.collect[k] += v;
- });
+ if (!_isOnQuest(user, progress, group)) return;
+ let quest = shared.content.quests[group.quest.key];
- let foundText = _.reduce(progress.collect, (m, v, k) => {
- m.push(`${v} ${quest.collect[k].text('en')}`);
- return m;
- }, []);
+ _.each(progress.collect, (v, k) => {
+ group.quest.progress.collect[k] += v;
+ });
- foundText = foundText ? foundText.join(', ') : 'nothing';
- group.sendChat(`\`${user.profile.name} found ${foundText}.\``);
- group.markModified('quest.progress.collect');
+ let foundText = _.reduce(progress.collect, (m, v, k) => {
+ m.push(`${v} ${quest.collect[k].text('en')}`);
+ return m;
+ }, []);
- // Still needs completing
- if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => {
- return group.quest.progress.collect[k] < v.count;
- })) return group.save();
+ foundText = foundText ? foundText.join(', ') : 'nothing';
+ group.sendChat(`\`${user.profile.name} found ${foundText}.\``);
+ group.markModified('quest.progress.collect');
- // TODO use promise
- return group.finishQuest(quest)
- .then(() => {
- group.sendChat('`All items found! Party has received their rewards.`');
- return group.save();
- });
- })
- // TODO ok to catch even if we're returning a promise?
- .catch();
+ // Still needs completing
+ if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => {
+ return group.quest.progress.collect[k] < v.count;
+ })) return group.save();
+
+ await group.finishQuest(quest);
+ group.sendChat('`All items found! Party has received their rewards.`');
+ return group.save();
+};
+
+schema.statics.bossQuest = async function bossQuest (user, progress) {
+ let group = await this.getGroup({user, groupId: 'party'});
+ if (!_isOnQuest(user, progress, group)) return;
+
+ let quest = shared.content.quests[group.quest.key];
+ if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point, log?
+
+ let down = progress.down * quest.boss.str; // multiply by boss strength
+
+ group.quest.progress.hp -= progress.up;
+ // TODO Create a party preferred language option so emits like this can be localized
+ group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``);
+
+ // If boss has Rage, increment Rage as well
+ if (quest.boss.rage) {
+ group.quest.progress.rage += Math.abs(down);
+ if (group.quest.progress.rage >= quest.boss.rage.value) {
+ group.sendChat(quest.boss.rage.effect('en'));
+ group.quest.progress.rage = 0;
+
+ // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage
+ if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing;
+ if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
+ }
+ }
+
+ // Everyone takes damage
+ await User.update({
+ _id: {$in: _.keys(group.quest.members)},
+ }, {
+ $inc: {'stats.hp': down, _v: 1},
+ }, {multi: true}).exec();
+ // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state
+ // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167
+ // must be notModified or otherwise could overwrite future changes
+ // if (down) user.stats.hp += down;
+
+ // Boss slain, finish quest
+ if (group.quest.progress.hp <= 0) {
+ group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``);
+
+ // Participants: Grant rewards & achievements, finish quest
+ await group.finishQuest(shared.content.quests[group.quest.key]);
+ return group.save();
+ }
+
+ return group.save();
};
// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
@@ -364,123 +502,65 @@ process.nextTick(() => {
});
});
-// TODO promise?
-schema.statics.tavernBoss = function tavernBoss (user, progress) {
+// returns a promise
+schema.statics.tavernBoss = async function tavernBoss (user, progress) {
if (!progress) return;
// hack: prevent crazy damage to world boss
let dmg = Math.min(900, Math.abs(progress.up || 0));
let rage = -Math.min(900, Math.abs(progress.down || 0));
- this.findOne(tavernQ).exec()
- .then(tavern => {
- if (!(tavern && tavern.quest && tavern.quest.key)) return;
+ let tavern = await this.findOne(tavernQ).exec();
+ if (!(tavern && tavern.quest && tavern.quest.key)) return;
- let quest = shared.content.quests[tavern.quest.key];
+ let quest = shared.content.quests[tavern.quest.key];
- if (tavern.quest.progress.hp <= 0) {
- tavern.sendChat(quest.completionChat('en'));
- tavern.finishQuest(quest, () => {});
- _.assign(tavernQuest, {extra: null});
- return tavern.save();
- } else {
- // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database,
- // use those first - which allows us to update the boss on the go if things are too easy/hard.
- if (!tavern.quest.extra) tavern.quest.extra = {};
- tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def);
- tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str);
+ if (tavern.quest.progress.hp <= 0) {
+ tavern.sendChat(quest.completionChat('en'));
+ await tavern.finishQuest(quest);
+ _.assign(tavernQuest, {extra: null});
+ return tavern.save();
+ } else {
+ // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database,
+ // use those first - which allows us to update the boss on the go if things are too easy/hard.
+ if (!tavern.quest.extra) tavern.quest.extra = {};
+ tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def);
+ tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str);
- if (tavern.quest.progress.rage >= quest.boss.rage.value) {
- if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {};
+ if (tavern.quest.progress.rage >= quest.boss.rage.value) {
+ if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {};
- let wd = tavern.quest.extra.worldDmg;
- // Burnout attacks Ian, Seasonal Sorceress, tavern
- let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary
+ let wd = tavern.quest.extra.worldDmg;
+ // Burnout attacks Ian, Seasonal Sorceress, tavern
+ let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary
- if (!scene) {
- tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``);
- tavern.quest.progress.rage = 0; // quest.boss.rage.value;
- } else {
- tavern.sendChat(quest.boss.rage[scene]('en'));
- tavern.quest.extra.worldDmg[scene] = true;
- tavern.quest.extra.worldDmg.recent = scene;
- tavern.markModified('quest.extra.worldDmg');
- tavern.quest.progress.rage = 0;
- if (quest.boss.rage.healing) {
- tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp;
- }
+ if (!scene) {
+ tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``);
+ tavern.quest.progress.rage = 0; // quest.boss.rage.value;
+ } else {
+ tavern.sendChat(quest.boss.rage[scene]('en'));
+ tavern.quest.extra.worldDmg[scene] = true;
+ tavern.quest.extra.worldDmg.recent = scene;
+ tavern.markModified('quest.extra.worldDmg');
+ tavern.quest.progress.rage = 0;
+ if (quest.boss.rage.healing) {
+ tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp;
}
}
-
- if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) {
- tavern.sendChat(quest.boss.desperation.text('en'));
- tavern.quest.extra.desperate = true;
- tavern.quest.extra.def = quest.boss.desperation.def;
- tavern.quest.extra.str = quest.boss.desperation.str;
- tavern.markModified('quest.extra');
- }
-
- _.assign(module.exports.tavernQuest, tavern.quest.toObject());
- return tavern.save();
- }
- })
- .catch(err => {
- throw err;
- });
-};
-
-schema.statics.bossQuest = function bossQuest (user, progress) {
- return this.findOne({
- type: 'party',
- members: {$in: [user._id]},
- }).exec()
- .then(group => {
- if (!_isOnQuest(user, progress, group)) return;
-
- let quest = shared.content.quests[group.quest.key];
- if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point
-
- let down = progress.down * quest.boss.str; // multiply by boss strength
-
- group.quest.progress.hp -= progress.up;
- group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); // TODO Create a party preferred language option so emits like this can be localized
-
- // If boss has Rage, increment Rage as well
- if (quest.boss.rage) {
- group.quest.progress.rage += Math.abs(down);
- if (group.quest.progress.rage >= quest.boss.rage.value) {
- group.sendChat(quest.boss.rage.effect('en'));
- group.quest.progress.rage = 0;
-
- // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage
- if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing;
- if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
- }
}
- // Everyone takes damage
- let promise = User.update({
- _id: {$in: _.keys(group.quest.members)},
- }, {
- $inc: {'stats.hp': down, _v: 1},
- }, {multi: true});
-
- // Boss slain, finish quest
- if (group.quest.progress.hp <= 0) {
- group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``);
- // Participants: Grant rewards & achievements, finish quest
-
- return promise
- .then(() => group.finishQuest())
- .then(() => group.save());
+ if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) {
+ tavern.sendChat(quest.boss.desperation.text('en'));
+ tavern.quest.extra.desperate = true;
+ tavern.quest.extra.def = quest.boss.desperation.def;
+ tavern.quest.extra.str = quest.boss.desperation.str;
+ tavern.markModified('quest.extra');
}
- return promise.then(() => group.save());
- })
- // TODO necessary to catch if we're returning a promise?
- .catch(err => {
- throw err;
- });
+ _.assign(tavernQuest, tavern.quest.toObject());
+ return tavern.save();
+ }
+ // TODO catch
};
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {