From 26437e7e2edc946c03a979ecc4b352778112ac14 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 24 Apr 2020 19:01:03 +0200 Subject: [PATCH 1/6] wip: valid unlocked sets --- test/common/ops/unlock.js | 2 +- website/common/script/ops/unlock.js | 58 ++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index 8c527d10aa..c927dccb66 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -71,7 +71,7 @@ describe('shared.ops.unlock', () => { } }); - xit('returns an error when user already owns items in a full set', done => { + it('returns an error when user already owns items in a full set and it would be more expensive to buy the entire set', done => { try { unlock(user, { query: { path: unlockPath.split(',').splice(2).join(',') } }); unlock(user, { query: { path: unlockPath } }); diff --git a/website/common/script/ops/unlock.js b/website/common/script/ops/unlock.js index 0ae0c97add..aafa10aa14 100644 --- a/website/common/script/ops/unlock.js +++ b/website/common/script/ops/unlock.js @@ -7,17 +7,52 @@ import { removeItemByPath } from './pinnedGearUtils'; import getItemInfo from '../libs/getItemInfo'; import content from '../content/index'; -const incentiveBackgrounds = ['blue', 'green', 'red', 'purple', 'yellow']; - -function determineCost (isBackground, isFullSet) { +function determineCost (setType, isFullSet, set) { if (isBackground) { return isFullSet ? 3.75 : 1.75; } return isFullSet ? 1.25 : 0.5; } -function isGear (path) { - return path.includes('gear.'); +/** + * Throw an error when the provided set isn't valid. + */ +function invalidSet () { + throw new BadRequest("invalid set string"); +} + +/** + * Return the type of the set (gear or one of the appareance sets - see content.appearance). + */ +function getSetType (firstPath) { + if (firstPath.includes('gear.')) return 'gear'; + + const type = firstPath.split('.')[0]; + if (content.appearance[type]) return type; + + return invalidSet(); +} + +/** + * Return the set of items to unlock. + */ +function getSet (setType, firstPath) { + const itemKey = splitPathItem(firstPath); + const item = setType === 'gear' + ? content.gear.flat[itemKey] + : content.appearance[setType][itemKey]; + + if (!item) return invalidSet(); + // Only animal gear sets are unlockable + if (setType === 'gear' && item.gearSet !== 'animal') return invalidSet(); + + + + if (setType === 'gear') { + + } else { + return item.set + } } function alreadyUnlocked (user, path) { @@ -68,6 +103,7 @@ function buildResponse ({ purchased, preference, items }, ownsAlready, language) // If item is already purchased -> equip it // Otherwise unlock it +// @TODO refactor and take as parameter the set name, for single items use the buy ops export default function unlock (user, req = {}, analytics) { const path = get(req.query, 'path'); @@ -75,11 +111,15 @@ export default function unlock (user, req = {}, analytics) { throw new BadRequest(i18n.t('pathRequired', req.language)); } - const isFullSet = path.includes(','); - const isBackground = path.startsWith('background.'); - const cost = determineCost(isBackground, isFullSet); - const setPaths = path.split(','); + const isFullSet = setPaths.length > 1; + // We take the first path and use it to get the set, + // The passed paths are not used anymore after this point + const firstPath = setPaths[0]; + const setType = getSetType(firstPath); + const set = getSet(setType, firstPath); + const cost = determineCost(setType, isFullSet, set); + let unlockedAlready; if (isFullSet) { From ebd22296df26f0980b91394715118f78248f1b9d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 7 May 2020 16:15:11 +0200 Subject: [PATCH 2/6] wip --- .../common/script/content/appearance/index.js | 16 +++++---- website/common/script/ops/unlock.js | 36 ++++++++++--------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/website/common/script/content/appearance/index.js b/website/common/script/content/appearance/index.js index 2c65beb9ce..3ffe67e57c 100644 --- a/website/common/script/content/appearance/index.js +++ b/website/common/script/content/appearance/index.js @@ -23,12 +23,16 @@ forOwn(backgrounds, (value, key) => { const appearances = { - hair, - shirt: shirts, - size: sizes, - skin: skins, - chair: chairs, - background: reorderedBgs, + hair, NO appearances.hair.{bangs|base|beard|color|flower|mustache}[key].set.setPrice and .key + shirt: shirts, appearances.shirt[key].set.setPrice and .key + size: sizes, NO, does not have cost + skin: skins, OK, appearances.skin[key].set.setPrice and .key + chair: chairs, NO, does not have cost + background: reorderedBgs, OK appearances.backgroud[key].set.setPrice and .key }; +^ check with get(path) after validating name because hair are nested for example +^ if item.set exist -> use item.setPrice +^ what about other items in set? + export default appearances; diff --git a/website/common/script/ops/unlock.js b/website/common/script/ops/unlock.js index aafa10aa14..c318ed600d 100644 --- a/website/common/script/ops/unlock.js +++ b/website/common/script/ops/unlock.js @@ -7,11 +7,12 @@ import { removeItemByPath } from './pinnedGearUtils'; import getItemInfo from '../libs/getItemInfo'; import content from '../content/index'; -function determineCost (setType, isFullSet, set) { - if (isBackground) { - return isFullSet ? 3.75 : 1.75; - } - return isFullSet ? 1.25 : 0.5; +/** + * Splits `items.gear.owned.headAccessory_wolfEars` into `items.gear.owned` + * and `headAccessory_wolfEars` + */ +function splitPathItem (path) { + return path.match(/(.+)\.([^.]+)/).splice(1); } /** @@ -43,37 +44,38 @@ function getSet (setType, firstPath) { : content.appearance[setType][itemKey]; if (!item) return invalidSet(); - // Only animal gear sets are unlockable - if (setType === 'gear' && item.gearSet !== 'animal') return invalidSet(); - if (setType === 'gear') { + // Only animal gear sets are unlockable + if (item.gearSet !== 'animal') return invalidSet(); } else { return item.set } } +//Add comment +function determineCost (setType, isFullSet, set) { + if (isBackground) { + return isFullSet ? 3.75 : 1.75; + } + return isFullSet ? 1.25 : 0.5; +} + +//Add comment function alreadyUnlocked (user, path) { return isGear(path) ? get(user, path) !== undefined : get(user, `purchased.${path}`); } +//Add comment function setAsObject (target, key, value) { // Using Object so path[1] won't create an array but an object {path: {1: value}} setWith(target, key, value, Object); } -/** - * Splits `items.gear.owned.headAccessory_wolfEars` into `items.gear.owned` - * and `headAccessory_wolfEars` - */ -function splitPathItem (path) { - return path.match(/(.+)\.([^.]+)/).splice(1); -} - /** * `markModified` does not exist on frontend users */ @@ -81,6 +83,7 @@ function markModified (user, path) { if (user.markModified) user.markModified(path); } +//Add comment function purchaseItem (path, user) { if (isGear(path)) { setAsObject(user, path, true); @@ -93,6 +96,7 @@ function purchaseItem (path, user) { } } +//Add comment function buildResponse ({ purchased, preference, items }, ownsAlready, language) { const response = [ { purchased, preference, items }, From 0896837528d6e7413128954207c008138d19b0e4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Jun 2020 23:58:48 +0200 Subject: [PATCH 3/6] working code and original tests pass --- test/common/ops/unlock.js | 42 ++++- .../common/script/content/appearance/index.js | 16 +- website/common/script/ops/unlock.js | 152 +++++++++++++----- 3 files changed, 155 insertions(+), 55 deletions(-) diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index c927dccb66..22fa847ab4 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -9,7 +9,7 @@ describe('shared.ops.unlock', () => { const unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'; const backgroundUnlockPath = 'background.giant_florals'; const unlockCost = 1.25; - const usersStartingGems = 5; + const usersStartingGems = 50 / 4; beforeEach(() => { user = generateUser(); @@ -48,32 +48,50 @@ describe('shared.ops.unlock', () => { }); it('returns an error when user already owns a full set', done => { + let expectedBalance; + try { unlock(user, { query: { path: unlockPath } }); + expectedBalance = user.balance; unlock(user, { query: { path: unlockPath } }); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('alreadyUnlocked')); - expect(user.balance).to.equal(3.75); + expect(user.balance).to.equal(expectedBalance); done(); } }); it('returns an error when user already owns a full set of gear', done => { + let expectedBalance; + try { unlock(user, { query: { path: unlockGearSetPath } }); + expectedBalance = user.balance; unlock(user, { query: { path: unlockGearSetPath } }); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('alreadyUnlocked')); - expect(user.balance).to.equal(3.75); + expect(user.balance).to.equal(expectedBalance); done(); } }); it('returns an error when user already owns items in a full set and it would be more expensive to buy the entire set', done => { try { - unlock(user, { query: { path: unlockPath.split(',').splice(2).join(',') } }); + // There are 11 shirts in the set, each cost 2 gems, the full set 5 gems + // In order for the full purchase not to be worth, we must own 9 + const partialUnlockPaths = unlockPath.split(','); + unlock(user, { query: { path: partialUnlockPaths[0] } }); + unlock(user, { query: { path: partialUnlockPaths[1] } }); + unlock(user, { query: { path: partialUnlockPaths[2] } }); + unlock(user, { query: { path: partialUnlockPaths[3] } }); + unlock(user, { query: { path: partialUnlockPaths[4] } }); + unlock(user, { query: { path: partialUnlockPaths[5] } }); + unlock(user, { query: { path: partialUnlockPaths[6] } }); + unlock(user, { query: { path: partialUnlockPaths[7] } }); + unlock(user, { query: { path: partialUnlockPaths[8] } }); + unlock(user, { query: { path: unlockPath } }); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); @@ -82,6 +100,22 @@ describe('shared.ops.unlock', () => { } }); + it('does not return an error when user already owns items in a full set and it would not be more expensive to buy the entire set', () => { + // There are 11 shirts in the set, each cost 2 gems, the full set 5 gems + // In order for the full purchase to be worth, we can own already 8 + const partialUnlockPaths = unlockPath.split(','); + unlock(user, { query: { path: partialUnlockPaths[0] } }); + unlock(user, { query: { path: partialUnlockPaths[1] } }); + unlock(user, { query: { path: partialUnlockPaths[2] } }); + unlock(user, { query: { path: partialUnlockPaths[3] } }); + unlock(user, { query: { path: partialUnlockPaths[4] } }); + unlock(user, { query: { path: partialUnlockPaths[5] } }); + unlock(user, { query: { path: partialUnlockPaths[6] } }); + unlock(user, { query: { path: partialUnlockPaths[7] } }); + + unlock(user, { query: { path: unlockPath } }); + }); + it('equips an item already owned', () => { expect(user.purchased.background.giant_florals).to.not.exist; diff --git a/website/common/script/content/appearance/index.js b/website/common/script/content/appearance/index.js index 3ffe67e57c..2c65beb9ce 100644 --- a/website/common/script/content/appearance/index.js +++ b/website/common/script/content/appearance/index.js @@ -23,16 +23,12 @@ forOwn(backgrounds, (value, key) => { const appearances = { - hair, NO appearances.hair.{bangs|base|beard|color|flower|mustache}[key].set.setPrice and .key - shirt: shirts, appearances.shirt[key].set.setPrice and .key - size: sizes, NO, does not have cost - skin: skins, OK, appearances.skin[key].set.setPrice and .key - chair: chairs, NO, does not have cost - background: reorderedBgs, OK appearances.backgroud[key].set.setPrice and .key + hair, + shirt: shirts, + size: sizes, + skin: skins, + chair: chairs, + background: reorderedBgs, }; -^ check with get(path) after validating name because hair are nested for example -^ if item.set exist -> use item.setPrice -^ what about other items in set? - export default appearances; diff --git a/website/common/script/ops/unlock.js b/website/common/script/ops/unlock.js index c318ed600d..26c263a3dd 100644 --- a/website/common/script/ops/unlock.js +++ b/website/common/script/ops/unlock.js @@ -7,6 +7,8 @@ import { removeItemByPath } from './pinnedGearUtils'; import getItemInfo from '../libs/getItemInfo'; import content from '../content/index'; +const incentiveBackgrounds = ['blue', 'green', 'red', 'purple', 'yellow']; + /** * Splits `items.gear.owned.headAccessory_wolfEars` into `items.gear.owned` * and `headAccessory_wolfEars` @@ -23,54 +25,88 @@ function invalidSet () { } /** - * Return the type of the set (gear or one of the appareance sets - see content.appearance). + * Return an item given its path and the type of set + */ +function getItemByPath (path, setType) { + console.log('getting item by path', path, setType); + const itemKey = splitPathItem(path)[1]; + const item = setType === 'gear' + ? content.gear.flat[itemKey] + : content.appearances[setType][itemKey]; + + return item; +} + +/** + * Return the type of the set (gear or one of the appareance sets - see content.appearances). */ function getSetType (firstPath) { if (firstPath.includes('gear.')) return 'gear'; const type = firstPath.split('.')[0]; - if (content.appearance[type]) return type; + if (content.appearances[type]) return type; return invalidSet(); } /** - * Return the set of items to unlock. + * Return the set of items to unlock given the path of the first item in the set. */ function getSet (setType, firstPath) { - const itemKey = splitPathItem(firstPath); - const item = setType === 'gear' - ? content.gear.flat[itemKey] - : content.appearance[setType][itemKey]; - + console.log('getting set from type and firstpath', setType, firstPath); + const item = getItemByPath(firstPath, setType); + console.log('item', item); if (!item) return invalidSet(); - if (setType === 'gear') { // Only animal gear sets are unlockable if (item.gearSet !== 'animal') return invalidSet(); - } else { - return item.set + // Since each type of gear has only one purchasable set (the animal set) + // we get all items with the same type and gearSet === 'animal' + const items = []; + const paths = []; + + Object.keys(content.gear.tree[item.type][item.klass]).forEach(possibleItemKey => { + const possibleItem = content.gear.tree[item.type][item.klass][possibleItemKey]; + if (possibleItem && possibleItem.gearSet === 'animal') { + items.push(possibleItem); + paths.push(`items.gear.owned.${possibleItem.key}`); + } + }); + + return { items, paths, set: { setPrice: 5 } }; } + console.log('not a gear set'); + + const { set } = item; + if (!set || set.setPrice === 0) return invalidSet(); + + const items = []; + const paths = []; + + Object.keys(content.appearances[setType]).forEach(possibleItemKey => { + const possibleItem = content.appearances[setType][possibleItemKey]; + if (possibleItem && possibleItem.set && possibleItem.set.key === set.key) { + items.push(possibleItem); + paths.push(`${setType}.${possibleItem.key}`); + } + }); + + return { items, paths, set }; } -//Add comment -function determineCost (setType, isFullSet, set) { - if (isBackground) { - return isFullSet ? 3.75 : 1.75; - } - return isFullSet ? 1.25 : 0.5; -} +/** + * checks if the user has already unlocked this item + */ +function alreadyUnlocked (user, setType, path) { + const isGear = setType === 'gear'; -//Add comment -function alreadyUnlocked (user, path) { - return isGear(path) + return isGear ? get(user, path) !== undefined : get(user, `purchased.${path}`); } -//Add comment function setAsObject (target, key, value) { // Using Object so path[1] won't create an array but an object {path: {1: value}} setWith(target, key, value, Object); @@ -83,9 +119,10 @@ function markModified (user, path) { if (user.markModified) user.markModified(path); } -//Add comment -function purchaseItem (path, user) { - if (isGear(path)) { +function purchaseItem (path, setType, user) { + const isGear = setType === 'gear'; + + if (isGear) { setAsObject(user, path, true); const itemName = splitPathItem(path)[1]; removeItemByPath(user, `gear.flat.${itemName}`); @@ -96,7 +133,13 @@ function purchaseItem (path, user) { } } -//Add comment +function getIndividualItemPrice (setType, item) { + if (setType === 'gear') return 0.5; + + if (!item.price || item.price === 0) return invalidSet(); + return item.price / 4; +} + function buildResponse ({ purchased, preference, items }, ownsAlready, language) { const response = [ { purchased, preference, items }, @@ -117,26 +160,54 @@ export default function unlock (user, req = {}, analytics) { const setPaths = path.split(','); const isFullSet = setPaths.length > 1; - // We take the first path and use it to get the set, - // The passed paths are not used anymore after this point const firstPath = setPaths[0]; - const setType = getSetType(firstPath); - const set = getSet(setType, firstPath); - const cost = determineCost(setType, isFullSet, set); - let unlockedAlready; + const setType = getSetType(firstPath); + const isBackground = setType === 'background'; + + // We take the first path and use it to get the set, + // The passed paths are not used anymore after this point for full sets + const { set, items, paths } = getSet(setType, firstPath); + + let cost; + let unlockedAlready = false; + + console.log('isFullSet', isFullSet, 'setType', setType); if (isFullSet) { - const alreadyUnlockedItems = setPaths.filter(p => alreadyUnlocked(user, p)).length; - const totalItems = setPaths.length; + console.log('fullset', {items}, {paths}, {set}); + cost = set.setPrice / 4; + + // all items in a set have the same price + const individualPrice = getIndividualItemPrice(setType, items[0]); + + const alreadyUnlockedItems = paths + .filter(itemPath => alreadyUnlocked(user, setType, itemPath)).length; + const totalItems = items.length; + console.log('totalItems', totalItems, 'alreadyUnlockedItems', alreadyUnlockedItems) if (alreadyUnlockedItems === totalItems) { throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); - // TODO Different pull request - // } else if ((totalItems - alreadyOwnedItems) < 3) { - // throw new NotAuthorized(i18n.t('alreadyUnlockedPart', req.language)); + } else if ((totalItems - alreadyUnlockedItems) * individualPrice < cost) { + throw new NotAuthorized(i18n.t('alreadyUnlockedPart', req.language)); } } else { - unlockedAlready = alreadyUnlocked(user, path); + const item = getItemByPath(firstPath, setType); + if (!item || !items.includes(item) || !paths.includes(firstPath)) { + return invalidSet(); + } + + cost = getIndividualItemPrice(setType, item); + + unlockedAlready = alreadyUnlocked(user, setType, firstPath); + + // Since only an item is being unlocked here, + // remove all the other items from the set + items.splice(0, items.length); + paths.splice(0, paths.length); + + // Only keep the item being unlocked + items.push(item); + paths.push(firstPath); } if (isBackground && !unlockedAlready @@ -149,7 +220,7 @@ export default function unlock (user, req = {}, analytics) { } if (isFullSet) { - setPaths.forEach(pathPart => purchaseItem(pathPart, user)); + paths.forEach(pathPart => purchaseItem(pathPart, setType, user)); } else { const [key, value] = splitPathItem(path); @@ -157,9 +228,8 @@ export default function unlock (user, req = {}, analytics) { const unsetBackground = isBackground && value === user.preferences.background; setAsObject(user, `preferences.${key}`, unsetBackground ? '' : value); } else { - purchaseItem(path, user); + purchaseItem(paths[0], setType, user); - // @TODO: Test and check test coverage if (isBackground) { const backgroundContent = content.backgroundsFlat[value]; const itemInfo = getItemInfo(user, 'background', backgroundContent); From 4c653aa5116e6f0c84bb0b3de8570f76942f2cee Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Jun 2020 17:56:06 +0200 Subject: [PATCH 4/6] unlock: minor fixes and increase tests coverage --- test/common/ops/unlock.js | 126 ++++++++++++++++++++++++---- website/common/locales/en/npc.json | 1 + website/common/script/ops/unlock.js | 49 ++++++----- 3 files changed, 139 insertions(+), 37 deletions(-) diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index 22fa847ab4..e54b368a77 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -2,13 +2,14 @@ import unlock from '../../../website/common/script/ops/unlock'; import i18n from '../../../website/common/script/i18n'; import { generateUser } from '../../helpers/common.helper'; import { NotAuthorized, BadRequest } from '../../../website/common/script/libs/errors'; +import get from 'lodash/get'; -describe('shared.ops.unlock', () => { +describe.only('shared.ops.unlock', () => { let user; const unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; const unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'; const backgroundUnlockPath = 'background.giant_florals'; - const unlockCost = 1.25; + const backgroundSetUnlockPath = 'background.archery_range,background.giant_florals,background.rainbows_end'; const usersStartingGems = 50 / 4; beforeEach(() => { @@ -77,6 +78,56 @@ describe('shared.ops.unlock', () => { } }); + it('returns an error if an item does not exists', done => { + try { + unlock(user, { query: { path: 'background.invalid_background' } }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUnlockSet')); + done(); + } + }); + + it('returns an error if there are items from multiple sets', done => { + try { + unlock(user, { query: { path: 'shirt.convict,skin.0ff591' } }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUnlockSet')); + done(); + } + }); + + it('returns an error if gear is not from the animal set', done => { + try { + unlock(user, { query: { path: 'items.gear.owned.back_mystery_202004' } }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUnlockSet')); + done(); + } + }); + + it('returns an error if the item is free', done => { + try { + unlock(user, { query: { path: 'shirt.black' } }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUnlockSet')); + done(); + } + }); + + it('returns an error if an item does not belong to a set (appearances)', done => { + try { + unlock(user, { query: { path: 'shirt.pink' } }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUnlockSet')); + done(); + } + }); + it('returns an error when user already owns items in a full set and it would be more expensive to buy the entire set', done => { try { // There are 11 shirts in the set, each cost 2 gems, the full set 5 gems @@ -141,31 +192,78 @@ describe('shared.ops.unlock', () => { expect(user.preferences.background).to.equal(''); }); - it('unlocks a full set', () => { + it('unlocks a full set of appearance items', () => { + const initialShirts = Object.keys(user.purchased.shirt).length; const [, message] = unlock(user, { query: { path: unlockPath } }); expect(message).to.equal(i18n.t('unlocked')); - expect(user.purchased.shirt.convict).to.be.true; + const individualPaths = unlockPath.split(','); + individualPaths.forEach(path => { + expect(get(user.purchased, path)).to.be.true; + }); + expect(Object.keys(user.purchased.shirt).length) + .to.equal(initialShirts + individualPaths.length); + expect(user.balance).to.equal(usersStartingGems - 1.25); }); it('unlocks a full set of gear', () => { + const initialGear = Object.keys(user.items.gear.owned).length; const [, message] = unlock(user, { query: { path: unlockGearSetPath } }); expect(message).to.equal(i18n.t('unlocked')); - expect(user.items.gear.owned.headAccessory_special_wolfEars).to.be.true; + + const individualPaths = unlockGearSetPath.split(','); + individualPaths.forEach(path => { + expect(get(user, path)).to.be.true; + }); + expect(Object.keys(user.items.gear.owned).length) + .to.equal(initialGear + individualPaths.length); + expect(user.balance).to.equal(usersStartingGems - 1.25); }); - it('unlocks an item', () => { + it('unlocks a full set of backgrounds', () => { + const initialBackgrounds = Object.keys(user.purchased.background).length; + const [, message] = unlock(user, { query: { path: backgroundSetUnlockPath } }); + + expect(message).to.equal(i18n.t('unlocked')); + const individualPaths = backgroundSetUnlockPath.split(','); + individualPaths.forEach(path => { + expect(get(user.purchased, path)).to.be.true; + }); + expect(Object.keys(user.purchased.background).length) + .to.equal(initialBackgrounds + individualPaths.length); + expect(user.balance).to.equal(usersStartingGems - 3.75); + }); + + it('unlocks an item (appearance)', () => { + const path = unlockPath.split(',')[0]; + const initialShirts = Object.keys(user.purchased.shirt).length; + const [, message] = unlock(user, { query: { path } }); + + expect(message).to.equal(i18n.t('unlocked')); + expect(Object.keys(user.purchased.shirt).length).to.equal(initialShirts + 1); + expect(get(user.purchased, path)).to.be.true; + expect(user.balance).to.equal(usersStartingGems - 0.5); + }); + + it('unlocks an item (gear)', () => { + const path = unlockGearSetPath.split(',')[0]; + const initialGear = Object.keys(user.items.gear.owned).length; + const [, message] = unlock(user, { query: { path } }); + + expect(message).to.equal(i18n.t('unlocked')); + expect(Object.keys(user.items.gear.owned).length).to.equal(initialGear + 1); + expect(get(user, path)).to.be.true; + expect(user.balance).to.equal(usersStartingGems - 0.5); + }); + + it('unlocks an item (background)', () => { + const initialBackgrounds = Object.keys(user.purchased.background).length; const [, message] = unlock(user, { query: { path: backgroundUnlockPath } }); expect(message).to.equal(i18n.t('unlocked')); - expect(user.purchased.background.giant_florals).to.be.true; - }); - - it('reduces a user\'s balance', () => { - const [, message] = unlock(user, { query: { path: unlockPath } }); - - expect(message).to.equal(i18n.t('unlocked')); - expect(user.balance).to.equal(usersStartingGems - unlockCost); + expect(Object.keys(user.purchased.background).length).to.equal(initialBackgrounds + 1); + expect(get(user.purchased, backgroundUnlockPath)).to.be.true; + expect(user.balance).to.equal(usersStartingGems - 1.75); }); }); diff --git a/website/common/locales/en/npc.json b/website/common/locales/en/npc.json index 71217f5880..9197d0ef2d 100644 --- a/website/common/locales/en/npc.json +++ b/website/common/locales/en/npc.json @@ -99,6 +99,7 @@ "unlocked": "Items have been unlocked", "alreadyUnlocked": "Full set already unlocked.", "alreadyUnlockedPart": "Full set already partially unlocked.", + "invalidUnlockSet": "This set of items is invalid and cannot be unlocked.", "invalidQuantity": "Quantity to purchase must be a positive whole number.", "USD": "(USD)", diff --git a/website/common/script/ops/unlock.js b/website/common/script/ops/unlock.js index 26c263a3dd..da2dd78baf 100644 --- a/website/common/script/ops/unlock.js +++ b/website/common/script/ops/unlock.js @@ -20,15 +20,14 @@ function splitPathItem (path) { /** * Throw an error when the provided set isn't valid. */ -function invalidSet () { - throw new BadRequest("invalid set string"); +function invalidSet (req) { + throw new BadRequest(i18n.t('invalidUnlockSet', req.language)); } /** * Return an item given its path and the type of set */ function getItemByPath (path, setType) { - console.log('getting item by path', path, setType); const itemKey = splitPathItem(path)[1]; const item = setType === 'gear' ? content.gear.flat[itemKey] @@ -40,27 +39,25 @@ function getItemByPath (path, setType) { /** * Return the type of the set (gear or one of the appareance sets - see content.appearances). */ -function getSetType (firstPath) { +function getSetType (firstPath, req) { if (firstPath.includes('gear.')) return 'gear'; const type = firstPath.split('.')[0]; if (content.appearances[type]) return type; - return invalidSet(); + return invalidSet(req); } /** * Return the set of items to unlock given the path of the first item in the set. */ -function getSet (setType, firstPath) { - console.log('getting set from type and firstpath', setType, firstPath); +function getSet (setType, firstPath, req) { const item = getItemByPath(firstPath, setType); - console.log('item', item); - if (!item) return invalidSet(); + if (!item) return invalidSet(req); if (setType === 'gear') { // Only animal gear sets are unlockable - if (item.gearSet !== 'animal') return invalidSet(); + if (item.gearSet !== 'animal') return invalidSet(req); // Since each type of gear has only one purchasable set (the animal set) // we get all items with the same type and gearSet === 'animal' @@ -77,10 +74,9 @@ function getSet (setType, firstPath) { return { items, paths, set: { setPrice: 5 } }; } - console.log('not a gear set'); const { set } = item; - if (!set || set.setPrice === 0) return invalidSet(); + if (!set || set.setPrice === 0) return invalidSet(req); const items = []; const paths = []; @@ -119,6 +115,9 @@ function markModified (user, path) { if (user.markModified) user.markModified(path); } +/** + * Purchase an item from a set for a given user + */ function purchaseItem (path, setType, user) { const isGear = setType === 'gear'; @@ -133,10 +132,13 @@ function purchaseItem (path, setType, user) { } } -function getIndividualItemPrice (setType, item) { +/** + * Return the price of a single item in a set + */ +function getIndividualItemPrice (setType, item, req) { if (setType === 'gear') return 0.5; - if (!item.price || item.price === 0) return invalidSet(); + if (!item.price || item.price === 0) return invalidSet(req); return item.price / 4; } @@ -162,29 +164,30 @@ export default function unlock (user, req = {}, analytics) { const isFullSet = setPaths.length > 1; const firstPath = setPaths[0]; - const setType = getSetType(firstPath); + const setType = getSetType(firstPath, req); const isBackground = setType === 'background'; // We take the first path and use it to get the set, // The passed paths are not used anymore after this point for full sets - const { set, items, paths } = getSet(setType, firstPath); + const { set, items, paths } = getSet(setType, firstPath, req); let cost; let unlockedAlready = false; - console.log('isFullSet', isFullSet, 'setType', setType); - if (isFullSet) { - console.log('fullset', {items}, {paths}, {set}); + // Make sure the paths as parameters match the ones from the set + if (setPaths.length !== paths.length) return invalidSet(req); + if (!setPaths.every(setPath => paths.includes(setPath))) return invalidSet(req); + cost = set.setPrice / 4; // all items in a set have the same price - const individualPrice = getIndividualItemPrice(setType, items[0]); + const individualPrice = getIndividualItemPrice(setType, items[0], req); const alreadyUnlockedItems = paths .filter(itemPath => alreadyUnlocked(user, setType, itemPath)).length; const totalItems = items.length; - console.log('totalItems', totalItems, 'alreadyUnlockedItems', alreadyUnlockedItems) + if (alreadyUnlockedItems === totalItems) { throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); } else if ((totalItems - alreadyUnlockedItems) * individualPrice < cost) { @@ -193,10 +196,10 @@ export default function unlock (user, req = {}, analytics) { } else { const item = getItemByPath(firstPath, setType); if (!item || !items.includes(item) || !paths.includes(firstPath)) { - return invalidSet(); + return invalidSet(req); } - cost = getIndividualItemPrice(setType, item); + cost = getIndividualItemPrice(setType, item, req); unlockedAlready = alreadyUnlocked(user, setType, firstPath); From 08b2be684bda7202d2d0126484a88a93f14391f9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Jun 2020 18:00:58 +0200 Subject: [PATCH 5/6] improve error message --- website/common/locales/en/npc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/common/locales/en/npc.json b/website/common/locales/en/npc.json index 9197d0ef2d..fe45b8a487 100644 --- a/website/common/locales/en/npc.json +++ b/website/common/locales/en/npc.json @@ -98,7 +98,7 @@ "pathRequired": "Path string is required", "unlocked": "Items have been unlocked", "alreadyUnlocked": "Full set already unlocked.", - "alreadyUnlockedPart": "Full set already partially unlocked.", + "alreadyUnlockedPart": "Full set already partially unlocked. It is cheaper to buy the remaining items individually.", "invalidUnlockSet": "This set of items is invalid and cannot be unlocked.", "invalidQuantity": "Quantity to purchase must be a positive whole number.", From d63aafc7f12df55f5649a45d8b5a7f778b6465be Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Jun 2020 18:03:19 +0200 Subject: [PATCH 6/6] fix lint --- test/common/ops/unlock.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index e54b368a77..baf1408515 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -1,10 +1,10 @@ +import get from 'lodash/get'; import unlock from '../../../website/common/script/ops/unlock'; import i18n from '../../../website/common/script/i18n'; import { generateUser } from '../../helpers/common.helper'; import { NotAuthorized, BadRequest } from '../../../website/common/script/libs/errors'; -import get from 'lodash/get'; -describe.only('shared.ops.unlock', () => { +describe('shared.ops.unlock', () => { let user; const unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; const unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars';