diff --git a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js index 9453757530..d8cd074bc7 100644 --- a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js +++ b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js @@ -187,6 +187,22 @@ describe('POST /user/class/cast/:spellId', () => { expect(group.chat[0].uuid).to.equal('system'); }); + it('cast bulk', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + await groupLeader.update({'stats.mp': 200, 'stats.class': 'wizard', 'stats.lvl': 13}); + await groupLeader.post('/user/class/cast/earth', {quantity: 2}); + + await sleep(1); + await group.sync(); + + expect(group.chat[0]).to.exist; + expect(group.chat[0].uuid).to.equal('system'); + }); + it('searing brightness does not affect challenge or group tasks', async () => { let guild = await generateGroup(user); let challenge = await generateChallenge(user, guild); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 70721cfa54..5776c9ef73 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1,16 +1,14 @@ import { authWithHeaders } from '../../middlewares/auth'; import common from '../../../common'; import { - NotFound, BadRequest, NotAuthorized, } from '../../libs/errors'; -import * as Tasks from '../../models/task'; import { basicFields as basicGroupFields, model as Group, } from '../../models/group'; -import { model as User } from '../../models/user'; +import * as Tasks from '../../models/task'; import Bluebird from 'bluebird'; import _ from 'lodash'; import * as passwordUtils from '../../libs/password'; @@ -524,224 +522,6 @@ api.getUserAnonymized = { }, }; -const partyMembersFields = 'profile.name stats achievements items.special'; - -async function castTaskSpell (res, req, targetId, user, spell) { - if (!targetId) throw new BadRequest(res.t('targetIdUUID')); - - const task = await Tasks.Task.findOne({ - _id: targetId, - userId: user._id, - }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); - if (task.group.id) throw new BadRequest(res.t('groupTasksNoCast')); - - spell.cast(user, task, req); - - const results = await Bluebird.all([ - user.save(), - task.save(), - ]); - - return results; -} - -async function castMultiTaskSpell (req, user, spell) { - const tasks = await Tasks.Task.find({ - userId: user._id, - ...Tasks.taskIsGroupOrChallengeQuery, - }).exec(); - - spell.cast(user, tasks, req); - - const toSave = tasks - .filter(t => t.isModified()) - .map(t => t.save()); - toSave.unshift(user.save()); - const saved = await Bluebird.all(toSave); - - const response = { - tasks: saved, - user, - }; - - return response; -} - -async function castSelfSpell (req, user, spell) { - spell.cast(user, null, req); - await user.save(); -} - -async function castPartySpell (req, party, partyMembers, user, spell) { - if (!party) { - partyMembers = [user]; // Act as solo party - } else { - partyMembers = await User - .find({ - 'party._id': party._id, - _id: { $ne: user._id }, // add separately - }) - // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save - // default values for non-selected fields and pre('save') will mess up thinking some values are missing - .exec(); - - partyMembers.unshift(user); - } - - spell.cast(user, partyMembers, req); - await Bluebird.all(partyMembers.map(m => m.save())); - - return partyMembers; -} - -async function castUserSpell (res, req, party, partyMembers, targetId, user, spell) { - if (!party && (!targetId || user._id === targetId)) { - partyMembers = user; - } else { - if (!targetId) throw new BadRequest(res.t('targetIdUUID')); - if (!party) throw new NotFound(res.t('partyNotFound')); - partyMembers = await User - .findOne({_id: targetId, 'party._id': party._id}) - // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save - // default values for non-selected fields and pre('save') will mess up thinking some values are missing - .exec(); - } - - if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); - - spell.cast(user, partyMembers, req); - - if (partyMembers !== user) { - await Bluebird.all([ - user.save(), - partyMembers.save(), - ]); - } else { - await partyMembers.save(); // partyMembers is user - } - - return partyMembers; -} - -/** - * @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target - * @apiName UserCast - * @apiGroup User - * - - * @apiParam (Path) {String=fireball, mpheal, earth, frost, smash, defensiveStance, valorousPresence, intimidate, pickPocket, backStab, toolsOfTrade, stealth, heal, protectAura, brightness, healAll} spellId The skill to cast. - * @apiParam (Query) {UUID} targetId Query parameter, necessary if the spell is cast on a party member or task. Not used if the spell is case on the user or the user's current party. - * @apiParamExample {json} Query example: - * Cast "Pickpocket" on a task: - * https://habitica.com/api/v3/user/class/cast/pickPocket?targetId=fd427623... - * - * Cast "Tools of the Trade" on the party: - * https://habitica.com/api/v3/user/class/cast/toolsOfTrade - * - * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned. - * - * @apiDescription Skill Key to Name Mapping - * Mage - * fireball: "Burst of Flames" - * mpheal: "Ethereal Surge" - * earth: "Earthquake" - * frost: "Chilling Frost" - * - * Warrior - * smash: "Brutal Smash" - * defensiveStance: "Defensive Stance" - * valorousPresence: "Valorous Presence" - * intimidate: "Intimidating Gaze" - * - * Rogue - * pickPocket: "Pickpocket" - * backStab: "Backstab" - * toolsOfTrade: "Tools of the Trade" - * stealth: "Stealth" - * - * Healer - * heal: "Healing Light" - * protectAura: "Protective Aura" - * brightness: "Searing Brightness" - * healAll: "Blessing" - * - * @apiError (400) {NotAuthorized} Not enough mana. - * @apiUse TaskNotFound - * @apiUse PartyNotFound - * @apiUse UserNotFound - */ -api.castSpell = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/class/cast/:spellId', - async handler (req, res) { - let user = res.locals.user; - let spellId = req.params.spellId; - let targetId = req.query.targetId; - - // optional because not required by all targetTypes, presence is checked later if necessary - req.checkQuery('targetId', res.t('targetIdUUID')).optional().isUUID(); - - let reqValidationErrors = req.validationErrors(); - if (reqValidationErrors) throw reqValidationErrors; - - let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; - let spell = common.content.spells[klass][spellId]; - - if (!spell) throw new NotFound(res.t('spellNotFound', {spellId})); - if (spell.mana > user.stats.mp) throw new NotAuthorized(res.t('notEnoughMana')); - if (spell.value > user.stats.gp && !spell.previousPurchase) throw new NotAuthorized(res.t('messageNotEnoughGold')); - if (spell.lvl > user.stats.lvl) throw new NotAuthorized(res.t('spellLevelTooHigh', {level: spell.lvl})); - - let targetType = spell.target; - - if (targetType === 'task') { - const results = await castTaskSpell(res, req, targetId, user, spell); - res.respond(200, { - user: results[0], - task: results[1], - }); - } else if (targetType === 'self') { - await castSelfSpell(req, user, spell); - res.respond(200, { user }); - } else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary - const response = await castMultiTaskSpell(req, user, spell); - res.respond(200, response); - } else if (targetType === 'party' || targetType === 'user') { - const party = await Group.getGroup({groupId: 'party', user}); - // arrays of users when targetType is 'party' otherwise single users - let partyMembers; - - if (targetType === 'party') { - partyMembers = await castPartySpell(req, party, partyMembers, user, spell); - } else { - partyMembers = await castUserSpell(res, req, party, partyMembers, targetId, user, spell); - } - - let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers]; - - // Only return some fields. - // See comment above on why we can't just select the necessary fields when querying - partyMembersRes = partyMembersRes.map(partyMember => { - return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields)); - }); - - res.respond(200, { - partyMembers: partyMembersRes, - user, - }); - - if (party && !spell.silent) { - let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; - party.sendChat(message); - await party.save(); - } - } - }, -}; - /** * @api {post} /api/v3/user/sleep Make the user start / stop sleeping (resting in the Inn) * @apiName UserSleep diff --git a/website/server/controllers/api-v3/user/spells.js b/website/server/controllers/api-v3/user/spells.js new file mode 100644 index 0000000000..35415137eb --- /dev/null +++ b/website/server/controllers/api-v3/user/spells.js @@ -0,0 +1,140 @@ +import { authWithHeaders } from '../../../middlewares/auth'; +import common from '../../../../common'; +import { + model as Group, +} from '../../../models/group'; +import { + NotAuthorized, + NotFound, +} from '../../../libs/errors'; +import { + castTaskSpell, + castMultiTaskSpell, + castSelfSpell, + castPartySpell, + castUserSpell, +} from '../../../libs/spells'; + +const partyMembersFields = 'profile.name stats achievements items.special'; + +let api = {}; + +/** + * @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target + * @apiName UserCast + * @apiGroup User + * + + * @apiParam (Path) {String=fireball, mpheal, earth, frost, smash, defensiveStance, valorousPresence, intimidate, pickPocket, backStab, toolsOfTrade, stealth, heal, protectAura, brightness, healAll} spellId The skill to cast. + * @apiParam (Query) {UUID} targetId Query parameter, necessary if the spell is cast on a party member or task. Not used if the spell is case on the user or the user's current party. + * @apiParamExample {json} Query example: + * Cast "Pickpocket" on a task: + * https://habitica.com/api/v3/user/class/cast/pickPocket?targetId=fd427623... + * + * Cast "Tools of the Trade" on the party: + * https://habitica.com/api/v3/user/class/cast/toolsOfTrade + * + * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned. + * + * @apiDescription Skill Key to Name Mapping + * Mage + * fireball: "Burst of Flames" + * mpheal: "Ethereal Surge" + * earth: "Earthquake" + * frost: "Chilling Frost" + * + * Warrior + * smash: "Brutal Smash" + * defensiveStance: "Defensive Stance" + * valorousPresence: "Valorous Presence" + * intimidate: "Intimidating Gaze" + * + * Rogue + * pickPocket: "Pickpocket" + * backStab: "Backstab" + * toolsOfTrade: "Tools of the Trade" + * stealth: "Stealth" + * + * Healer + * heal: "Healing Light" + * protectAura: "Protective Aura" + * brightness: "Searing Brightness" + * healAll: "Blessing" + * + * @apiError (400) {NotAuthorized} Not enough mana. + * @apiUse TaskNotFound + * @apiUse PartyNotFound + * @apiUse UserNotFound + */ +api.castSpell = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/class/cast/:spellId', + async handler (req, res) { + let user = res.locals.user; + let spellId = req.params.spellId; + let targetId = req.query.targetId; + const quantity = req.body.quantity || 1; + + // optional because not required by all targetTypes, presence is checked later if necessary + req.checkQuery('targetId', res.t('targetIdUUID')).optional().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; + let spell = common.content.spells[klass][spellId]; + + if (!spell) throw new NotFound(res.t('spellNotFound', {spellId})); + if (spell.mana > user.stats.mp) throw new NotAuthorized(res.t('notEnoughMana')); + if (spell.value > user.stats.gp && !spell.previousPurchase) throw new NotAuthorized(res.t('messageNotEnoughGold')); + if (spell.lvl > user.stats.lvl) throw new NotAuthorized(res.t('spellLevelTooHigh', {level: spell.lvl})); + + let targetType = spell.target; + + if (targetType === 'task') { + const results = await castTaskSpell(res, req, targetId, user, spell, quantity); + res.respond(200, { + user: results[0], + task: results[1], + }); + } else if (targetType === 'self') { + await castSelfSpell(req, user, spell, quantity); + res.respond(200, { user }); + } else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary + const response = await castMultiTaskSpell(req, user, spell, quantity); + res.respond(200, response); + } else if (targetType === 'party' || targetType === 'user') { + const party = await Group.getGroup({groupId: 'party', user}); + // arrays of users when targetType is 'party' otherwise single users + let partyMembers; + + if (targetType === 'party') { + partyMembers = await castPartySpell(req, party, partyMembers, user, spell, quantity); + } else { + partyMembers = await castUserSpell(res, req, party, partyMembers, targetId, user, spell, quantity); + } + + let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers]; + + // Only return some fields. + // See comment above on why we can't just select the necessary fields when querying + partyMembersRes = partyMembersRes.map(partyMember => { + return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields)); + }); + + res.respond(200, { + partyMembers: partyMembersRes, + user, + }); + + if (party && !spell.silent) { + let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; + party.sendChat(message); + await party.save(); + } + } + }, +}; + +module.exports = api; diff --git a/website/server/libs/spells.js b/website/server/libs/spells.js new file mode 100644 index 0000000000..ba728e3664 --- /dev/null +++ b/website/server/libs/spells.js @@ -0,0 +1,121 @@ +import Bluebird from 'bluebird'; + +import { model as User } from '../models/user'; +import * as Tasks from '../models/task'; +import { + NotFound, + BadRequest, +} from './errors'; + +// @TODO: After refactoring individual spells, move quantity to the calculations + +async function castTaskSpell (res, req, targetId, user, spell, quantity = 1) { + if (!targetId) throw new BadRequest(res.t('targetIdUUID')); + + const task = await Tasks.Task.findOne({ + _id: targetId, + userId: user._id, + }).exec(); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); + if (task.group.id) throw new BadRequest(res.t('groupTasksNoCast')); + + for (let i = 0; i < quantity; i += 1) { + spell.cast(user, task, req); + } + + const results = await Bluebird.all([ + user.save(), + task.save(), + ]); + + return results; +} + +async function castMultiTaskSpell (req, user, spell, quantity = 1) { + const tasks = await Tasks.Task.find({ + userId: user._id, + ...Tasks.taskIsGroupOrChallengeQuery, + }).exec(); + + for (let i = 0; i < quantity; i += 1) { + spell.cast(user, tasks, req); + } + + const toSave = tasks + .filter(t => t.isModified()) + .map(t => t.save()); + toSave.unshift(user.save()); + const saved = await Bluebird.all(toSave); + + const response = { + tasks: saved, + user, + }; + + return response; +} + +async function castSelfSpell (req, user, spell, quantity = 1) { + for (let i = 0; i < quantity; i += 1) { + spell.cast(user, null, req); + } + await user.save(); +} + +async function castPartySpell (req, party, partyMembers, user, spell, quantity = 1) { + if (!party) { + partyMembers = [user]; // Act as solo party + } else { + partyMembers = await User + .find({ + 'party._id': party._id, + _id: { $ne: user._id }, // add separately + }) + // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save + // default values for non-selected fields and pre('save') will mess up thinking some values are missing + .exec(); + + partyMembers.unshift(user); + } + + for (let i = 0; i < quantity; i += 1) { + spell.cast(user, partyMembers, req); + } + await Bluebird.all(partyMembers.map(m => m.save())); + + return partyMembers; +} + +async function castUserSpell (res, req, party, partyMembers, targetId, user, spell, quantity = 1) { + if (!party && (!targetId || user._id === targetId)) { + partyMembers = user; + } else { + if (!targetId) throw new BadRequest(res.t('targetIdUUID')); + if (!party) throw new NotFound(res.t('partyNotFound')); + partyMembers = await User + .findOne({_id: targetId, 'party._id': party._id}) + // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save + // default values for non-selected fields and pre('save') will mess up thinking some values are missing + .exec(); + } + + if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); + + for (let i = 0; i < quantity; i += 1) { + spell.cast(user, partyMembers, req); + } + + if (partyMembers !== user) { + await Bluebird.all([ + user.save(), + partyMembers.save(), + ]); + } else { + await partyMembers.save(); // partyMembers is user + } + + return partyMembers; +} + +export {castTaskSpell, castMultiTaskSpell, castSelfSpell, castPartySpell, castUserSpell};