mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 22:57:21 +01:00
Merge pull request #6565 from HabitRPG/sabrecat/quest-accept
WIP: Quests for API v3
This commit is contained in:
@@ -67,5 +67,23 @@
|
|||||||
"emailsMustBeAnArray": "Email invites must be a an Array.",
|
"emailsMustBeAnArray": "Email invites must be a an Array.",
|
||||||
"canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
|
"canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
|
||||||
"cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.",
|
"cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.",
|
||||||
"onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,6 @@
|
|||||||
"whichQuestStart": "Which quest do you want to start?",
|
"whichQuestStart": "Which quest do you want to start?",
|
||||||
"getMoreQuests": "Get more quests",
|
"getMoreQuests": "Get more quests",
|
||||||
"unlockedAQuest": "You unlocked a quest!",
|
"unlockedAQuest": "You unlocked a quest!",
|
||||||
"leveledUpReceivedQuest": "You leveled up to <strong>Level <%= level %></strong> and received a quest scroll!"
|
"leveledUpReceivedQuest": "You leveled up to <strong>Level <%= level %></strong> and received a quest scroll!",
|
||||||
|
"questInvitationDoesNotExist": "No quest invitation has been sent out yet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// TODO what can be moved to /website/src?
|
||||||
/*
|
/*
|
||||||
------------------------------------------------------
|
------------------------------------------------------
|
||||||
Cron and time / day functions
|
Cron and time / day functions
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ gulp.task('test:api-v3:unit', (done) => {
|
|||||||
gulp.task('test:api-v3:integration', (done) => {
|
gulp.task('test:api-v3:integration', (done) => {
|
||||||
let runner = exec(
|
let runner = exec(
|
||||||
testBin('mocha test/api/v3/integration --recursive'),
|
testBin('mocha test/api/v3/integration --recursive'),
|
||||||
|
{maxBuffer: 500*1024},
|
||||||
(err, stdout, stderr) => done(err)
|
(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) => {
|
gulp.task('test:api-v3:integration:separate-server', (done) => {
|
||||||
let runner = exec(
|
let runner = exec(
|
||||||
testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'),
|
testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'),
|
||||||
|
{maxBuffer: 500*1024},
|
||||||
(err, stdout, stderr) => done(err)
|
(err, stdout, stderr) => done(err)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
300
test/api/v3/unit/models/group.test.js
Normal file
300
test/api/v3/unit/models/group.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,11 +8,4 @@ export { requester };
|
|||||||
export { translate } from '../translate';
|
export { translate } from '../translate';
|
||||||
export { checkExistence, resetHabiticaDB } from '../../mongo';
|
export { checkExistence, resetHabiticaDB } from '../../mongo';
|
||||||
export * from './object-generators';
|
export * from './object-generators';
|
||||||
|
export { sleep } from '../../sleep';
|
||||||
export async function sleep (seconds) {
|
|
||||||
let milliseconds = seconds * 1000;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, milliseconds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ afterEach((done) => {
|
|||||||
mongoose.connection.db.dropDatabase(done);
|
mongoose.connection.db.dropDatabase(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { sleep } from './sleep';
|
||||||
|
|
||||||
export function generateUser (options = {}) {
|
export function generateUser (options = {}) {
|
||||||
return new User(options).toObject();
|
return new User(options).toObject();
|
||||||
}
|
}
|
||||||
|
|||||||
7
test/helpers/sleep.js
Normal file
7
test/helpers/sleep.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export async function sleep (seconds) {
|
||||||
|
let milliseconds = seconds * 1000;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, milliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
443
website/src/controllers/api-v3/quests.js
Normal file
443
website/src/controllers/api-v3/quests.js
Normal file
@@ -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;
|
||||||
@@ -15,7 +15,7 @@ import Q from 'q';
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import scoreTask from '../../../../common/script/api-v3/scoreTask';
|
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 = {};
|
let api = {};
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO log errors...
|
||||||
function track (eventType, data) {
|
function track (eventType, data) {
|
||||||
return Q.all([
|
return Q.all([
|
||||||
_sendDataToAmplitude(eventType, data),
|
_sendDataToAmplitude(eventType, data),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { findIndex } from 'lodash';
|
import {
|
||||||
|
findIndex,
|
||||||
|
isPlainObject,
|
||||||
|
} from 'lodash';
|
||||||
|
|
||||||
export function removeFromArray (array, element) {
|
export function removeFromArray (array, element) {
|
||||||
let elementIndex;
|
let elementIndex;
|
||||||
|
|
||||||
if (typeof element === 'object') {
|
if (isPlainObject(element)) {
|
||||||
elementIndex = findIndex(array, element);
|
elementIndex = findIndex(array, element);
|
||||||
} else {
|
} else {
|
||||||
elementIndex = array.indexOf(element);
|
elementIndex = array.indexOf(element);
|
||||||
|
|||||||
@@ -2,15 +2,274 @@ import _ from 'lodash';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {
|
import {
|
||||||
daysSince,
|
daysSince,
|
||||||
|
shouldDo,
|
||||||
} from '../../../../common/script/cron';
|
} from '../../../../common/script/cron';
|
||||||
import cron from '../../../../common/script/api-v3/cron';
|
|
||||||
import common from '../../../../common';
|
import common from '../../../../common';
|
||||||
import Task from '../../models/task';
|
import Task from '../../models/task';
|
||||||
import Q from 'q';
|
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
|
// 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 user = res.locals.user;
|
||||||
let analytics = res.analytics;
|
let analytics = res.analytics;
|
||||||
|
|
||||||
@@ -32,7 +291,7 @@ export default function cronMiddleware (req, res, next) {
|
|||||||
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
|
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
|
||||||
|
|
||||||
// Run cron
|
// 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
|
// Clear old completed todos - 30 days for free users, 90 for subscribers
|
||||||
// Do not delete challenges completed todos TODO unless the task is broken?
|
// 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'),
|
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'),
|
||||||
},
|
},
|
||||||
'challenge.id': {$exists: false},
|
'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 ranCron = user.isModified();
|
||||||
let quest = common.content.quests[user.party.quest.key];
|
let quest = common.content.quests[user.party.quest.key];
|
||||||
|
|
||||||
// if (ranCron) res.locals.wasModified = true; // TODO remove?
|
// if (ranCron) res.locals.wasModified = true; // TODO remove?
|
||||||
if (!ranCron) return next();
|
if (!ranCron) return next();
|
||||||
// TODO Group.tavernBoss(user, progress);
|
|
||||||
if (!quest || true /* TODO remove */) {
|
// Group.tavernBoss(user, progress);
|
||||||
|
|
||||||
// Save user and tasks
|
// Save user and tasks
|
||||||
let toSave = [user.save()];
|
let toSave = [user.save()];
|
||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
if (task.isModified) toSave.push(task.save());
|
if (task.isModified) toSave.push(task.save());
|
||||||
});
|
});
|
||||||
|
Q.all(toSave)
|
||||||
return Q.all(toSave).then(() => next()).catch(next);
|
.then(saved => {
|
||||||
}
|
user = res.locals.user = saved[0];
|
||||||
|
if (!quest) return;
|
||||||
|
|
||||||
// If user is on a quest, roll for boss & player, or handle collections
|
// 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?
|
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
|
||||||
// TODO do
|
return Group[`${questType}Quest`](user, progress)
|
||||||
/* async.waterfall([
|
.then(() => User.findById(user._id).exec()) // fetch the updated user...
|
||||||
function(cb){
|
.then(updatedUser => {
|
||||||
user.save(cb); // make sure to save the cron effects
|
res.locals.user = updatedUser;
|
||||||
},
|
});
|
||||||
function(saved, count, cb){
|
})
|
||||||
var type = quest.boss ? 'boss' : 'collect';
|
.then(() => next())
|
||||||
Group[type+'Quest'](user,progress,cb);
|
.catch(next);
|
||||||
},
|
|
||||||
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;
|
|
||||||
});*/
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import _ from 'lodash';
|
|||||||
import { model as Challenge} from './challenge';
|
import { model as Challenge} from './challenge';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
||||||
|
import { InternalServerError } from '../libs/api-v3/errors';
|
||||||
import * as firebase from '../libs/api-v2/firebase';
|
import * as firebase from '../libs/api-v2/firebase';
|
||||||
import baseModel from '../libs/api-v3/baseModel';
|
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 Q from 'q';
|
||||||
import nconf from 'nconf';
|
import nconf from 'nconf';
|
||||||
|
|
||||||
@@ -105,9 +108,8 @@ export let basicFields = 'name type privacy';
|
|||||||
// TODO test
|
// TODO test
|
||||||
schema.pre('remove', true, async function preRemoveGroup (next, done) {
|
schema.pre('remove', true, async function preRemoveGroup (next, done) {
|
||||||
next();
|
next();
|
||||||
let group = this;
|
|
||||||
try {
|
try {
|
||||||
await group.removeGroupInvitations();
|
await this.removeGroupInvitations();
|
||||||
done();
|
done();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
done(err);
|
done(err);
|
||||||
@@ -213,7 +215,7 @@ schema.methods.sendChat = function sendChat (message, user) {
|
|||||||
this.chat.splice(200);
|
this.chat.splice(200);
|
||||||
|
|
||||||
// Kick off chat notifications in the background. // TODO refactor
|
// 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};
|
lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true};
|
||||||
|
|
||||||
if (this._id === 'habitrpg') {
|
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) {
|
function _cleanQuestProgress (merge) {
|
||||||
// TODO clone? (also in sendChat message)
|
|
||||||
let clean = {
|
let clean = {
|
||||||
key: null,
|
key: null,
|
||||||
progress: {
|
progress: {
|
||||||
@@ -245,20 +326,35 @@ function _cleanQuestProgress (merge) {
|
|||||||
collect: {},
|
collect: {},
|
||||||
},
|
},
|
||||||
completed: null,
|
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, _.omit(merge, 'progress'));
|
||||||
_.merge(clean.progress, merge.progress);
|
if (merge.progress) _.merge(clean.progress, merge.progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO move to User.cleanQuestProgress?
|
||||||
schema.statics.cleanQuestProgress = _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
|
// Participants: Grant rewards & achievements, finish quest
|
||||||
|
// Returns the promise from update().exec()
|
||||||
schema.methods.finishQuest = function finishQuest (quest) {
|
schema.methods.finishQuest = function finishQuest (quest) {
|
||||||
let questK = quest.key;
|
let questK = quest.key;
|
||||||
let updates = {$inc: {}, $set: {}};
|
let updates = {$inc: {}, $set: {}};
|
||||||
@@ -300,18 +396,17 @@ schema.methods.finishQuest = function finishQuest (quest) {
|
|||||||
let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}};
|
let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}};
|
||||||
this.quest = {};
|
this.quest = {};
|
||||||
this.markModified('quest');
|
this.markModified('quest');
|
||||||
return User.update(q, updates, {multi: true});
|
return User.update(q, updates, {multi: true}).exec();
|
||||||
};
|
};
|
||||||
|
|
||||||
function _isOnQuest (user, progress, group) {
|
function _isOnQuest (user, progress, group) {
|
||||||
return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true;
|
return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
schema.statics.collectQuest = function collectQuest (user, progress) {
|
// Returns a promise
|
||||||
return this.findOne({
|
schema.statics.collectQuest = async function collectQuest (user, progress) {
|
||||||
type: 'party',
|
let group = await this.getGroup({user, groupId: 'party'});
|
||||||
members: {$in: [user._id]},
|
|
||||||
}).then(group => {
|
|
||||||
if (!_isOnQuest(user, progress, group)) return;
|
if (!_isOnQuest(user, progress, group)) return;
|
||||||
let quest = shared.content.quests[group.quest.key];
|
let quest = shared.content.quests[group.quest.key];
|
||||||
|
|
||||||
@@ -333,15 +428,58 @@ schema.statics.collectQuest = function collectQuest (user, progress) {
|
|||||||
return group.quest.progress.collect[k] < v.count;
|
return group.quest.progress.collect[k] < v.count;
|
||||||
})) return group.save();
|
})) return group.save();
|
||||||
|
|
||||||
// TODO use promise
|
await group.finishQuest(quest);
|
||||||
return group.finishQuest(quest)
|
|
||||||
.then(() => {
|
|
||||||
group.sendChat('`All items found! Party has received their rewards.`');
|
group.sendChat('`All items found! Party has received their rewards.`');
|
||||||
return group.save();
|
return group.save();
|
||||||
});
|
};
|
||||||
})
|
|
||||||
// TODO ok to catch even if we're returning a promise?
|
schema.statics.bossQuest = async function bossQuest (user, progress) {
|
||||||
.catch();
|
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}}}})`
|
// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
|
||||||
@@ -364,23 +502,22 @@ process.nextTick(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO promise?
|
// returns a promise
|
||||||
schema.statics.tavernBoss = function tavernBoss (user, progress) {
|
schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||||
if (!progress) return;
|
if (!progress) return;
|
||||||
|
|
||||||
// hack: prevent crazy damage to world boss
|
// hack: prevent crazy damage to world boss
|
||||||
let dmg = Math.min(900, Math.abs(progress.up || 0));
|
let dmg = Math.min(900, Math.abs(progress.up || 0));
|
||||||
let rage = -Math.min(900, Math.abs(progress.down || 0));
|
let rage = -Math.min(900, Math.abs(progress.down || 0));
|
||||||
|
|
||||||
this.findOne(tavernQ).exec()
|
let tavern = await this.findOne(tavernQ).exec();
|
||||||
.then(tavern => {
|
|
||||||
if (!(tavern && tavern.quest && tavern.quest.key)) return;
|
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) {
|
if (tavern.quest.progress.hp <= 0) {
|
||||||
tavern.sendChat(quest.completionChat('en'));
|
tavern.sendChat(quest.completionChat('en'));
|
||||||
tavern.finishQuest(quest, () => {});
|
await tavern.finishQuest(quest);
|
||||||
_.assign(tavernQuest, {extra: null});
|
_.assign(tavernQuest, {extra: null});
|
||||||
return tavern.save();
|
return tavern.save();
|
||||||
} else {
|
} else {
|
||||||
@@ -420,67 +557,10 @@ schema.statics.tavernBoss = function tavernBoss (user, progress) {
|
|||||||
tavern.markModified('quest.extra');
|
tavern.markModified('quest.extra');
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(module.exports.tavernQuest, tavern.quest.toObject());
|
_.assign(tavernQuest, tavern.quest.toObject());
|
||||||
return tavern.save();
|
return tavern.save();
|
||||||
}
|
}
|
||||||
})
|
// TODO catch
|
||||||
.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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return promise.then(() => group.save());
|
|
||||||
})
|
|
||||||
// TODO necessary to catch if we're returning a promise?
|
|
||||||
.catch(err => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
|
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
|
||||||
|
|||||||
Reference in New Issue
Block a user