mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-16 14:17:22 +01:00
Merge pull request #7570 from crookedneighbor/WIP_7567_dont_rely_on_quest_key_on_user
Use party's quest key in cron
This commit is contained in:
@@ -17,9 +17,7 @@ module.exports = function randomDrop (user, options, req = {}) {
|
|||||||
let acceptableDrops;
|
let acceptableDrops;
|
||||||
let chance;
|
let chance;
|
||||||
let drop;
|
let drop;
|
||||||
let dropK;
|
|
||||||
let dropMultiplier;
|
let dropMultiplier;
|
||||||
let quest;
|
|
||||||
let rarity;
|
let rarity;
|
||||||
let task;
|
let task;
|
||||||
|
|
||||||
@@ -38,15 +36,8 @@ module.exports = function randomDrop (user, options, req = {}) {
|
|||||||
}, 0) || 0));
|
}, 0) || 0));
|
||||||
chance = diminishingReturns(chance, 0.75);
|
chance = diminishingReturns(chance, 0.75);
|
||||||
|
|
||||||
if (user.party.quest.key)
|
if (predictableRandom(user, user.stats.gp) < chance) {
|
||||||
quest = content.quests[user.party.quest.key];
|
user.party.quest.progress.collect++;
|
||||||
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]++;
|
|
||||||
user.markModified('party.quest.progress');
|
user.markModified('party.quest.progress');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
140
migrations/20160602_convert_quest_collection.js
Normal file
140
migrations/20160602_convert_quest_collection.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,10 +44,7 @@ describe('POST /debug/quest-progress', () => {
|
|||||||
|
|
||||||
await user.sync();
|
await user.sync();
|
||||||
|
|
||||||
expect(user.party.quest.progress.collect).to.eql({
|
expect(user.party.quest.progress.collect).to.eql(300);
|
||||||
tracks: 300,
|
|
||||||
branches: 300,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when not in production mode', async () => {
|
it('returns error when not in production mode', async () => {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe('POST /groups/:groupId/quests/abort', () => {
|
|||||||
progress: {
|
progress: {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
collect: {},
|
collect: 0,
|
||||||
},
|
},
|
||||||
completed: null,
|
completed: null,
|
||||||
RSVPNeeded: false,
|
RSVPNeeded: false,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('POST /groups/:groupId/quests/cancel', () => {
|
|||||||
progress: {
|
progress: {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
collect: {},
|
collect: 0,
|
||||||
},
|
},
|
||||||
completed: null,
|
completed: null,
|
||||||
RSVPNeeded: false,
|
RSVPNeeded: false,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe('POST /groups/:groupId/quests/leave', () => {
|
|||||||
progress: {
|
progress: {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
collect: {},
|
collect: 0,
|
||||||
},
|
},
|
||||||
completed: null,
|
completed: null,
|
||||||
RSVPNeeded: false,
|
RSVPNeeded: false,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('POST /groups/:groupId/quests/reject', () => {
|
|||||||
progress: {
|
progress: {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
collect: {},
|
collect: 0,
|
||||||
},
|
},
|
||||||
completed: null,
|
completed: null,
|
||||||
RSVPNeeded: false,
|
RSVPNeeded: false,
|
||||||
|
|||||||
@@ -5,49 +5,365 @@ import { quests as questScrolls } from '../../../../../common/script/content';
|
|||||||
import * as email from '../../../../../website/server/libs/api-v3/email';
|
import * as email from '../../../../../website/server/libs/api-v3/email';
|
||||||
|
|
||||||
describe('Group Model', () => {
|
describe('Group Model', () => {
|
||||||
context('Instance Methods', () => {
|
let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember;
|
||||||
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 },
|
||||||
|
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 () => {
|
beforeEach(async () => {
|
||||||
sandbox.stub(email, 'sendTxn');
|
progress = {
|
||||||
|
up: 5,
|
||||||
|
down: -5,
|
||||||
|
collect: 5,
|
||||||
|
};
|
||||||
|
|
||||||
party = new Group({
|
party.quest.members = {
|
||||||
name: 'test party',
|
[questLeader._id]: true,
|
||||||
type: 'party',
|
[participatingMember._id]: true,
|
||||||
privacy: 'private',
|
[nonParticipatingMember._id]: false,
|
||||||
});
|
[undecidedMember._id]: null,
|
||||||
|
};
|
||||||
|
|
||||||
questLeader = new User({
|
await party.save();
|
||||||
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('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', () => {
|
context('Failure Conditions', () => {
|
||||||
it('throws an error if group is not a party', async () => {
|
it('throws an error if group is not a party', async () => {
|
||||||
let guild = new Group({
|
let guild = new Group({
|
||||||
@@ -74,11 +390,12 @@ describe('Group Model', () => {
|
|||||||
party.quest.key = 'whale';
|
party.quest.key = 'whale';
|
||||||
party.quest.active = false;
|
party.quest.active = false;
|
||||||
party.quest.leader = questLeader._id;
|
party.quest.leader = questLeader._id;
|
||||||
party.quest.members = { };
|
party.quest.members = {
|
||||||
party.quest.members[questLeader._id] = true;
|
[questLeader._id]: true,
|
||||||
party.quest.members[participatingMember._id] = true;
|
[participatingMember._id]: true,
|
||||||
party.quest.members[nonParticipatingMember._id] = false;
|
[nonParticipatingMember._id]: false,
|
||||||
party.quest.members[undecidedMember._id] = null;
|
[undecidedMember._id]: null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('activates quest', () => {
|
it('activates quest', () => {
|
||||||
@@ -141,7 +458,7 @@ describe('Group Model', () => {
|
|||||||
|
|
||||||
expect(participatingMember.party.quest.key).to.eql('whale');
|
expect(participatingMember.party.quest.key).to.eql('whale');
|
||||||
expect(participatingMember.party.quest.progress.down).to.eql(0);
|
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(participatingMember.party.quest.completed).to.eql(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,12 +470,12 @@ describe('Group Model', () => {
|
|||||||
|
|
||||||
expect(participatingMember.party.quest.key).to.eql('whale');
|
expect(participatingMember.party.quest.key).to.eql('whale');
|
||||||
expect(participatingMember.party.quest.progress.down).to.eql(0);
|
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(participatingMember.party.quest.completed).to.eql(null);
|
||||||
|
|
||||||
expect(questLeader.party.quest.key).to.eql('whale');
|
expect(questLeader.party.quest.key).to.eql('whale');
|
||||||
expect(questLeader.party.quest.progress.down).to.eql(0);
|
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);
|
expect(questLeader.party.quest.completed).to.eql(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +567,6 @@ describe('Group Model', () => {
|
|||||||
$set: {
|
$set: {
|
||||||
'party.quest.key': 'whale',
|
'party.quest.key': 'whale',
|
||||||
'party.quest.progress.down': 0,
|
'party.quest.progress.down': 0,
|
||||||
'party.quest.progress.collect': {},
|
|
||||||
'party.quest.completed': null,
|
'party.quest.completed': null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -279,7 +595,7 @@ describe('Group Model', () => {
|
|||||||
|
|
||||||
expect(userQuest.key).to.eql('whale');
|
expect(userQuest.key).to.eql('whale');
|
||||||
expect(userQuest.progress.down).to.eql(0);
|
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);
|
expect(userQuest.completed).to.eql(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
generateDaily,
|
generateDaily,
|
||||||
generateReward,
|
generateReward,
|
||||||
} from '../../helpers/common.helper';
|
} from '../../helpers/common.helper';
|
||||||
import content from '../../../common/script/content/index';
|
|
||||||
|
|
||||||
describe('common.fns.randomDrop', () => {
|
describe('common.fns.randomDrop', () => {
|
||||||
let user;
|
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', () => {
|
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';
|
user.party.quest.key = 'vice2';
|
||||||
let collectWhat = Object.keys(content.quests[user.party.quest.key].collect)[0]; // lightCrystal
|
|
||||||
predictableRandom = () => {
|
predictableRandom = () => {
|
||||||
return 0.0001;
|
return 0.0001;
|
||||||
};
|
};
|
||||||
randomDrop(user, { task, predictableRandom });
|
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 });
|
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', () => {
|
context('drops enabled', () => {
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ describe('Groups Controller', function() {
|
|||||||
expect(user.party.quest.progress).to.eql({
|
expect(user.party.quest.progress).to.eql({
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
collect: {}
|
collect: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -171,11 +171,7 @@ api.questProgress = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (quest.collect) {
|
if (quest.collect) {
|
||||||
let collect = user.party.quest.progress.collect;
|
user.party.quest.progress.collect += 300;
|
||||||
_.each(quest.collect, (details, item) => {
|
|
||||||
collect[item] = collect[item] || 0;
|
|
||||||
collect[item] += 300;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.markModified('party.quest.progress');
|
user.markModified('party.quest.progress');
|
||||||
|
|||||||
@@ -392,6 +392,17 @@ api.scoreTask = {
|
|||||||
|
|
||||||
let wasCompleted = task.completed;
|
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);
|
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)
|
// 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);
|
if (direction === 'up') user.fns.randomDrop({task, delta}, req);
|
||||||
|
|||||||
@@ -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
|
// 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 = user.party.quest.progress;
|
||||||
let _progress = _.cloneDeep(progress);
|
let _progress = _.cloneDeep(progress);
|
||||||
_.merge(progress, {down: 0, up: 0});
|
_.merge(progress, {down: 0, up: 0, collect: 0});
|
||||||
progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
|
|
||||||
|
|
||||||
// TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn
|
// 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;
|
// let numberOfPMs = Object.keys(user.inbox.messages).length;
|
||||||
|
|||||||
@@ -158,13 +158,7 @@ async function cronAsync (req, res) {
|
|||||||
});
|
});
|
||||||
await Bluebird.all(toSave);
|
await Bluebird.all(toSave);
|
||||||
|
|
||||||
let quest = common.content.quests[user.party.quest.key];
|
await Group.processQuestProgress(user, progress);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set _cronSignature, lastCron and auth.timestamps.loggedin to signal end of cron
|
// Set _cronSignature, lastCron and auth.timestamps.loggedin to signal end of cron
|
||||||
await User.update({
|
await User.update({
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function _cleanQuestProgress (merge) {
|
|||||||
progress: {
|
progress: {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
collect: {},
|
collect: 0,
|
||||||
},
|
},
|
||||||
completed: null,
|
completed: null,
|
||||||
RSVPNeeded: false,
|
RSVPNeeded: false,
|
||||||
@@ -365,7 +365,6 @@ schema.methods.startQuest = async function startQuest (user) {
|
|||||||
if (userIsParticipating) {
|
if (userIsParticipating) {
|
||||||
user.party.quest.key = this.quest.key;
|
user.party.quest.key = this.quest.key;
|
||||||
user.party.quest.progress.down = 0;
|
user.party.quest.progress.down = 0;
|
||||||
user.party.quest.progress.collect = collected;
|
|
||||||
user.party.quest.completed = null;
|
user.party.quest.completed = null;
|
||||||
user.markModified('party.quest');
|
user.markModified('party.quest');
|
||||||
}
|
}
|
||||||
@@ -389,7 +388,6 @@ schema.methods.startQuest = async function startQuest (user) {
|
|||||||
$set: {
|
$set: {
|
||||||
'party.quest.key': this.quest.key,
|
'party.quest.key': this.quest.key,
|
||||||
'party.quest.progress.down': 0,
|
'party.quest.progress.down': 0,
|
||||||
'party.quest.progress.collect': collected,
|
|
||||||
'party.quest.completed': null,
|
'party.quest.completed': null,
|
||||||
},
|
},
|
||||||
}, { multi: true }).exec();
|
}, { multi: true }).exec();
|
||||||
@@ -491,42 +489,14 @@ 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 = async function collectQuest (user, progress) {
|
schema.methods._processBossQuest = async function processBossQuest (options) {
|
||||||
let group = await this.getGroup({user, groupId: 'party'});
|
let {
|
||||||
if (!_isOnQuest(user, progress, group)) return;
|
user,
|
||||||
let quest = shared.content.quests[group.quest.key];
|
progress,
|
||||||
|
} = options;
|
||||||
_.each(progress.collect, (v, k) => {
|
|
||||||
group.quest.progress.collect[k] += v;
|
|
||||||
});
|
|
||||||
|
|
||||||
let foundText = _.reduce(progress.collect, (m, v, k) => {
|
|
||||||
m.push(`${v} ${quest.collect[k].text('en')}`);
|
|
||||||
return m;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
foundText = foundText ? foundText.join(', ') : 'nothing';
|
|
||||||
group.sendChat(`\`${user.profile.name} found ${foundText}.\``);
|
|
||||||
group.markModified('quest.progress.collect');
|
|
||||||
|
|
||||||
// Still needs completing
|
|
||||||
if (_.find(shared.content.quests[group.quest.key].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.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?
|
|
||||||
|
|
||||||
|
let group = this;
|
||||||
|
let quest = questScrolls[group.quest.key];
|
||||||
let down = progress.down * quest.boss.str; // multiply by boss strength
|
let down = progress.down * quest.boss.str; // multiply by boss strength
|
||||||
|
|
||||||
group.quest.progress.hp -= progress.up;
|
group.quest.progress.hp -= progress.up;
|
||||||
@@ -572,6 +542,75 @@ schema.statics.bossQuest = async function bossQuest (user, progress) {
|
|||||||
return await group.save();
|
return await group.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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'});
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
let questType = quest.boss ? 'Boss' : 'Collection';
|
||||||
|
|
||||||
|
await group[`_process${questType}Quest`]({
|
||||||
|
user,
|
||||||
|
progress,
|
||||||
|
group,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
|
// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
|
||||||
// we export an empty object that is then populated with the query-returned data
|
// we export an empty object that is then populated with the query-returned data
|
||||||
export let tavernQuest = {};
|
export let tavernQuest = {};
|
||||||
|
|||||||
@@ -376,9 +376,8 @@ export let schema = new Schema({
|
|||||||
progress: {
|
progress: {
|
||||||
up: {type: Number, default: 0},
|
up: {type: Number, default: 0},
|
||||||
down: {type: Number, default: 0},
|
down: {type: Number, default: 0},
|
||||||
collect: {type: Schema.Types.Mixed, default: () => {
|
// TEMPORARY - Switch type to Number after migration
|
||||||
return {};
|
collect: {type: Schema.Types.Mixed, default: 0},
|
||||||
}}, // {feather:1, ingot:2}
|
|
||||||
},
|
},
|
||||||
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
|
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user