From c04bac44e771daad1ecba7ec06cba22ea087248b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 31 May 2016 22:35:07 -0500 Subject: [PATCH 1/5] feat: Use party's quest key in cron --- test/api/v3/unit/models/group.test.js | 436 +++++++++++++++++++--- website/server/middlewares/api-v3/cron.js | 8 +- website/server/models/group.js | 50 ++- 3 files changed, 429 insertions(+), 65 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 32007e068d..1f0304eb92 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -5,49 +5,53 @@ 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(), + ]); + }); + 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({ @@ -74,11 +78,12 @@ describe('Group Model', () => { 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; + party.quest.members = { + [questLeader._id]: true, + [participatingMember._id]: true, + [nonParticipatingMember._id]: false, + [undecidedMember._id]: null, + }; }); it('activates quest', () => { @@ -296,5 +301,344 @@ describe('Group Model', () => { }); }); }); + + describe('processQuestProgress', () => { + let progress; + + beforeEach(async () => { + progress = { + up: 5, + down: -5, + }; + + party.quest.active = true; + party.quest.members = { + [questLeader._id]: true, + [participatingMember._id]: true, + [nonParticipatingMember._id]: false, + [undecidedMember._id]: null, + }; + + await party.save(); + + sandbox.stub(Group, 'processBossQuest').returns(Promise.resolve()); + sandbox.stub(Group, '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); + + expect(Group.processBossQuest).to.not.be.called; + expect(Group.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); + + expect(Group.processBossQuest).to.not.be.called; + expect(Group.processCollectionQuest).to.not.be.called; + }); + + it('returns early if user is not on quest', async () => { + await Group.processQuestProgress(nonParticipatingMember, progress); + + expect(Group.processBossQuest).to.not.be.called; + expect(Group.processCollectionQuest).to.not.be.called; + }); + + it('returns early if user has made no progress', async () => { + await Group.processQuestProgress(participatingMember, null); + + expect(Group.processBossQuest).to.not.be.called; + expect(Group.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); + + expect(Group.processBossQuest).to.not.be.called; + expect(Group.processCollectionQuest).to.not.be.called; + }); + + it('calls processBossQuest if quest is a boss quest', async () => { + party.quest.key = 'whale'; + await party.save(); + + await Group.processQuestProgress(participatingMember, progress); + + expect(Group.processBossQuest).to.be.calledOnce; + expect(Group.processCollectionQuest).to.not.be.called; + }); + + it('calls processCollectionQuest if quest is a boss quest', async () => { + party.quest.key = 'evilsanta2'; + await party.save(); + + await Group.processQuestProgress(participatingMember, progress); + + expect(Group.processBossQuest).to.not.be.called; + expect(Group.processCollectionQuest).to.be.calledOnce; + }); + }); + + describe('processBossQuest', () => { + let progress, quest; + + beforeEach(async () => { + progress = { + up: 5, + down: -5, + }; + quest = questScrolls.whale; + + party.quest.members = { + [questLeader._id]: true, + [participatingMember._id]: true, + [nonParticipatingMember._id]: false, + [undecidedMember._id]: null, + }; + party.quest.key = 'whale'; + + await party.save(); + await party.startQuest(questLeader); + + sandbox.stub(party, 'sendChat'); + }); + + it('applies user\'s progress to quest boss hp', async () => { + await Group.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.quest.progress.hp).to.eql(495); + }); + + it('sends a chat message about progress', async () => { + await Group.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.sendChat).to.be.calledOnce; + expect(party.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.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + 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.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.sendChat).to.be.calledTwice; + expect(party.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 () => { + progress.up = 999; + sandbox.spy(party, 'finishQuest'); + + await Group.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.finishQuest).to.be.calledOnce; + expect(party.finishQuest).to.be.calledWith(quest); + }); + + context('rage quests', () => { + beforeEach(async () => { + party.quest.active = false; + party.quest.key = 'trex_undead'; + quest = questScrolls[party.quest.key]; + + await party.save(); + + await party.startQuest(questLeader); + }); + + it('applies down progress to boss rage', async () => { + await Group.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.quest.progress.rage).to.eql(10); + }); + + it('activates rage when progress.down triggers rage bar', async () => { + progress.down = -999; + + party.quest.progress.hp = 300; + + await Group.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.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.processBossQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.quest.progress.hp).to.eql(500); + }); + }); + }); + + describe('processCollectionQuest', () => { + let progress, quest; + + beforeEach(async () => { + progress = { + collect: { + soapBars: 5, + }, + }; + quest = questScrolls.atom1; + + party.quest.members = { + [questLeader._id]: true, + [participatingMember._id]: true, + [nonParticipatingMember._id]: false, + [undecidedMember._id]: null, + }; + party.quest.key = 'atom1'; + + await party.save(); + await party.startQuest(questLeader); + + sandbox.stub(party, 'sendChat'); + }); + + it('applies user\'s progress to found quest items', async () => { + await Group.processCollectionQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.quest.progress.collect.soapBars).to.eq(5) + }); + + it('sends a chat message about progress', async () => { + await Group.processCollectionQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.sendChat).to.be.calledOnce; + expect(party.sendChat).to.be.calledWith('`Participating Member found 5 Bars of Soap.`'); + }); + + it('sends a chat message if no progress is made', async () => { + delete progress.collect.soapBars; + + await Group.processCollectionQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.sendChat).to.be.calledOnce; + expect(party.sendChat).to.be.calledWith('`Participating Member found nothing.`'); + }); + + it('sends message about victory', async () => { + progress.collect.soapBars = 500; + + await Group.processCollectionQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.sendChat).to.be.calledTwice; + expect(party.sendChat).to.be.calledWith('`All items found! Party has received their rewards.`'); + }); + + it('calls finishQuest when all items are found', async () => { + progress.collect.soapBars = 999; + sandbox.spy(party, 'finishQuest'); + + await Group.processCollectionQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.finishQuest).to.be.calledOnce; + expect(party.finishQuest).to.be.calledWith(quest); + }); + }); }); }); diff --git a/website/server/middlewares/api-v3/cron.js b/website/server/middlewares/api-v3/cron.js index 18d9907d0d..e4745f734e 100644 --- a/website/server/middlewares/api-v3/cron.js +++ b/website/server/middlewares/api-v3/cron.js @@ -158,13 +158,7 @@ async function cronAsync (req, res) { }); await Bluebird.all(toSave); - let quest = common.content.quests[user.party.quest.key]; - - if (quest) { - // If user is on a quest, roll for boss & player, or handle collections - let questType = quest.boss ? 'boss' : 'collect'; - await Group[`${questType}Quest`](user, progress); - } + await Group.processQuestProgress(user, progress); // Set _cronSignature, lastCron and auth.timestamps.loggedin to signal end of cron await User.update({ diff --git a/website/server/models/group.js b/website/server/models/group.js index fed02f4619..58aa38fc97 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -491,10 +491,13 @@ function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -schema.statics.collectQuest = async function collectQuest (user, progress) { - let group = await this.getGroup({user, groupId: 'party'}); - if (!_isOnQuest(user, progress, group)) return; - let quest = shared.content.quests[group.quest.key]; +async function processCollectionQuest (options) { + let { + user, + progress, + quest, + group, + } = options; _.each(progress.collect, (v, k) => { group.quest.progress.collect[k] += v; @@ -505,7 +508,7 @@ schema.statics.collectQuest = async function collectQuest (user, progress) { return m; }, []); - foundText = foundText ? foundText.join(', ') : 'nothing'; + foundText = foundText.length > 0 ? foundText.join(', ') : 'nothing'; group.sendChat(`\`${user.profile.name} found ${foundText}.\``); group.markModified('quest.progress.collect'); @@ -518,14 +521,15 @@ schema.statics.collectQuest = async function collectQuest (user, progress) { group.sendChat('`All items found! Party has received their rewards.`'); return await group.save(); -}; +} -schema.statics.bossQuest = async function bossQuest (user, progress) { - let group = await this.getGroup({user, groupId: 'party'}); - if (!_isOnQuest(user, progress, group)) return; - - let quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return; // TODO why is this ever happening, progress should be defined at this point, log? +async function processBossQuest (options) { + let { + user, + progress, + quest, + group, + } = options; let down = progress.down * quest.boss.str; // multiply by boss strength @@ -570,6 +574,28 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { } return await group.save(); +} + +schema.statics.processBossQuest = processBossQuest; +schema.statics.processCollectionQuest = processCollectionQuest; + +schema.statics.processQuestProgress = async function processQuestProgress (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); + + if (!_isOnQuest(user, progress, group)) return; + + let quest = shared.content.quests[group.quest.key]; + + if (!quest) return; // TODO should this throw an error instead? + + let questType = quest.boss ? 'Boss' : 'Collection'; + + await this[`process${questType}Quest`]({ + user, + progress, + quest, + group, + }); }; // to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` From 58cd634255061fab347234bcfb858934c63c790c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 2 Jun 2016 17:36:29 -0500 Subject: [PATCH 2/5] fix: Change quest collection to calculate items on the server only --- common/script/fns/randomDrop.js | 13 ++---------- .../debug/POST-debug_quest-progress.test.js | 5 +---- .../POST-groups_groupid_quests_abort.test.js | 2 +- .../POST-groups_groupid_quests_cancel.test.js | 2 +- .../POST-groups_groupid_quests_leave.test.js | 2 +- .../POST-groups_groupid_quests_reject.test.js | 2 +- test/api/v3/unit/models/group.test.js | 21 ++++++++----------- test/common/fns/randomDrop.test.js | 14 +++---------- test/server_side/controllers/groups.test.js | 2 +- website/server/controllers/api-v3/debug.js | 6 +----- website/server/libs/api-v3/cron.js | 3 +-- website/server/models/group.js | 17 +++++++++------ website/server/models/user.js | 4 +--- 13 files changed, 34 insertions(+), 59 deletions(-) diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index ec6f350d4c..2d91f2e2f3 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -17,9 +17,7 @@ module.exports = function randomDrop (user, options, req = {}) { let acceptableDrops; let chance; let drop; - let dropK; let dropMultiplier; - let quest; let rarity; let task; @@ -38,15 +36,8 @@ module.exports = function randomDrop (user, options, req = {}) { }, 0) || 0)); chance = diminishingReturns(chance, 0.75); - if (user.party.quest.key) - quest = content.quests[user.party.quest.key]; - if (quest && quest.collect && predictableRandom(user, user.stats.gp) < chance) { - dropK = randomVal(user, quest.collect, { - key: true, - }); - if (!user.party.quest.progress.collect[dropK]) - user.party.quest.progress.collect[dropK] = 0; - user.party.quest.progress.collect[dropK]++; + if (predictableRandom(user, user.stats.gp) < chance) { + user.party.quest.progress.collect++; user.markModified('party.quest.progress'); } diff --git a/test/api/v3/integration/debug/POST-debug_quest-progress.test.js b/test/api/v3/integration/debug/POST-debug_quest-progress.test.js index 3ae3d48882..5ab0ee0af8 100644 --- a/test/api/v3/integration/debug/POST-debug_quest-progress.test.js +++ b/test/api/v3/integration/debug/POST-debug_quest-progress.test.js @@ -44,10 +44,7 @@ describe('POST /debug/quest-progress', () => { await user.sync(); - expect(user.party.quest.progress.collect).to.eql({ - tracks: 300, - branches: 300, - }); + expect(user.party.quest.progress.collect).to.eql(300); }); it('returns error when not in production mode', async () => { diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 850cf53646..f65a1f5557 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -102,7 +102,7 @@ describe('POST /groups/:groupId/quests/abort', () => { progress: { up: 0, down: 0, - collect: {}, + collect: 0, }, completed: null, RSVPNeeded: false, diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index f3bd03a180..bd4ff20916 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -114,7 +114,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { progress: { up: 0, down: 0, - collect: {}, + collect: 0, }, completed: null, RSVPNeeded: false, diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index 65d781c163..584a37a2d9 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -113,7 +113,7 @@ describe('POST /groups/:groupId/quests/leave', () => { progress: { up: 0, down: 0, - collect: {}, + collect: 0, }, completed: null, RSVPNeeded: false, diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 596ad2f360..80641b1e15 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -116,7 +116,7 @@ describe('POST /groups/:groupId/quests/reject', () => { progress: { up: 0, down: 0, - collect: {}, + collect: 0, }, completed: null, RSVPNeeded: false, diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 1f0304eb92..533dced9a6 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -146,7 +146,7 @@ describe('Group Model', () => { 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.progress.collect).to.eql(0); expect(participatingMember.party.quest.completed).to.eql(null); }); @@ -158,12 +158,12 @@ describe('Group Model', () => { 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.progress.collect).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.collect).to.eql({}); + expect(questLeader.party.quest.progress.collect).to.eql(0); expect(questLeader.party.quest.completed).to.eql(null); }); @@ -255,7 +255,6 @@ describe('Group Model', () => { $set: { 'party.quest.key': 'whale', 'party.quest.progress.down': 0, - 'party.quest.progress.collect': {}, 'party.quest.completed': null, }, } @@ -284,7 +283,7 @@ describe('Group Model', () => { expect(userQuest.key).to.eql('whale'); expect(userQuest.progress.down).to.eql(0); - expect(userQuest.progress.collect).to.eql({}); + expect(userQuest.progress.collect).to.eql(0); expect(userQuest.completed).to.eql(null); }); @@ -554,9 +553,7 @@ describe('Group Model', () => { beforeEach(async () => { progress = { - collect: { - soapBars: 5, - }, + collect: 5, }; quest = questScrolls.atom1; @@ -582,7 +579,7 @@ describe('Group Model', () => { group: party, }); - expect(party.quest.progress.collect.soapBars).to.eq(5) + expect(party.quest.progress.collect.soapBars).to.eq(5); }); it('sends a chat message about progress', async () => { @@ -598,7 +595,7 @@ describe('Group Model', () => { }); it('sends a chat message if no progress is made', async () => { - delete progress.collect.soapBars; + progress.collect = 0; await Group.processCollectionQuest({ user: participatingMember, @@ -612,7 +609,7 @@ describe('Group Model', () => { }); it('sends message about victory', async () => { - progress.collect.soapBars = 500; + progress.collect = 500; await Group.processCollectionQuest({ user: participatingMember, @@ -626,7 +623,7 @@ describe('Group Model', () => { }); it('calls finishQuest when all items are found', async () => { - progress.collect.soapBars = 999; + progress.collect = 999; sandbox.spy(party, 'finishQuest'); await Group.processCollectionQuest({ diff --git a/test/common/fns/randomDrop.test.js b/test/common/fns/randomDrop.test.js index 39920e02bf..b91021ab6e 100644 --- a/test/common/fns/randomDrop.test.js +++ b/test/common/fns/randomDrop.test.js @@ -6,7 +6,6 @@ import { generateDaily, generateReward, } from '../../helpers/common.helper'; -import content from '../../../common/script/content/index'; describe('common.fns.randomDrop', () => { let user; @@ -22,23 +21,16 @@ describe('common.fns.randomDrop', () => { }; }); - /** - * function signature as follows: - * randomDrop(user, modifiers) {} - * modifiers = { task, delta = null } - **/ - it('drops an item for the user.party.quest.progress', () => { - expect(user.party.quest.progress.collect).to.eql({}); + expect(user.party.quest.progress.collect).to.eql(0); user.party.quest.key = 'vice2'; - let collectWhat = Object.keys(content.quests[user.party.quest.key].collect)[0]; // lightCrystal predictableRandom = () => { return 0.0001; }; randomDrop(user, { task, predictableRandom }); - expect(user.party.quest.progress.collect[collectWhat]).to.eql(1); + expect(user.party.quest.progress.collect).to.eql(1); randomDrop(user, { task, predictableRandom }); - expect(user.party.quest.progress.collect[collectWhat]).to.eql(2); + expect(user.party.quest.progress.collect).to.eql(2); }); context('drops enabled', () => { diff --git a/test/server_side/controllers/groups.test.js b/test/server_side/controllers/groups.test.js index bf12df321b..88fe31752e 100644 --- a/test/server_side/controllers/groups.test.js +++ b/test/server_side/controllers/groups.test.js @@ -380,7 +380,7 @@ describe('Groups Controller', function() { expect(user.party.quest.progress).to.eql({ up: 0, down: 0, - collect: {} + collect: 0, }); }); diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 9315f9bf4e..923a79814a 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -171,11 +171,7 @@ api.questProgress = { } if (quest.collect) { - let collect = user.party.quest.progress.collect; - _.each(quest.collect, (details, item) => { - collect[item] = collect[item] || 0; - collect[item] += 300; - }); + user.party.quest.progress.collect += 300; } user.markModified('party.quest.progress'); diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index ab3b941eb2..f6a4d337c7 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -277,8 +277,7 @@ export function cron (options = {}) { // 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); + _.merge(progress, {down: 0, up: 0, collect: 0}); // TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn // let numberOfPMs = Object.keys(user.inbox.messages).length; diff --git a/website/server/models/group.js b/website/server/models/group.js index 58aa38fc97..e62c3e12f2 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -117,7 +117,7 @@ function _cleanQuestProgress (merge) { progress: { up: 0, down: 0, - collect: {}, + collect: 0, }, completed: null, RSVPNeeded: false, @@ -365,7 +365,6 @@ schema.methods.startQuest = async function startQuest (user) { if (userIsParticipating) { user.party.quest.key = this.quest.key; user.party.quest.progress.down = 0; - user.party.quest.progress.collect = collected; user.party.quest.completed = null; user.markModified('party.quest'); } @@ -389,7 +388,6 @@ schema.methods.startQuest = async function startQuest (user) { $set: { 'party.quest.key': this.quest.key, 'party.quest.progress.down': 0, - 'party.quest.progress.collect': collected, 'party.quest.completed': null, }, }, { multi: true }).exec(); @@ -499,11 +497,18 @@ async function processCollectionQuest (options) { group, } = options; - _.each(progress.collect, (v, k) => { - group.quest.progress.collect[k] += v; + let itemsFound = {}; + + _.times(progress.collect, () => { + let item = shared.fns.randomVal(user, quest.collect, {key: true}); + if (!itemsFound[item]) { + itemsFound[item] = 0; + } + itemsFound[item]++; + group.quest.progress.collect[item]++; }); - let foundText = _.reduce(progress.collect, (m, v, k) => { + let foundText = _.reduce(itemsFound, (m, v, k) => { m.push(`${v} ${quest.collect[k].text('en')}`); return m; }, []); diff --git a/website/server/models/user.js b/website/server/models/user.js index db305e3330..8042eaa48e 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -376,9 +376,7 @@ export let schema = new Schema({ progress: { up: {type: Number, default: 0}, down: {type: Number, default: 0}, - collect: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, // {feather:1, ingot:2} + collect: {type: Number, default: 0}, }, completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled From b3b437e593b5e0d810bc3a7afa327b40d21c4429 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 3 Jun 2016 09:01:48 -0500 Subject: [PATCH 3/5] chore: Add quest collection migration and temporary code to catch data inconsistencies --- .../20160602_convert_quest_collection.js | 140 ++++++++++++++++++ website/server/controllers/api-v3/tasks.js | 11 ++ website/server/models/group.js | 11 ++ website/server/models/user.js | 3 +- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 migrations/20160602_convert_quest_collection.js diff --git a/migrations/20160602_convert_quest_collection.js b/migrations/20160602_convert_quest_collection.js new file mode 100644 index 0000000000..172575cf1e --- /dev/null +++ b/migrations/20160602_convert_quest_collection.js @@ -0,0 +1,140 @@ +'use strict'; + +/**************************************** + * Author: Blade Barringer @crookedneighbor + * + * Reason: Collection quest data on the client is unreliable + * because the quest key on the user.party.quest.key property + * is unreliable. We were calculating the quest items found + * at the time a drop was created, when instead we could + * just calculate it from the party on the server. This + * necessitates changing the property type of party.quest.progress.collect + * from an object to a number, hence this migration. + ***************************************/ + +global.Promise = require('bluebird'); +const TaskQueue = require('cwait').TaskQueue; +const logger = require('./utils/logger'); +const Timer = require('./utils/timer'); +const connectToDb = require('./utils/connect').connectToDb; +const closeDb = require('./utils/connect').closeDb; + +const timer = new Timer(); + +// PROD: Enable prod db +// const DB_URI = 'mongodb://username:password@dsXXXXXX-a0.mlab.com:XXXXX,dsXXXXXX-a1.mlab.com:XXXXX/habitica?replicaSet=rs-dsXXXXXX'; +const DB_URI = 'mongodb://localhost/new-prod-copy'; + +const COLLECTION_QUESTS = [ + 'evilsanta2', + 'vice2', + 'egg', + 'atom1', + 'moonstone1', + 'goldenknight1', + 'dilatoryDistress1', +] + +let Users, Groups; + +connectToDb(DB_URI).then((db) => { + Users = db.collection('users_backup'); + Groups = db.collection('groups_backup'); + + return Promise.resolve(); +}) +.then(findUsersWithCollectionData) +.then(getUsersCollectionData) +.then(transformCollectionData) +.then(cleanUpEmptyCollectionData) +.then(() => { + timer.stop(); + closeDb(); +}).catch(reportError); + +function reportError (err) { + logger.error('Uh oh, an error occurred'); + closeDb(); + timer.stop(); + throw err; +} + +function findUsersWithCollectionData () { + logger.info('Looking up groups on collection quests...'); + + return Groups.find({'quest.key': {$in: COLLECTION_QUESTS }}, ['quest.members']).toArray().then((groups) => { + logger.success('Found', groups.length, 'parties on collection quests'); + logger.info('Parsing member data...'); + + let members = groups.reduce((array, party) => { + let questers = Object.keys(party.quest.members); + array.push.apply(array, questers); + return array; + }, []); + + logger.success('Found', members.length, 'users on collection quests'); + + return Promise.resolve(members); + }) +} + +function getUsersCollectionData (users) { + logger.info('Fetching collection data from users...'); + + return Users.find({_id: {$in: users}}, ['party.quest.progress']).toArray().then((docs) => { + let items = docs.reduce((array, user) => { + let total = 0; + let collect = user.party && user.party.quest && user.party.quest.progress && user.party.quest.progress.collect; + + if (!collect) return array; + if (typeof collect === 'number') return array; + + for (var i in collect) { + if (collect.hasOwnProperty(i)) { + total += collect[i]; + } + } + + array.push({_id: user._id, collect: total}); + return array; + }, []); + + return Promise.resolve(items); + }); +} + +function updateUserById (user) { + return Users.findOneAndUpdate({_id: user._id}, {$set: {'party.quest.progress.collect': user.collect}}, {returnOriginal: false}) +} + + +function transformCollectionData (users) { + let queue = new TaskQueue(Promise, 300); + + logger.info('About to update', users.length, 'user collection items...'); + + return Promise.map(users, queue.wrap(updateUserById)).then((result) => { + let updates = result.filter(res => res.lastErrorObject && res.lastErrorObject.updatedExisting) + let failures = result.filter(res => !(res.lastErrorObject && res.lastErrorObject.updatedExisting)); + + logger.success(updates.length, 'users have been fixed'); + + if (failures.length > 0) { + logger.error(failures.length, 'users could not be found'); + } + + return Promise.resolve(); + }); +} + +function cleanUpEmptyCollectionData () { + logger.info('Fetching users without collection data...'); + + return Users.updateMany({$or: [{'party.quest.progress.collect': { $type: 3}}, {'party.quest.progress.collect': { $exists: false}}]}, {$set: {'party.quest.progress.collect': 0}}).then((r) => { + let updates = r.result.n; + + logger.success(updates, 'users have been fixed'); + + return Promise.resolve(); + }); +} diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 9bf4ffe634..1c536eac68 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -392,6 +392,17 @@ api.scoreTask = { let wasCompleted = task.completed; + // TEMPORARY, remove once collection migration completes + if (typeof user.party.quest.progress.collect === 'object') { + let totalItemsFound = _.reduce(user.party.quest.progress.collect, (total, amount) => { + return total + amount; + }, 0); + + user.party.quest.progress.collect = totalItemsFound; + } else if (!user.party.quest.progress.collect) { + user.party.quest.progress.collect = 0; + } + let [delta] = common.ops.scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); diff --git a/website/server/models/group.js b/website/server/models/group.js index e62c3e12f2..678269791c 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -589,6 +589,17 @@ schema.statics.processQuestProgress = async function processQuestProgress (user, if (!_isOnQuest(user, progress, group)) return; + // TEMPORARY, remove once collection migration completes + if (typeof progress.collect === 'object') { + let totalItemsFound = _.reduce(progress.collect, (total, amount) => { + return total + amount; + }, 0); + + progress.collect = totalItemsFound; + } else if (!progress.collect) { + progress.collect = 0; + } + let quest = shared.content.quests[group.quest.key]; if (!quest) return; // TODO should this throw an error instead? diff --git a/website/server/models/user.js b/website/server/models/user.js index 8042eaa48e..ca544274da 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -376,7 +376,8 @@ export let schema = new Schema({ progress: { up: {type: Number, default: 0}, down: {type: Number, default: 0}, - collect: {type: Number, default: 0}, + // TEMPORARY - Switch type to Number after migration + collect: {type: Schema.Types.Mixed, default: 0}, }, completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled From 25cc01e7cd4e16ff5c91d179a46de913692fbb86 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 4 Jun 2016 12:41:26 -0500 Subject: [PATCH 4/5] fix: Supply random value as seed so quest collection is random --- test/api/v3/unit/models/group.test.js | 23 ++++++++++++++++++++++- website/server/models/group.js | 5 +++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 533dced9a6..93d80878b5 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -379,7 +379,7 @@ describe('Group Model', () => { expect(Group.processCollectionQuest).to.not.be.called; }); - it('calls processCollectionQuest if quest is a boss quest', async () => { + it('calls processCollectionQuest if quest is a collection quest', async () => { party.quest.key = 'evilsanta2'; await party.save(); @@ -608,6 +608,27 @@ describe('Group Model', () => { expect(party.sendChat).to.be.calledWith('`Participating Member found nothing.`'); }); + it('handles collection quests with multiple items', async () => { + progress.collect = 10; + party.quest.key = 'evilsanta2'; + party.quest.active = false; + quest = questScrolls.evilsanta2; + + await party.save(); + await party.startQuest(questLeader); + + await Group.processCollectionQuest({ + user: participatingMember, + progress, + quest, + group: party, + }); + + expect(party.sendChat).to.be.calledOnce; + expect(party.sendChat).to.be.calledWithMatch(/`Participating Member found/);; + expect(party.sendChat).to.be.calledWithMatch(/\d* (Tracks|Broken Twigs)/); + }); + it('sends message about victory', async () => { progress.collect = 500; diff --git a/website/server/models/group.js b/website/server/models/group.js index 678269791c..f11227bf43 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -500,7 +500,8 @@ async function processCollectionQuest (options) { let itemsFound = {}; _.times(progress.collect, () => { - let item = shared.fns.randomVal(user, quest.collect, {key: true}); + let item = shared.fns.randomVal(user, quest.collect, {key: true, seed: Math.random()}); + if (!itemsFound[item]) { itemsFound[item] = 0; } @@ -518,7 +519,7 @@ async function processCollectionQuest (options) { group.markModified('quest.progress.collect'); // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { + if (_.find(quest.collect, (v, k) => { return group.quest.progress.collect[k] < v.count; })) return await group.save(); From 840a90bbdf143fef8b4bb3b4bf64f64c34843905 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 4 Jun 2016 12:54:50 -0500 Subject: [PATCH 5/5] refactor: Make quest methods private instance methods --- test/api/v3/unit/models/group.test.js | 670 ++++++++++++-------------- website/server/models/group.js | 92 ++-- 2 files changed, 356 insertions(+), 406 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 93d80878b5..e164bae769 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -50,6 +50,318 @@ describe('Group Model', () => { ]); }); + describe('Static Methods', () => { + describe('processQuestProgress', () => { + let progress; + + beforeEach(async () => { + progress = { + up: 5, + down: -5, + collect: 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('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.collect = 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.collect = 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.collect = 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.collect = 999; + + await Group.processQuestProgress(participatingMember, progress); + + expect(finishQuest).to.be.calledOnce; + expect(finishQuest).to.be.calledWith(quest); + }); + }); + }); + }); + context('Instance Methods', () => { describe('#startQuest', () => { context('Failure Conditions', () => { @@ -300,363 +612,5 @@ describe('Group Model', () => { }); }); }); - - describe('processQuestProgress', () => { - let progress; - - beforeEach(async () => { - progress = { - up: 5, - down: -5, - }; - - party.quest.active = true; - party.quest.members = { - [questLeader._id]: true, - [participatingMember._id]: true, - [nonParticipatingMember._id]: false, - [undecidedMember._id]: null, - }; - - await party.save(); - - sandbox.stub(Group, 'processBossQuest').returns(Promise.resolve()); - sandbox.stub(Group, '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); - - expect(Group.processBossQuest).to.not.be.called; - expect(Group.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); - - expect(Group.processBossQuest).to.not.be.called; - expect(Group.processCollectionQuest).to.not.be.called; - }); - - it('returns early if user is not on quest', async () => { - await Group.processQuestProgress(nonParticipatingMember, progress); - - expect(Group.processBossQuest).to.not.be.called; - expect(Group.processCollectionQuest).to.not.be.called; - }); - - it('returns early if user has made no progress', async () => { - await Group.processQuestProgress(participatingMember, null); - - expect(Group.processBossQuest).to.not.be.called; - expect(Group.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); - - expect(Group.processBossQuest).to.not.be.called; - expect(Group.processCollectionQuest).to.not.be.called; - }); - - it('calls processBossQuest if quest is a boss quest', async () => { - party.quest.key = 'whale'; - await party.save(); - - await Group.processQuestProgress(participatingMember, progress); - - expect(Group.processBossQuest).to.be.calledOnce; - expect(Group.processCollectionQuest).to.not.be.called; - }); - - it('calls processCollectionQuest if quest is a collection quest', async () => { - party.quest.key = 'evilsanta2'; - await party.save(); - - await Group.processQuestProgress(participatingMember, progress); - - expect(Group.processBossQuest).to.not.be.called; - expect(Group.processCollectionQuest).to.be.calledOnce; - }); - }); - - describe('processBossQuest', () => { - let progress, quest; - - beforeEach(async () => { - progress = { - up: 5, - down: -5, - }; - quest = questScrolls.whale; - - party.quest.members = { - [questLeader._id]: true, - [participatingMember._id]: true, - [nonParticipatingMember._id]: false, - [undecidedMember._id]: null, - }; - party.quest.key = 'whale'; - - await party.save(); - await party.startQuest(questLeader); - - sandbox.stub(party, 'sendChat'); - }); - - it('applies user\'s progress to quest boss hp', async () => { - await Group.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.quest.progress.hp).to.eql(495); - }); - - it('sends a chat message about progress', async () => { - await Group.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.sendChat).to.be.calledOnce; - expect(party.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.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - 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.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.sendChat).to.be.calledTwice; - expect(party.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 () => { - progress.up = 999; - sandbox.spy(party, 'finishQuest'); - - await Group.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.finishQuest).to.be.calledOnce; - expect(party.finishQuest).to.be.calledWith(quest); - }); - - context('rage quests', () => { - beforeEach(async () => { - party.quest.active = false; - party.quest.key = 'trex_undead'; - quest = questScrolls[party.quest.key]; - - await party.save(); - - await party.startQuest(questLeader); - }); - - it('applies down progress to boss rage', async () => { - await Group.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.quest.progress.rage).to.eql(10); - }); - - it('activates rage when progress.down triggers rage bar', async () => { - progress.down = -999; - - party.quest.progress.hp = 300; - - await Group.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.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.processBossQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.quest.progress.hp).to.eql(500); - }); - }); - }); - - describe('processCollectionQuest', () => { - let progress, quest; - - beforeEach(async () => { - progress = { - collect: 5, - }; - quest = questScrolls.atom1; - - party.quest.members = { - [questLeader._id]: true, - [participatingMember._id]: true, - [nonParticipatingMember._id]: false, - [undecidedMember._id]: null, - }; - party.quest.key = 'atom1'; - - await party.save(); - await party.startQuest(questLeader); - - sandbox.stub(party, 'sendChat'); - }); - - it('applies user\'s progress to found quest items', async () => { - await Group.processCollectionQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.quest.progress.collect.soapBars).to.eq(5); - }); - - it('sends a chat message about progress', async () => { - await Group.processCollectionQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.sendChat).to.be.calledOnce; - expect(party.sendChat).to.be.calledWith('`Participating Member found 5 Bars of Soap.`'); - }); - - it('sends a chat message if no progress is made', async () => { - progress.collect = 0; - - await Group.processCollectionQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.sendChat).to.be.calledOnce; - expect(party.sendChat).to.be.calledWith('`Participating Member found nothing.`'); - }); - - it('handles collection quests with multiple items', async () => { - progress.collect = 10; - party.quest.key = 'evilsanta2'; - party.quest.active = false; - quest = questScrolls.evilsanta2; - - await party.save(); - await party.startQuest(questLeader); - - await Group.processCollectionQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.sendChat).to.be.calledOnce; - expect(party.sendChat).to.be.calledWithMatch(/`Participating Member found/);; - expect(party.sendChat).to.be.calledWithMatch(/\d* (Tracks|Broken Twigs)/); - }); - - it('sends message about victory', async () => { - progress.collect = 500; - - await Group.processCollectionQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.sendChat).to.be.calledTwice; - expect(party.sendChat).to.be.calledWith('`All items found! Party has received their rewards.`'); - }); - - it('calls finishQuest when all items are found', async () => { - progress.collect = 999; - sandbox.spy(party, 'finishQuest'); - - await Group.processCollectionQuest({ - user: participatingMember, - progress, - quest, - group: party, - }); - - expect(party.finishQuest).to.be.calledOnce; - expect(party.finishQuest).to.be.calledWith(quest); - }); - }); }); }); diff --git a/website/server/models/group.js b/website/server/models/group.js index f11227bf43..feddc8ba43 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -489,54 +489,14 @@ function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -async function processCollectionQuest (options) { +schema.methods._processBossQuest = async function processBossQuest (options) { let { user, progress, - quest, - group, - } = options; - - let itemsFound = {}; - - _.times(progress.collect, () => { - let item = shared.fns.randomVal(user, quest.collect, {key: true, seed: Math.random()}); - - if (!itemsFound[item]) { - itemsFound[item] = 0; - } - itemsFound[item]++; - group.quest.progress.collect[item]++; - }); - - let foundText = _.reduce(itemsFound, (m, v, k) => { - m.push(`${v} ${quest.collect[k].text('en')}`); - return m; - }, []); - - foundText = foundText.length > 0 ? foundText.join(', ') : 'nothing'; - group.sendChat(`\`${user.profile.name} found ${foundText}.\``); - group.markModified('quest.progress.collect'); - - // Still needs completing - if (_.find(quest.collect, (v, k) => { - return group.quest.progress.collect[k] < v.count; - })) return await group.save(); - - await group.finishQuest(quest); - group.sendChat('`All items found! Party has received their rewards.`'); - - return await group.save(); -} - -async function processBossQuest (options) { - let { - user, - progress, - quest, - group, } = options; + let group = this; + let quest = questScrolls[group.quest.key]; let down = progress.down * quest.boss.str; // multiply by boss strength group.quest.progress.hp -= progress.up; @@ -580,10 +540,47 @@ async function processBossQuest (options) { } return await group.save(); -} +}; -schema.statics.processBossQuest = processBossQuest; -schema.statics.processCollectionQuest = processCollectionQuest; +schema.methods._processCollectionQuest = async function processCollectionQuest (options) { + let { + user, + progress, + } = options; + + let group = this; + let quest = questScrolls[group.quest.key]; + let itemsFound = {}; + + _.times(progress.collect, () => { + let item = shared.fns.randomVal(user, quest.collect, {key: true, seed: Math.random()}); + + if (!itemsFound[item]) { + itemsFound[item] = 0; + } + itemsFound[item]++; + group.quest.progress.collect[item]++; + }); + + let foundText = _.reduce(itemsFound, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); + return m; + }, []); + + foundText = foundText.length > 0 ? foundText.join(', ') : 'nothing'; + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); + group.markModified('quest.progress.collect'); + + // Still needs completing + if (_.find(quest.collect, (v, k) => { + return group.quest.progress.collect[k] < v.count; + })) return await group.save(); + + await group.finishQuest(quest); + group.sendChat('`All items found! Party has received their rewards.`'); + + return await group.save(); +}; schema.statics.processQuestProgress = async function processQuestProgress (user, progress) { let group = await this.getGroup({user, groupId: 'party'}); @@ -607,10 +604,9 @@ schema.statics.processQuestProgress = async function processQuestProgress (user, let questType = quest.boss ? 'Boss' : 'Collection'; - await this[`process${questType}Quest`]({ + await group[`_process${questType}Quest`]({ user, progress, - quest, group, }); };