Merge branch 'develop' into party-chat-translations

# Conflicts:
#	website/server/controllers/api-v3/user.js
This commit is contained in:
Mateus Etto
2018-02-26 19:55:32 +09:00
774 changed files with 18976 additions and 16904 deletions

View File

@@ -247,7 +247,7 @@ api.loginLocal = {
let username = req.body.username;
let password = req.body.password;
if (validator.isEmail(username)) {
if (validator.isEmail(String(username))) {
login = {'auth.local.email': username.toLowerCase()}; // Emails are stored lowercase
} else {
login = {'auth.local.username': username};
@@ -347,11 +347,11 @@ api.loginSocial = {
// Clean previous email preferences
if (savedUser.auth[network].emails && savedUser.auth[network].emails[0] && savedUser.auth[network].emails[0].value) {
EmailUnsubscription
.remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
.exec()
.then(() => {
if (!existingUser) sendTxnEmail(savedUser, 'welcome');
}); // eslint-disable-line max-nested-callbacks
.remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
.exec()
.then(() => {
if (!existingUser) sendTxnEmail(savedUser, 'welcome');
}); // eslint-disable-line max-nested-callbacks
}
if (!existingUser) {
@@ -410,7 +410,7 @@ api.pusherAuth = {
}
resourceId = resourceId.join('-'); // the split at the beginning had split resourceId too
if (!validator.isUUID(resourceId)) {
if (!validator.isUUID(String(resourceId))) {
throw new BadRequest('Invalid Pusher resource id, must be a UUID.');
}

View File

@@ -1,8 +1,6 @@
import { authWithHeaders, authWithSession } from '../../middlewares/auth';
import _ from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import uuid from 'uuid';
import { model as Challenge } from '../../models/challenge';
import {
model as Group,
@@ -24,77 +22,15 @@ import {
createTasks,
} from '../../libs/taskManager';
const TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'dateCompleted', 'history', 'id', 'streak', 'createdAt', 'challenge'];
import {
addUserJoinChallengeNotification,
getChallengeGroupResponse,
createChallenge,
cleanUpTask,
} from '../../libs/challenges';
let api = {};
async function createChallenge (user, req, res) {
let groupId = req.body.group;
let prize = req.body.prize;
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember'));
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
throw new NotAuthorized(res.t('onlyGroupLeaderChal'));
}
if (group._id === TAVERN_ID && prize < 1) {
throw new NotAuthorized(res.t('tavChalsMinPrize'));
}
if (prize > 0) {
let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
let prizeCost = prize / 4;
if (prizeCost > user.balance + groupBalance) {
throw new NotAuthorized(res.t('cantAfford'));
}
if (groupBalance >= prizeCost) {
// Group pays for all of prize
group.balance -= prizeCost;
} else if (groupBalance > 0) {
// User pays remainder of prize cost after group
let remainder = prizeCost - group.balance;
group.balance = 0;
user.balance -= remainder;
} else {
// User pays for all of prize
user.balance -= prizeCost;
}
}
group.challengeCount += 1;
if (!req.body.summary) {
req.body.summary = req.body.name;
}
req.body.leader = user._id;
req.body.official = user.contributor.admin && req.body.official ? true : false;
let challenge = new Challenge(Challenge.sanitize(req.body));
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
let challengeValidationErrors = challenge.validateSync();
if (challengeValidationErrors) throw challengeValidationErrors;
// Add achievement if user's first challenge
if (!user.achievements.joinedChallenge) {
user.achievements.joinedChallenge = true;
user.addNotification('CHALLENGE_JOINED_ACHIEVEMENT');
}
let results = await Bluebird.all([challenge.save({
validateBeforeSave: false, // already validate
}), group.save()]);
let savedChal = results[0];
await savedChal.syncToUser(user); // (it also saves the user)
return {savedChal, group};
}
/**
* @apiDefine ChallengeLeader Challenge Leader
* The leader of the challenge can use this route.
@@ -264,12 +200,7 @@ api.createChallenge = {
_id: user._id,
profile: {name: user.profile.name},
};
response.group = { // we already have the group data
_id: group._id,
name: group.name,
type: group.type,
privacy: group.privacy,
};
response.group = getChallengeGroupResponse(group);
res.analytics.track('challenge create', {
uuid: user._id,
@@ -320,22 +251,13 @@ api.joinChallenge = {
challenge.memberCount += 1;
// Add achievement if user's first challenge
if (!user.achievements.joinedChallenge) {
user.achievements.joinedChallenge = true;
user.addNotification('CHALLENGE_JOINED_ACHIEVEMENT');
}
addUserJoinChallengeNotification(user);
// Add all challenge's tasks to user's tasks and save the challenge
let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
let response = results[1].toJSON();
response.group = { // we already have the group data
_id: group._id,
name: group.name,
type: group.type,
privacy: group.privacy,
};
response.group = getChallengeGroupResponse(group);
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
@@ -434,11 +356,11 @@ api.getUserChallenges = {
let challenges = await Challenge.find({
$or: orOptions,
})
.sort('-official -createdAt')
// see below why we're not using populate
// .populate('group', basicGroupFields)
// .populate('leader', nameFields)
.exec();
.sort('-official -createdAt')
// see below why we're not using populate
// .populate('group', basicGroupFields)
// .populate('leader', nameFields)
.exec();
let resChals = challenges.map(challenge => challenge.toJSON());
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
@@ -692,12 +614,7 @@ api.updateChallenge = {
let savedChal = await challenge.save();
let response = savedChal.toJSON();
response.group = { // we already have the group data
_id: group._id,
name: group.name,
type: group.type,
privacy: group.privacy,
};
response.group = getChallengeGroupResponse(group);
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
res.respond(200, response);
@@ -798,23 +715,6 @@ api.selectChallengeWinner = {
},
};
function cleanUpTask (task) {
let cleansedTask = omit(task, TASK_KEYS_TO_REMOVE);
// Copy checklists but reset to uncomplete and assign new id
if (!cleansedTask.checklist) cleansedTask.checklist = [];
cleansedTask.checklist.forEach((item) => {
item.completed = false;
item.id = uuid();
});
if (cleansedTask.type !== 'reward') {
delete cleansedTask.value;
}
return cleansedTask;
}
/**
* @api {post} /api/v3/challenges/:challengeId/clone Clone a challenge
* @apiName CloneChallenge

View File

@@ -935,10 +935,10 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
if (group.type === 'guild') {
if (_.includes(userToInvite.guilds, group._id)) {
throw new NotAuthorized(res.t('userAlreadyInGroup'));
throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name}));
}
if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup'));
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name}));
}
let guildInvite = {
@@ -952,14 +952,14 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
} else if (group.type === 'party') {
// Do not add to invitations.parties array if the user is already invited to that party
if (_.find(userToInvite.invitations.parties, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyPendingInvitation'));
throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name}));
}
if (userToInvite.party._id) {
let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
// Allow user to be invited to a new party when they're partying solo
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty'));
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name}));
}
let partyInvite = {id: group._id, name: group.name, inviter: inviter._id};
@@ -1018,9 +1018,9 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
]})
.select({_id: true, 'preferences.emailNotifications': true})
.exec();

View File

@@ -71,15 +71,15 @@ api.getPatrons = {
const perPage = 50;
let patrons = await User
.find({
'backer.tier': {$gt: 0},
})
.select('contributor backer profile.name')
.sort('-backer.tier')
.skip(page * perPage)
.limit(perPage)
.lean()
.exec();
.find({
'backer.tier': {$gt: 0},
})
.select('contributor backer profile.name')
.sort('-backer.tier')
.skip(page * perPage)
.limit(perPage)
.lean()
.exec();
res.respond(200, patrons);
},
@@ -123,13 +123,13 @@ api.getHeroes = {
middlewares: [authWithHeaders()],
async handler (req, res) {
let heroes = await User
.find({
'contributor.level': {$gt: 0},
})
.select('contributor backer profile.name')
.sort('-contributor.level')
.lean()
.exec();
.find({
'contributor.level': {$gt: 0},
})
.select('contributor backer profile.name')
.sort('-contributor.level')
.lean()
.exec();
res.respond(200, heroes);
},

View File

@@ -13,11 +13,11 @@ function geti18nBrowserScript (language) {
return `(function () {
if (!window) return;
window['habitica-i18n'] = ${JSON.stringify({
availableLanguages,
language,
strings: translations[langCode],
momentLang: momentLangs[language.momentLangCode],
})};
availableLanguages,
language,
strings: translations[langCode],
momentLang: momentLangs[language.momentLangCode],
})};
})()`;
}

View File

@@ -408,8 +408,8 @@ api.getChallengeMemberProgress = {
userId: memberId,
'challenge.id': challengeId,
})
.select('-tags') // We don't want to return the tags publicly TODO same for other data?
.exec();
.select('-tags') // We don't want to return the tags publicly TODO same for other data?
.exec();
// manually call toJSON with minimize: true so empty paths aren't returned
let response = member.toJSON({minimize: true});

View File

@@ -3,7 +3,7 @@ import { authWithHeaders } from '../../middlewares/auth';
let api = {};
// @TODO export this const, cannot export it from here because only routes are exported from controllers
const LAST_ANNOUNCEMENT_TITLE = 'WORLD BOSS: THE DYSHEARTENER IS UNLEASHED';
const LAST_ANNOUNCEMENT_TITLE = 'FEBRUARY SUBSCRIBER ITEMS AND BEHIND THE SCENES BLOG';
const worldDmg = { // @TODO
bailey: false,
};
@@ -32,23 +32,21 @@ api.getNews = {
<h1 class="align-self-center">${res.t('newStuff')}</h1>
</div>
</div>
<h2>2/14/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<h2>2/22/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<hr/>
<p>Oh no, a World Boss is attacking Habitica! Head to the Tavern to see it now. If you're on mobile, make sure that you have the latest versions downloaded to get the full experience!</p>
<div class="promo_dysheartener center-block"></div>
<p class="text-center">~*~</p>
<p>The sun is rising on Valentine's Day when a shocking crash splinters the air. A blaze of sickly pink light lances through all the buildings, and bricks crumble as a deep crack rips through Habit City's main street. An unearthly shrieking rises through the air, shattering windows as a hulking form slithers forth from the oozing earth.</p>
<p>Mandibles snap and a carapace glitters; legs upon legs unfurl in the air. The crowd begins to scream as the insectoid creature rears up, revealing itself to be none other than that cruelest of creatures: the fearsome Dysheartener itself. It howls in anticipation and lunges forward, hungering to gnaw on the hopes of hard-working Habiticans. With each rasping scrape of its spiny forelegs, you feel a vise of despair tightening in your chest.</p>
<p>"Take heart, everyone!" Lemoness shouts. "It probably thinks that we're easy targets because so many of us have daunting New Year's Resolutions, but it's about to discover that Habiticans know how to stick to their goals!"</p>
<p>AnnDeLune raises her staff. "Let's tackle our tasks and take this monster down!"</p>
<p class="text-center">~*~</p>
<p>Complete Habits, Dailies and To-Dos to damage the World Boss! Incomplete Dailies fill the Rage Strike Bar. When the Rage Strike bar is full, the World Boss will attack one of Habitica's shopkeepers. A World Boss will never damage individual players or accounts in any way. Only active accounts who are not resting in the Inn will have their incomplete Dailies tallied.</p>
<p>*If youd prefer not to see the World Boss due to a phobia, check out the <a href="http://habitica.wikia.com/wiki/Phobia_Protection_Extension" target="_blank">Phobia Protection Extension</a> (and set it to hide “Beetles”) :)</p>
<div class="small">by Lemoness, Beffymaroo, SabreCat, viirus, Apollo, and piyorii</div>
<div class="small">Art by AnnDeLune, Lemoness, and Beffymaroo</div>
<div class="small">Written by Lemoness</div>
<div class="small">Phobia Protection Extension by Alys</div>
<h3>February Subscriber Items Revealed!</h3>
<p>The February Subscriber Items have been revealed: The Love Bug Set!! It's a special three-piece set in honor of our ongoing battle with the Dysheartener. You only have until February 28 to receive the item set when you <a href='/user/settings/subscription'>subscribe</a>. If you're already an active subscriber, reload the site and then head to Inventory > Items to claim your gear!</p>
<p>Subscribers also receive the ability to buy Gems for Gold -- the longer you subscribe, the more Gems you can buy per month! There are other perks as well, such as longer access to uncompressed data and a cute Jackalope pet. Best of all, subscriptions let us keep Habitica running. Thank you very much for your support -- it means a lot to us.</p>
<div class="promo_mystery_201802 center-block"></div>
<div class="small mb-3">by Beffymaroo</div>
<div class="media align-items-center">
<div class="media-body">
<h3>Behind the Scenes: Bringing a World Boss to Life</h3>
<p>There's a new <a href='https://habitica.wordpress.com/2018/02/22/behind-the-scenes-bringing-a-world-boss-to-life/' target='_blank'>Behind the Scenes post</a> on the Habitica blog! Ever wonder what goes into bringing a World Boss to Habitica? Check out this post for a behind the scenes glimpse of how the team makes these events happen. It's all fun and no spoilers (we promise)!</p>
<div class="small mb-3">by Beffymaroo</div>
</div>
<div class="promo_seasonalshop_broken ml-3"></div>
</div>
</div>
`,
});

View File

@@ -79,8 +79,8 @@ api.inviteToQuest = {
'party._id': group._id,
_id: {$ne: user._id},
})
.select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices')
.exec();
.select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices')
.exec();
group.markModified('quest');
group.quest.key = questKey;

View File

@@ -287,7 +287,7 @@ api.getUserTasks = {
async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`);
types.push('completedTodos', '_allCompletedTodos'); // _allCompletedTodos is currently in BETA and is likely to be removed in future
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
req.checkQuery('type', res.t('invalidTasksTypeExtra')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
@@ -325,7 +325,7 @@ api.getChallengeTasks = {
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let types = Tasks.tasksTypes.map(type => `${type}s`);
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
req.checkQuery('type', res.t('invalidTasksType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;

View File

@@ -86,7 +86,7 @@ api.getGroupTasks = {
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
req.checkQuery('type', res.t('invalidTasksType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
@@ -493,8 +493,8 @@ api.getGroupApprovals = {
'group.approval.approved': false,
'group.approval.requested': true,
}, 'userId group text')
.populate('userId', 'profile')
.exec();
.populate('userId', 'profile')
.exec();
res.respond(200, approvals);
},

View File

@@ -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,239 +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'}.\``;
if (targetType === 'user') {
party.sendChat(message, null, null, {
type: 'spell_cast_user',
user: user.profile.name,
class: klass,
spell: spellId,
target: partyMembers.profile.name,
});
} else {
party.sendChat(message, null, null, {
type: 'spell_cast_party',
user: user.profile.name,
class: klass,
spell: spellId,
});
}
await party.save();
}
}
},
};
/**
* @api {post} /api/v3/user/sleep Make the user start / stop sleeping (resting in the Inn)
* @apiName UserSleep

View File

@@ -0,0 +1,155 @@
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'}.\``;
if (targetType === 'user') {
party.sendChat(message, null, null, {
type: 'spell_cast_user',
user: user.profile.name,
class: klass,
spell: spellId,
target: partyMembers.profile.name,
});
} else {
party.sendChat(message, null, null, {
type: 'spell_cast_party',
user: user.profile.name,
class: klass,
spell: spellId,
});
}
await party.save();
}
}
},
};
module.exports = api;