import { sleep } from '../../../../helpers/api-unit.helper'; import { model as Group } from '../../../../../website/server/models/group'; import { model as User } from '../../../../../website/server/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/server/libs/api-v3/email'; describe('Group Model', () => { 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 }, profile: { name: 'Quest Leader' }, items: { quests: { whale: 1, }, }, }); party.leader = questLeader._id; participatingMember = new User({ party: { _id: party._id }, profile: { name: 'Participating Member' }, }); nonParticipatingMember = new User({ party: { _id: party._id }, profile: { name: 'Non-Participating Member' }, }); undecidedMember = new User({ party: { _id: party._id }, profile: { name: 'Undecided Member' }, }); await Promise.all([ party.save(), questLeader.save(), participatingMember.save(), nonParticipatingMember.save(), undecidedMember.save(), ]); }); describe('Static Methods', () => { describe('processQuestProgress', () => { let progress; beforeEach(async () => { progress = { up: 5, down: -5, collectedItems: 5, }; party.quest.members = { [questLeader._id]: true, [participatingMember._id]: true, [nonParticipatingMember._id]: false, [undecidedMember._id]: null, }; await party.save(); }); context('early returns', () => { beforeEach(() => { sandbox.stub(Group.prototype, '_processBossQuest').returns(Promise.resolve()); sandbox.stub(Group.prototype, '_processCollectionQuest').returns(Promise.resolve()); }); it('returns early if user is not in a party', async () => { let userWithoutParty = new User(); await userWithoutParty.save(); await Group.processQuestProgress(userWithoutParty, progress); party = await Group.findOne({_id: party._id}); expect(party._processBossQuest).to.not.be.called; expect(party._processCollectionQuest).to.not.be.called; }); it('returns early if party is not on quest', async () => { party.quest.active = false; await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party._processBossQuest).to.not.be.called; expect(party._processCollectionQuest).to.not.be.called; }); it('returns early if user is not on quest', async () => { await Group.processQuestProgress(nonParticipatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party._processBossQuest).to.not.be.called; expect(party._processCollectionQuest).to.not.be.called; }); it('returns early if user has made no progress', async () => { await Group.processQuestProgress(participatingMember, null); party = await Group.findOne({_id: party._id}); expect(party._processBossQuest).to.not.be.called; expect(party._processCollectionQuest).to.not.be.called; }); it('returns early if quest does not exist', async () => { party.quest.key = 'foobar'; await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party._processBossQuest).to.not.be.called; expect(party._processCollectionQuest).to.not.be.called; }); it('calls _processBossQuest if quest is a boss quest', async () => { party.quest.key = 'whale'; await party.startQuest(questLeader); await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype._processBossQuest).to.be.calledOnce; expect(party._processCollectionQuest).to.not.be.called; }); it('calls _processCollectionQuest if quest is a collection quest', async () => { party.quest.key = 'evilsanta2'; await party.startQuest(questLeader); await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party._processBossQuest).to.not.be.called; expect(Group.prototype._processCollectionQuest).to.be.calledOnce; }); }); context('Boss Quests', () => { beforeEach(async () => { party.quest.key = 'whale'; await party.startQuest(questLeader); await party.save(); sandbox.stub(Group.prototype, 'sendChat'); }); it('applies user\'s progress to quest boss hp', async () => { await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party.quest.progress.hp).to.eql(495); }); it('sends a chat message about progress', async () => { await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member attacks Wailing Whale for 5.0 damage.` `Wailing Whale attacks party for 7.5 damage.`'); }); it('applies damage only to participating members of party', async () => { await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); let [ updatedLeader, updatedParticipatingMember, updatedNonParticipatingMember, updatedUndecidedMember, ] = await Promise.all([ User.findById(questLeader._id), User.findById(participatingMember._id), User.findById(nonParticipatingMember._id), User.findById(undecidedMember._id), ]); expect(updatedLeader.stats.hp).to.eql(42.5); expect(updatedParticipatingMember.stats.hp).to.eql(42.5); expect(updatedNonParticipatingMember.stats.hp).to.eql(50); expect(updatedUndecidedMember.stats.hp).to.eql(50); }); it('applies damage only to participating members of party even under buggy conditions', async () => { // stops unfair damage from mbugs like https://github.com/HabitRPG/habitrpg/issues/7653 party.quest.members = { [questLeader._id]: true, [participatingMember._id]: true, [nonParticipatingMember._id]: false, [undecidedMember._id]: null, }; await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); let [ updatedLeader, updatedParticipatingMember, updatedNonParticipatingMember, updatedUndecidedMember, ] = await Promise.all([ User.findById(questLeader._id), User.findById(participatingMember._id), User.findById(nonParticipatingMember._id), User.findById(undecidedMember._id), ]); expect(updatedLeader.stats.hp).to.eql(42.5); expect(updatedParticipatingMember.stats.hp).to.eql(42.5); expect(updatedNonParticipatingMember.stats.hp).to.eql(50); expect(updatedUndecidedMember.stats.hp).to.eql(50); }); it('sends message about victory', async () => { progress.up = 999; await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledTwice; expect(Group.prototype.sendChat).to.be.calledWith('`You defeated Wailing Whale! Questing party members receive the rewards of victory.`'); }); it('calls finishQuest when boss has <= 0 hp', async () => { let quest = questScrolls[party.quest.key]; let finishQuest = sandbox.spy(Group.prototype, 'finishQuest'); progress.up = 999; await Group.processQuestProgress(participatingMember, progress); expect(finishQuest).to.be.calledOnce; expect(finishQuest).to.be.calledWith(quest); }); context('with Rage', () => { beforeEach(async () => { party.quest.active = false; party.quest.key = 'trex_undead'; await party.startQuest(questLeader); await party.save(); }); it('applies down progress to boss rage', async () => { await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party.quest.progress.rage).to.eql(10); }); it('activates rage when progress.down triggers rage bar', async () => { let quest = questScrolls[party.quest.key]; progress.down = -999; party.quest.progress.hp = 300; await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledWith(quest.boss.rage.effect('en')); expect(party.quest.progress.hp).to.eql(383.5); expect(party.quest.progress.rage).to.eql(0); }); it('rage sets boss hp to max hp if raging would have caused hp to be higher than the max', async () => { progress.down = -999; party.quest.progress.hp = 490; await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party.quest.progress.hp).to.eql(500); }); }); }); context('Collection Quests', () => { beforeEach(async () => { party.quest.key = 'atom1'; await party.startQuest(questLeader); await party.save(); sandbox.stub(Group.prototype, 'sendChat'); }); it('applies user\'s progress to found quest items', async () => { await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(party.quest.progress.collect.soapBars).to.eq(5); }); it('sends a chat message about progress', async () => { await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member found 5 Bars of Soap.`'); }); it('sends a chat message if no progress is made', async () => { progress.collectedItems = 0; await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledWith('`Participating Member found nothing.`'); }); it('handles collection quests with multiple items', async () => { progress.collectedItems = 10; party.quest.key = 'evilsanta2'; party.quest.active = false; await party.startQuest(questLeader); await party.save(); await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledOnce; expect(Group.prototype.sendChat).to.be.calledWithMatch(/`Participating Member found/); expect(Group.prototype.sendChat).to.be.calledWithMatch(/\d* (Tracks|Broken Twigs)/); }); it('sends message about victory', async () => { progress.collectedItems = 500; await Group.processQuestProgress(participatingMember, progress); party = await Group.findOne({_id: party._id}); expect(Group.prototype.sendChat).to.be.calledTwice; expect(Group.prototype.sendChat).to.be.calledWith('`All items found! Party has received their rewards.`'); }); it('calls finishQuest when all items are found', async () => { let quest = questScrolls[party.quest.key]; let finishQuest = sandbox.spy(Group.prototype, 'finishQuest'); progress.collectedItems = 999; // TODO should this be collectedItems? What is this testing? await Group.processQuestProgress(participatingMember, progress); expect(finishQuest).to.be.calledOnce; expect(finishQuest).to.be.calledWith(quest); }); }); }); }); context('Instance Methods', () => { describe('#startQuest', () => { 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 = { [questLeader._id]: true, [participatingMember._id]: true, [nonParticipatingMember._id]: false, [undecidedMember._id]: null, }; }); it('activates quest', () => { party.startQuest(participatingMember); expect(party.quest.active).to.eql(true); }); it('sets up boss quest', () => { let bossQuest = questScrolls.whale; party.quest.key = bossQuest.key; party.startQuest(participatingMember); expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); }); it('sets up rage meter for rage boss quest', () => { let rageBossQuest = questScrolls.trex_undead; party.quest.key = rageBossQuest.key; party.startQuest(participatingMember); expect(party.quest.progress.rage).to.eql(0); }); it('sets up collection quest', () => { let collectionQuest = questScrolls.vice2; party.quest.key = collectionQuest.key; party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ lightCrystal: 0, }); }); it('sets up collection quest with multiple items', () => { let collectionQuest = questScrolls.evilsanta2; party.quest.key = collectionQuest.key; party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ tracks: 0, branches: 0, }); }); it('prunes non-participating members from quest members object', () => { party.startQuest(participatingMember); let expectedQuestMembers = {}; expectedQuestMembers[questLeader._id] = true; expectedQuestMembers[participatingMember._id] = true; expect(party.quest.members).to.eql(expectedQuestMembers); }); it('applies updates to user object directly if user is participating', async () => { await party.startQuest(participatingMember); expect(participatingMember.party.quest.key).to.eql('whale'); expect(participatingMember.party.quest.progress.down).to.eql(0); expect(participatingMember.party.quest.progress.collectedItems).to.eql(0); 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.collectedItems).to.eql(0); 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.collectedItems).to.eql(0); 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.completed': null, }, } ); }); it('updates non-user quest leader and decrements quest scroll', async () => { sandbox.spy(User, 'update'); await party.startQuest(participatingMember); expect(User.update).to.be.calledWith( { _id: questLeader._id }, { $inc: { 'items.quests.whale': -1, }, } ); }); it('modifies the participating initiating user directly', async () => { await party.startQuest(participatingMember); let userQuest = participatingMember.party.quest; expect(userQuest.key).to.eql('whale'); expect(userQuest.progress.down).to.eql(0); expect(userQuest.progress.collectedItems).to.eql(0); 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); }); }); }); }); });