From 2a97915477d26403fcd8395d59a0f4163c7d37b1 Mon Sep 17 00:00:00 2001 From: negue Date: Sat, 17 Mar 2018 21:56:19 +0100 Subject: [PATCH] Purchase API Refactoring: Market Gear (#10010) * convert buyGear to buyMarketGearOperation + tests * move NotImplementedError --- .../ops/buy/{buyGear.js => buyMarketGear.js} | 35 ++++- website/common/script/libs/errors.js | 9 ++ .../script/ops/buy/abstractBuyOperation.js | 127 ++++++++++++++++++ website/common/script/ops/buy/buy.js | 9 +- website/common/script/ops/buy/buyGear.js | 82 ----------- .../common/script/ops/buy/buyMarketGear.js | 78 +++++++++++ 6 files changed, 253 insertions(+), 87 deletions(-) rename test/common/ops/buy/{buyGear.js => buyMarketGear.js} (86%) create mode 100644 website/common/script/ops/buy/abstractBuyOperation.js delete mode 100644 website/common/script/ops/buy/buyGear.js create mode 100644 website/common/script/ops/buy/buyMarketGear.js diff --git a/test/common/ops/buy/buyGear.js b/test/common/ops/buy/buyMarketGear.js similarity index 86% rename from test/common/ops/buy/buyGear.js rename to test/common/ops/buy/buyMarketGear.js index bd1798a673..48ce22ea1d 100644 --- a/test/common/ops/buy/buyGear.js +++ b/test/common/ops/buy/buyMarketGear.js @@ -4,14 +4,20 @@ import sinon from 'sinon'; // eslint-disable-line no-shadow import { generateUser, } from '../../../helpers/common.helper'; -import buyGear from '../../../../website/common/script/ops/buy/buyGear'; +import {BuyMarketGearOperation} from '../../../../website/common/script/ops/buy/buyMarketGear'; import shared from '../../../../website/common/script'; import { BadRequest, NotAuthorized, NotFound, } from '../../../../website/common/script/libs/errors'; import i18n from '../../../../website/common/script/i18n'; -describe('shared.ops.buyGear', () => { +function buyGear (user, req, analytics) { + let buyOp = new BuyMarketGearOperation(user, req, analytics); + + return buyOp.purchase(); +} + +describe('shared.ops.buyMarketGear', () => { let user; let analytics = {track () {}}; @@ -111,6 +117,31 @@ describe('shared.ops.buyGear', () => { } }); + it('does not buy equipment of different class', (done) => { + user.stats.gp = 82; + user.stats.class = 'warrior'; + + try { + buyGear(user, {params: {key: 'weapon_special_winter2018Rogue'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('cannotBuyItem')); + done(); + } + }); + + it('does not buy equipment in bulk', (done) => { + user.stats.gp = 82; + + try { + buyGear(user, {params: {key: 'armor_warrior_1'}, quantity: 3}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageNotAbleToBuyInBulk')); + done(); + } + }); + // TODO after user.ops.equip is done xit('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { user.stats.gp = 100; diff --git a/website/common/script/libs/errors.js b/website/common/script/libs/errors.js index cb780d215b..8e82afa7a8 100644 --- a/website/common/script/libs/errors.js +++ b/website/common/script/libs/errors.js @@ -40,3 +40,12 @@ export class NotFound extends CustomError { this.message = customMessage || 'Not found.'; } } + +export class NotImplementedError extends CustomError { + constructor (str) { + super(); + this.name = this.constructor.name; + + this.message = `Method: '${str}' not implemented`; + } +} diff --git a/website/common/script/ops/buy/abstractBuyOperation.js b/website/common/script/ops/buy/abstractBuyOperation.js new file mode 100644 index 0000000000..d641372a5f --- /dev/null +++ b/website/common/script/ops/buy/abstractBuyOperation.js @@ -0,0 +1,127 @@ +import i18n from '../../i18n'; +import { + NotAuthorized, NotImplementedError, +} from '../../libs/errors'; +import _merge from 'lodash/merge'; +import _get from 'lodash/get'; + +export class AbstractBuyOperation { + /** + * @param {User} user - the User-Object + * @param {Request} req - the Request-Object + * @param {analytics} analytics + */ + constructor (user, req, analytics) { + this.user = user; + this.req = req || {}; + this.analytics = analytics; + + this.quantity = _get(req, 'quantity', 1); + } + + /** + * Shortcut to get the translated string without passing `req.language` + * @param {String} key - translation key + * @param {*=} params + * @returns {*|string} + */ + // eslint-disable-next-line no-unused-vars + i18n (key, params = {}) { + return i18n.t.apply(null, [...arguments, this.req.language]); + } + + /** + * If the Operation allows purchasing items by quantity + * @returns Boolean + */ + multiplePurchaseAllowed () { + throw new NotImplementedError('multiplePurchaseAllowed'); + } + + /** + * Method is called to save the params as class-fields in order to access them + */ + extractAndValidateParams () { + throw new NotImplementedError('extractAndValidateParams'); + } + + executeChanges () { + throw new NotImplementedError('executeChanges'); + } + + analyticsData () { + throw new NotImplementedError('sendToAnalytics'); + } + + purchase () { + if (!this.multiplePurchaseAllowed() && this.quantity > 1) { + throw new NotAuthorized(this.i18n('messageNotAbleToBuyInBulk')); + } + + this.extractAndValidateParams(this.user, this.req); + + let resultObj = this.executeChanges(this.user, this.item, this.req); + + if (this.analytics) { + this.sendToAnalytics(this.analyticsData()); + } + + return resultObj; + } + + sendToAnalytics (additionalData = {}) { + // spread-operator produces an "unexpected token" error + let analyticsData = _merge(additionalData, { + // ...additionalData, + uuid: this.user._id, + category: 'behavior', + headers: this.req.headers, + }); + + if (this.multiplePurchaseAllowed()) { + analyticsData.quantityPurchased = this.quantity; + } + + this.analytics.track('acquire item', analyticsData); + } +} + +export class AbstractGoldItemOperation extends AbstractBuyOperation { + constructor (user, req, analytics) { + super(user, req, analytics); + } + + getItemValue (item) { + return item.value; + } + + canUserPurchase (user, item) { + this.item = item; + let itemValue = this.getItemValue(item); + + let userGold = user.stats.gp; + + if (userGold < itemValue * this.quantity) { + throw new NotAuthorized(this.i18n('messageNotEnoughGold')); + } + + if (item.canOwn && !item.canOwn(user)) { + throw new NotAuthorized(this.i18n('cannotBuyItem')); + } + } + + substractCurrency (user, item, quantity = 1) { + let itemValue = this.getItemValue(item); + + user.stats.gp -= itemValue * quantity; + } + + analyticsData () { + return { + itemKey: this.item.key, + itemType: 'Market', + acquireMethod: 'Gold', + goldCost: this.getItemValue(this.item), + }; + } +} diff --git a/website/common/script/ops/buy/buy.js b/website/common/script/ops/buy/buy.js index 1ecb149c9c..51e054f5be 100644 --- a/website/common/script/ops/buy/buy.js +++ b/website/common/script/ops/buy/buy.js @@ -5,7 +5,7 @@ import { } from '../../libs/errors'; import buyHealthPotion from './buyHealthPotion'; import buyArmoire from './buyArmoire'; -import buyGear from './buyGear'; +import {BuyMarketGearOperation} from './buyMarketGear'; import buyMysterySet from './buyMysterySet'; import buyQuest from './buyQuest'; import buySpecialSpell from './buySpecialSpell'; @@ -58,9 +58,12 @@ module.exports = function buy (user, req = {}, analytics) { case 'special': buyRes = buySpecialSpell(user, req, analytics); break; - default: - buyRes = buyGear(user, req, analytics); + default: { + const buyOp = new BuyMarketGearOperation(user, req, analytics); + + buyRes = buyOp.purchase(); break; + } } return buyRes; diff --git a/website/common/script/ops/buy/buyGear.js b/website/common/script/ops/buy/buyGear.js deleted file mode 100644 index e811dd6fb4..0000000000 --- a/website/common/script/ops/buy/buyGear.js +++ /dev/null @@ -1,82 +0,0 @@ -import content from '../../content/index'; -import i18n from '../../i18n'; -import get from 'lodash/get'; -import pick from 'lodash/pick'; -import splitWhitespace from '../../libs/splitWhitespace'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../../libs/errors'; -import handleTwoHanded from '../../fns/handleTwoHanded'; -import ultimateGear from '../../fns/ultimateGear'; - -import { removePinnedGearAddPossibleNewOnes } from '../pinnedGearUtils'; - -module.exports = function buyGear (user, req = {}, analytics) { - let key = get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - - let item = content.gear.flat[key]; - - if (!item) throw new NotFound(i18n.t('itemNotFound', {key}, req.language)); - - if (user.stats.gp < item.value) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); - } - - if (item.canOwn && !item.canOwn(user)) { - throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); - } - - let message; - - if (user.items.gear.owned[item.key]) { - throw new NotAuthorized(i18n.t('equipmentAlreadyOwned', req.language)); - } - - let itemIndex = Number(item.index); - - if (Number.isInteger(itemIndex) && content.classes.includes(item.klass)) { - let previousLevelGear = key.replace(/[0-9]/, itemIndex - 1); - let hasPreviousLevelGear = user.items.gear.owned[previousLevelGear]; - let checkIndexToType = itemIndex > (item.type === 'weapon' || item.type === 'shield' && item.klass === 'rogue' ? 0 : 1); - - if (checkIndexToType && !hasPreviousLevelGear) { - throw new NotAuthorized(i18n.t('previousGearNotOwned', req.language)); - } - } - - if (user.preferences.autoEquip) { - user.items.gear.equipped[item.type] = item.key; - message = handleTwoHanded(user, item, undefined, req); - } - - removePinnedGearAddPossibleNewOnes(user, `gear.flat.${item.key}`, item.key); - - if (item.last) ultimateGear(user); - - user.stats.gp -= item.value; - - if (!message) { - message = i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language); - } - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: key, - acquireMethod: 'Gold', - goldCost: item.value, - category: 'behavior', - headers: req.headers, - }); - } - - return [ - pick(user, splitWhitespace('items achievements stats flags pinnedItems')), - message, - ]; -}; diff --git a/website/common/script/ops/buy/buyMarketGear.js b/website/common/script/ops/buy/buyMarketGear.js new file mode 100644 index 0000000000..6ed3928089 --- /dev/null +++ b/website/common/script/ops/buy/buyMarketGear.js @@ -0,0 +1,78 @@ +import content from '../../content/index'; +import get from 'lodash/get'; +import pick from 'lodash/pick'; +import splitWhitespace from '../../libs/splitWhitespace'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../../libs/errors'; +import handleTwoHanded from '../../fns/handleTwoHanded'; +import ultimateGear from '../../fns/ultimateGear'; + +import {removePinnedGearAddPossibleNewOnes} from '../pinnedGearUtils'; + +import { AbstractGoldItemOperation } from './abstractBuyOperation'; + +export class BuyMarketGearOperation extends AbstractGoldItemOperation { + constructor (user, req, analytics) { + super(user, req, analytics); + } + + multiplePurchaseAllowed () { + return false; + } + + extractAndValidateParams (user, req) { + let key = this.key = get(req, 'params.key'); + if (!key) throw new BadRequest(this.i18n('missingKeyParam')); + + let item = content.gear.flat[key]; + + if (!item) throw new NotFound(this.i18n('itemNotFound', {key})); + + this.canUserPurchase(user, item); + + if (user.items.gear.owned[item.key]) { + throw new NotAuthorized(this.i18n('equipmentAlreadyOwned')); + } + + let itemIndex = Number(item.index); + + if (Number.isInteger(itemIndex) && content.classes.includes(item.klass)) { + let previousLevelGear = key.replace(/[0-9]/, itemIndex - 1); + let hasPreviousLevelGear = user.items.gear.owned[previousLevelGear]; + let checkIndexToType = itemIndex > (item.type === 'weapon' || item.type === 'shield' && item.klass === 'rogue' ? 0 : 1); + + if (checkIndexToType && !hasPreviousLevelGear) { + throw new NotAuthorized(this.i18n('previousGearNotOwned')); + } + } + } + + executeChanges (user, item, req) { + let message; + + if (user.preferences.autoEquip) { + user.items.gear.equipped[item.type] = item.key; + message = handleTwoHanded(user, item, undefined, req); + } + + removePinnedGearAddPossibleNewOnes(user, `gear.flat.${item.key}`, item.key); + + if (item.last) ultimateGear(user); + + this.substractCurrency(user, item); + + if (!message) { + message = this.i18n('messageBought', { + itemText: item.text(req.language), + }); + } + + return [ + pick(user, splitWhitespace('items achievements stats flags pinnedItems')), + message, + ]; + } +}