From 3e37941e0afe940fe035411a1ba8c414717f3484 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 31 Oct 2017 12:57:44 -0600 Subject: [PATCH] Bulk stats (#9260) * Reorganized stats * Organized allocation common code * Added bulk allocate to common * Added allocate bulk route * Fixed structure and lint * Fixed import and apidoc --- .../{ => stats}/POST-user_allocate.test.js | 2 +- .../stats/POST-user_allocate_bulk.test.js | 40 ++++++ .../POST-user_allocate_now.test.js | 2 +- test/common/ops/{ => stats}/allocate.js | 8 +- test/common/ops/stats/allocateBulk.js | 98 +++++++++++++ test/common/ops/{ => stats}/allocateNow.js | 4 +- .../client/components/userMenu/profile.vue | 2 +- website/client/components/userMenu/stats.vue | 2 +- website/common/locales/en/character.json | 3 +- website/common/script/index.js | 6 +- website/common/script/ops/index.js | 6 +- .../common/script/ops/{ => stats}/allocate.js | 6 +- .../common/script/ops/stats/allocateBulk.js | 44 ++++++ .../script/ops/{ => stats}/allocateNow.js | 2 +- website/server/controllers/api-v3/user.js | 89 ------------ .../server/controllers/api-v3/user/stats.js | 136 ++++++++++++++++++ 16 files changed, 342 insertions(+), 108 deletions(-) rename test/api/v3/integration/user/{ => stats}/POST-user_allocate.test.js (95%) create mode 100644 test/api/v3/integration/user/stats/POST-user_allocate_bulk.test.js rename test/api/v3/integration/user/{ => stats}/POST-user_allocate_now.test.js (92%) rename test/common/ops/{ => stats}/allocate.js (86%) create mode 100644 test/common/ops/stats/allocateBulk.js rename test/common/ops/{ => stats}/allocateNow.js (83%) rename website/common/script/ops/{ => stats}/allocate.js (87%) create mode 100644 website/common/script/ops/stats/allocateBulk.js rename website/common/script/ops/{ => stats}/allocateNow.js (79%) create mode 100644 website/server/controllers/api-v3/user/stats.js diff --git a/test/api/v3/integration/user/POST-user_allocate.test.js b/test/api/v3/integration/user/stats/POST-user_allocate.test.js similarity index 95% rename from test/api/v3/integration/user/POST-user_allocate.test.js rename to test/api/v3/integration/user/stats/POST-user_allocate.test.js index 02d4990092..6487641c79 100644 --- a/test/api/v3/integration/user/POST-user_allocate.test.js +++ b/test/api/v3/integration/user/stats/POST-user_allocate.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration/v3'; +} from '../../../../../helpers/api-integration/v3'; describe('POST /user/allocate', () => { let user; diff --git a/test/api/v3/integration/user/stats/POST-user_allocate_bulk.test.js b/test/api/v3/integration/user/stats/POST-user_allocate_bulk.test.js new file mode 100644 index 0000000000..21705c403f --- /dev/null +++ b/test/api/v3/integration/user/stats/POST-user_allocate_bulk.test.js @@ -0,0 +1,40 @@ +import { + generateUser, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe('POST /user/allocate-bulk', () => { + let user; + const statsUpdate = { + stats: { + con: 1, + str: 2, + }, + }; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('returns an error if user does not have enough points', async () => { + await expect(user.post('/user/allocate-bulk', statsUpdate)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughAttrPoints'), + }); + }); + + it('allocates attribute points', async () => { + await user.update({'stats.points': 3}); + + await user.post('/user/allocate-bulk', statsUpdate); + await user.sync(); + + expect(user.stats.con).to.equal(1); + expect(user.stats.str).to.equal(2); + expect(user.stats.points).to.equal(0); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_allocate_now.test.js b/test/api/v3/integration/user/stats/POST-user_allocate_now.test.js similarity index 92% rename from test/api/v3/integration/user/POST-user_allocate_now.test.js rename to test/api/v3/integration/user/stats/POST-user_allocate_now.test.js index b45f2156be..b8f3ee5603 100644 --- a/test/api/v3/integration/user/POST-user_allocate_now.test.js +++ b/test/api/v3/integration/user/stats/POST-user_allocate_now.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration/v3'; +} from '../../../../../helpers/api-integration/v3'; describe('POST /user/allocate-now', () => { // More tests in common code unit tests diff --git a/test/common/ops/allocate.js b/test/common/ops/stats/allocate.js similarity index 86% rename from test/common/ops/allocate.js rename to test/common/ops/stats/allocate.js index b314ae32ed..1eaa14f88d 100644 --- a/test/common/ops/allocate.js +++ b/test/common/ops/stats/allocate.js @@ -1,12 +1,12 @@ -import allocate from '../../../website/common/script/ops/allocate'; +import allocate from '../../../../website/common/script/ops/stats/allocate'; import { BadRequest, NotAuthorized, -} from '../../../website/common/script/libs/errors'; -import i18n from '../../../website/common/script/i18n'; +} from '../../../../website/common/script/libs/errors'; +import i18n from '../../../../website/common/script/i18n'; import { generateUser, -} from '../../helpers/common.helper'; +} from '../../../helpers/common.helper'; describe('shared.ops.allocate', () => { let user; diff --git a/test/common/ops/stats/allocateBulk.js b/test/common/ops/stats/allocateBulk.js new file mode 100644 index 0000000000..e91de8cf9e --- /dev/null +++ b/test/common/ops/stats/allocateBulk.js @@ -0,0 +1,98 @@ +import allocateBulk from '../../../../website/common/script/ops/stats/allocateBulk'; +import { + BadRequest, + NotAuthorized, +} from '../../../../website/common/script/libs/errors'; +import i18n from '../../../../website/common/script/i18n'; +import { + generateUser, +} from '../../../helpers/common.helper'; + +describe('shared.ops.allocateBulk', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('throws an error if an invalid attribute is supplied', (done) => { + try { + allocateBulk(user, { + body: { + stats: { + invalid: 1, + str: 2, + }, + }, + }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidAttribute', {attr: 'invalid'})); + done(); + } + }); + + it('throws an error if the stats are not supplied', (done) => { + try { + allocateBulk(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('statsObjectRequired')); + done(); + } + }); + + it('throws an error if the user doesn\'t have attribute points', (done) => { + try { + allocateBulk(user, { + body: { + stats: { + int: 1, + str: 2, + }, + }, + }); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughAttrPoints')); + done(); + } + }); + + it('throws an error if the user doesn\'t have enough attribute points', (done) => { + user.stats.points = 1; + try { + allocateBulk(user, { + body: { + stats: { + int: 1, + str: 2, + }, + }, + }); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughAttrPoints')); + done(); + } + }); + + it('allocates attribute points', () => { + user.stats.points = 3; + expect(user.stats.int).to.equal(0); + expect(user.stats.str).to.equal(0); + + allocateBulk(user, { + body: { + stats: { + int: 1, + str: 2, + }, + }, + }); + + expect(user.stats.str).to.equal(2); + expect(user.stats.int).to.equal(1); + expect(user.stats.points).to.equal(0); + }); +}); diff --git a/test/common/ops/allocateNow.js b/test/common/ops/stats/allocateNow.js similarity index 83% rename from test/common/ops/allocateNow.js rename to test/common/ops/stats/allocateNow.js index 548eb238e2..d9f1baacb3 100644 --- a/test/common/ops/allocateNow.js +++ b/test/common/ops/stats/allocateNow.js @@ -1,7 +1,7 @@ -import allocateNow from '../../../website/common/script/ops/allocateNow'; +import allocateNow from '../../../../website/common/script/ops/stats/allocateNow'; import { generateUser, -} from '../../helpers/common.helper'; +} from '../../../helpers/common.helper'; describe('shared.ops.allocateNow', () => { let user; diff --git a/website/client/components/userMenu/profile.vue b/website/client/components/userMenu/profile.vue index fabddf112d..731a2c3465 100644 --- a/website/client/components/userMenu/profile.vue +++ b/website/client/components/userMenu/profile.vue @@ -559,7 +559,7 @@ import keys from 'lodash/keys'; import { beastMasterProgress, mountMasterProgress } from '../../../common/script/count'; import statsComputed from '../../../common/script/libs/statsComputed'; import autoAllocate from '../../../common/script/fns/autoAllocate'; -import allocate from '../../../common/script/ops/allocate'; +import allocate from '../../../common/script/ops/stats/allocate'; import MemberDetails from '../memberDetails'; import bPopover from 'bootstrap-vue/lib/components/popover'; diff --git a/website/client/components/userMenu/stats.vue b/website/client/components/userMenu/stats.vue index 2b4af50f14..8df97720b8 100644 --- a/website/client/components/userMenu/stats.vue +++ b/website/client/components/userMenu/stats.vue @@ -184,7 +184,7 @@ import { beastMasterProgress, mountMasterProgress } from '../../../common/script import statsComputed from '../../../common/script/libs/statsComputed'; import autoAllocate from '../../../common/script/fns/autoAllocate'; import changeClass from '../../../common/script/ops/changeClass'; -import allocate from '../../../common/script/ops/allocate'; +import allocate from '../../../common/script/ops/stats/allocate'; const DROP_ANIMALS = keys(Content.pets); const TOTAL_NUMBER_OF_DROP_ANIMALS = DROP_ANIMALS.length; diff --git a/website/common/locales/en/character.json b/website/common/locales/en/character.json index dd172c5aa2..7760890b7f 100644 --- a/website/common/locales/en/character.json +++ b/website/common/locales/en/character.json @@ -222,5 +222,6 @@ "allocated": "Allocated", "buffs": "Buffs", "pointsAvailable": "Points Available", - "pts": "pts" + "pts": "pts", + "statsObjectRequired": "Stats update is required" } diff --git a/website/common/script/index.js b/website/common/script/index.js index 307cb8b4ec..ed2d9889da 100644 --- a/website/common/script/index.js +++ b/website/common/script/index.js @@ -137,7 +137,9 @@ api.fns = { import scoreTask from './ops/scoreTask'; import sleep from './ops/sleep'; -import allocate from './ops/allocate'; +import allocateNow from './ops/stats/allocateNow'; +import allocate from './ops/stats/allocate'; +import allocateBulk from './ops/stats/allocateBulk'; import buy from './ops/buy'; import buyGear from './ops/buyGear'; import buyHealthPotion from './ops/buyHealthPotion'; @@ -145,7 +147,6 @@ import buyArmoire from './ops/buyArmoire'; import buyMysterySet from './ops/buyMysterySet'; import buyQuest from './ops/buyQuest'; import buySpecialSpell from './ops/buySpecialSpell'; -import allocateNow from './ops/allocateNow'; import hatch from './ops/hatch'; import feed from './ops/feed'; import equip from './ops/equip'; @@ -176,6 +177,7 @@ api.ops = { scoreTask, sleep, allocate, + allocateBulk, buy, buyGear, buyHealthPotion, diff --git a/website/common/script/ops/index.js b/website/common/script/ops/index.js index 06ed551eeb..306e21e865 100644 --- a/website/common/script/ops/index.js +++ b/website/common/script/ops/index.js @@ -3,7 +3,9 @@ import revive from './revive'; import reset from './reset'; import reroll from './reroll'; import rebirth from './rebirth'; -import allocateNow from './allocateNow'; +import allocate from './stats/allocate'; +import allocateBulk from './stats/allocateBulk'; +import allocateNow from './stats/allocateNow'; import sortTask from './sortTask'; import updateTask from './updateTask'; import deleteTask from './deleteTask'; @@ -35,7 +37,6 @@ import hatch from './hatch'; import unlock from './unlock'; import changeClass from './changeClass'; import disableClasses from './disableClasses'; -import allocate from './allocate'; import readCard from './readCard'; import openMysteryItem from './openMysteryItem'; import scoreTask from './scoreTask'; @@ -49,6 +50,7 @@ module.exports = { reroll, rebirth, allocateNow, + allocateBulk, sortTask, updateTask, deleteTask, diff --git a/website/common/script/ops/allocate.js b/website/common/script/ops/stats/allocate.js similarity index 87% rename from website/common/script/ops/allocate.js rename to website/common/script/ops/stats/allocate.js index 39075863b4..70b5944c92 100644 --- a/website/common/script/ops/allocate.js +++ b/website/common/script/ops/stats/allocate.js @@ -1,12 +1,12 @@ import get from 'lodash/get'; import { ATTRIBUTES, -} from '../constants'; +} from '../../constants'; import { BadRequest, NotAuthorized, -} from '../libs/errors'; -import i18n from '../i18n'; +} from '../../libs/errors'; +import i18n from '../../i18n'; module.exports = function allocate (user, req = {}) { let stat = get(req, 'query.stat', 'str'); diff --git a/website/common/script/ops/stats/allocateBulk.js b/website/common/script/ops/stats/allocateBulk.js new file mode 100644 index 0000000000..f44800a813 --- /dev/null +++ b/website/common/script/ops/stats/allocateBulk.js @@ -0,0 +1,44 @@ +import get from 'lodash/get'; +import { + ATTRIBUTES, +} from '../../constants'; +import { + BadRequest, + NotAuthorized, +} from '../../libs/errors'; +import i18n from '../../i18n'; + +module.exports = function allocateBulk (user, req = {}) { + const stats = get(req, 'body.stats'); + if (!stats) throw new BadRequest(i18n.t('statsObjectRequired', req.language)); + + const statKeys = Object.keys(stats); + const invalidStats = statKeys.filter(statKey => { + return ATTRIBUTES.indexOf(statKey) === -1; + }); + if (invalidStats.length > 0) { + throw new BadRequest(i18n.t('invalidAttribute', {attr: invalidStats.join(',')}, req.language)); + } + + if (user.stats.points <= 0) { + throw new NotAuthorized(i18n.t('notEnoughAttrPoints', req.language)); + } + + const newStatValues = Object.values(stats); + const totalPointsToAllocate = newStatValues.reduce((sum, value) => { + return sum + value; + }, 0); + if (user.stats.points < totalPointsToAllocate) { + throw new NotAuthorized(i18n.t('notEnoughAttrPoints', req.language)); + } + + for (let [stat, value] of Object.entries(stats)) { + user.stats[stat] += value; + user.stats.points -= value; + if (stat === 'int') user.stats.mp += value; + } + + return [ + user.stats, + ]; +}; diff --git a/website/common/script/ops/allocateNow.js b/website/common/script/ops/stats/allocateNow.js similarity index 79% rename from website/common/script/ops/allocateNow.js rename to website/common/script/ops/stats/allocateNow.js index 3ac65dcf1b..09b4ab2c82 100644 --- a/website/common/script/ops/allocateNow.js +++ b/website/common/script/ops/stats/allocateNow.js @@ -1,5 +1,5 @@ import times from 'lodash/times'; -import autoAllocate from '../fns/autoAllocate'; +import autoAllocate from '../../fns/autoAllocate'; module.exports = function allocateNow (user) { times(user.stats.points, () => autoAllocate(user)); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index e8e89cb47c..9637829707 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -746,95 +746,6 @@ api.sleep = { }, }; -/** - * @api {post} /api/v3/user/allocate Allocate a single attribute point - * @apiName UserAllocate - * @apiGroup User - * - * @apiParam (Body) {String="str","con","int","per"} stat Query parameter - Default ='str' - * - * @apiParamExample {json} Example request - * {"stat":"int"} - * - * @apiSuccess {Object} data Returns stats from the user profile - * - * @apiError {NotAuthorized} NoPoints Not enough attribute points to increment a stat. - * - * @apiErrorExample {json} - * { - * "success": false, - * "error": "NotAuthorized", - * "message": "You don't have enough attribute points." - * } - */ -api.allocate = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/allocate', - async handler (req, res) { - let user = res.locals.user; - let allocateRes = common.ops.allocate(user, req); - await user.save(); - res.respond(200, ...allocateRes); - }, -}; - -/** - * @api {post} /api/v3/user/allocate-now Allocate all attribute points - * @apiDescription Uses the user's chosen automatic allocation method, or if none, assigns all to STR. Note: will return success, even if there are 0 points to allocate. - * @apiName UserAllocateNow - * @apiGroup User - * - * @apiSuccessExample {json} Success-Response: - * { - * "success": true, - * "data": { - * "hp": 50, - * "mp": 38, - * "exp": 7, - * "gp": 284.8637271160258, - * "lvl": 10, - * "class": "rogue", - * "points": 0, - * "str": 2, - * "con": 2, - * "int": 3, - * "per": 3, - * "buffs": { - * "str": 0, - * "int": 0, - * "per": 0, - * "con": 0, - * "stealth": 0, - * "streaks": false, - * "snowball": false, - * "spookySparkles": false, - * "shinySeed": false, - * "seafoam": false - * }, - * "training": { - * "int": 0, - * "per": 0, - * "str": 0, - * "con": 0 - * } - * } - * } - * - * @apiSuccess {Object} data user.stats - */ -api.allocateNow = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/allocate-now', - async handler (req, res) { - let user = res.locals.user; - let allocateNowRes = common.ops.allocateNow(user); - await user.save(); - res.respond(200, ...allocateNowRes); - }, -}; - /** * @api {post} /api/v3/user/buy/:key Buy gear, armoire or potion * @apiDescription Under the hood uses UserBuyGear, UserBuyPotion and UserBuyArmoire diff --git a/website/server/controllers/api-v3/user/stats.js b/website/server/controllers/api-v3/user/stats.js new file mode 100644 index 0000000000..3e54dc2bb9 --- /dev/null +++ b/website/server/controllers/api-v3/user/stats.js @@ -0,0 +1,136 @@ +// @TODO: Can we import only the functions we need instead of the large object? +import common from '../../../../common'; +import { authWithHeaders } from '../../../middlewares/auth'; + +let api = {}; + +/** + * @api {post} /api/v3/user/allocate Allocate a single attribute point + * @apiName UserAllocate + * @apiGroup User + * + * @apiParam (Body) {String="str","con","int","per"} stat Query parameter - Default ='str' + * + * @apiParamExample {json} Example request + * {"stat":"int"} + * + * @apiSuccess {Object} data Returns stats from the user profile + * + * @apiError {NotAuthorized} NoPoints Not enough attribute points to increment a stat. + * + * @apiErrorExample {json} + * { + * "success": false, + * "error": "NotAuthorized", + * "message": "You don't have enough attribute points." + * } + */ +api.allocate = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/allocate', + async handler (req, res) { + let user = res.locals.user; + let allocateRes = common.ops.allocate(user, req); + await user.save(); + res.respond(200, ...allocateRes); + }, +}; + +/** + * @api {post} /api/v3/user/allocate-bulk Allocate multiple attribute points + * @apiName UserAllocateBulk + * @apiGroup User + * + * @apiParam (Body) { Object } stats Body parameter + * + * @apiParamExample {json} Example request + * { + * stats: { + * 'int': int, + * 'str': int, + * 'con': int, + * 'per': int, + * }, + * } + * + * @apiSuccess {Object} data Returns stats from the user profile + * + * @apiError {NotAuthorized} NoPoints Not enough attribute points to increment a stat. + * + * @apiErrorExample {json} + * { + * "success": false, + * "error": "NotAuthorized", + * "message": "You don't have enough attribute points." + * } + */ +api.allocateBulk = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/allocate-bulk', + async handler (req, res) { + let user = res.locals.user; + let allocateRes = common.ops.allocateBulk(user, req); + await user.save(); + res.respond(200, ...allocateRes); + }, +}; + +/** + * @api {post} /api/v3/user/allocate-now Allocate all attribute points + * @apiDescription Uses the user's chosen automatic allocation method, or if none, assigns all to STR. Note: will return success, even if there are 0 points to allocate. + * @apiName UserAllocateNow + * @apiGroup User + * + * @apiSuccessExample {json} Success-Response: + * { + * "success": true, + * "data": { + * "hp": 50, + * "mp": 38, + * "exp": 7, + * "gp": 284.8637271160258, + * "lvl": 10, + * "class": "rogue", + * "points": 0, + * "str": 2, + * "con": 2, + * "int": 3, + * "per": 3, + * "buffs": { + * "str": 0, + * "int": 0, + * "per": 0, + * "con": 0, + * "stealth": 0, + * "streaks": false, + * "snowball": false, + * "spookySparkles": false, + * "shinySeed": false, + * "seafoam": false + * }, + * "training": { + * "int": 0, + * "per": 0, + * "str": 0, + * "con": 0 + * } + * } + * } + * + * @apiSuccess {Object} data user.stats + */ +api.allocateNow = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/allocate-now', + async handler (req, res) { + let user = res.locals.user; + let allocateNowRes = common.ops.allocateNow(user); + await user.save(); + res.respond(200, ...allocateNowRes); + }, +}; + +module.exports = api;