mirror of
https://github.com/sudoxnym/habitica.git
synced 2026-07-13 15:38:59 +00:00
Merge branch 'unlock-fixes' into release
This commit is contained in:
commit
7e4c8938e5
3 changed files with 302 additions and 52 deletions
|
|
@ -1,3 +1,4 @@
|
|||
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';
|
||||
|
|
@ -8,8 +9,8 @@ describe('shared.ops.unlock', () => {
|
|||
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 usersStartingGems = 5;
|
||||
const backgroundSetUnlockPath = 'background.archery_range,background.giant_florals,background.rainbows_end';
|
||||
const usersStartingGems = 50 / 4;
|
||||
|
||||
beforeEach(() => {
|
||||
user = generateUser();
|
||||
|
|
@ -48,32 +49,100 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
xit('returns an error when user already owns items in a full set', done => {
|
||||
it('returns an error if an item does not exists', done => {
|
||||
try {
|
||||
unlock(user, { query: { path: unlockPath.split(',').splice(2).join(',') } });
|
||||
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
|
||||
// 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 +151,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;
|
||||
|
||||
|
|
@ -107,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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@
|
|||
"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.",
|
||||
|
||||
"USD": "(USD)",
|
||||
|
|
|
|||
|
|
@ -9,19 +9,96 @@ import content from '../content/index';
|
|||
|
||||
const incentiveBackgrounds = ['blue', 'green', 'red', 'purple', 'yellow'];
|
||||
|
||||
function determineCost (isBackground, isFullSet) {
|
||||
if (isBackground) {
|
||||
return isFullSet ? 3.75 : 1.75;
|
||||
/**
|
||||
* Splits `items.gear.owned.headAccessory_wolfEars` into `items.gear.owned`
|
||||
* and `headAccessory_wolfEars`
|
||||
*/
|
||||
function splitPathItem (path) {
|
||||
return path.match(/(.+)\.([^.]+)/).splice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error when the provided set isn't valid.
|
||||
*/
|
||||
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) {
|
||||
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, req) {
|
||||
if (firstPath.includes('gear.')) return 'gear';
|
||||
|
||||
const type = firstPath.split('.')[0];
|
||||
if (content.appearances[type]) return type;
|
||||
|
||||
return invalidSet(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the set of items to unlock given the path of the first item in the set.
|
||||
*/
|
||||
function getSet (setType, firstPath, req) {
|
||||
const item = getItemByPath(firstPath, setType);
|
||||
if (!item) return invalidSet(req);
|
||||
|
||||
if (setType === 'gear') {
|
||||
// Only animal gear sets are unlockable
|
||||
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'
|
||||
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 } };
|
||||
}
|
||||
return isFullSet ? 1.25 : 0.5;
|
||||
|
||||
const { set } = item;
|
||||
if (!set || set.setPrice === 0) return invalidSet(req);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function isGear (path) {
|
||||
return path.includes('gear.');
|
||||
}
|
||||
/**
|
||||
* checks if the user has already unlocked this item
|
||||
*/
|
||||
function alreadyUnlocked (user, setType, path) {
|
||||
const isGear = setType === 'gear';
|
||||
|
||||
function alreadyUnlocked (user, path) {
|
||||
return isGear(path)
|
||||
return isGear
|
||||
? get(user, path) !== undefined
|
||||
: get(user, `purchased.${path}`);
|
||||
}
|
||||
|
|
@ -31,14 +108,6 @@ function setAsObject (target, key, 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
|
||||
*/
|
||||
|
|
@ -46,8 +115,13 @@ function markModified (user, path) {
|
|||
if (user.markModified) user.markModified(path);
|
||||
}
|
||||
|
||||
function purchaseItem (path, user) {
|
||||
if (isGear(path)) {
|
||||
/**
|
||||
* Purchase an item from a set for a given user
|
||||
*/
|
||||
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}`);
|
||||
|
|
@ -58,6 +132,16 @@ function purchaseItem (path, user) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(req);
|
||||
return item.price / 4;
|
||||
}
|
||||
|
||||
function buildResponse ({ purchased, preference, items }, ownsAlready, language) {
|
||||
const response = [
|
||||
{ purchased, preference, items },
|
||||
|
|
@ -68,6 +152,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,24 +160,57 @@ 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(',');
|
||||
let unlockedAlready;
|
||||
const isFullSet = setPaths.length > 1;
|
||||
const firstPath = setPaths[0];
|
||||
|
||||
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, req);
|
||||
|
||||
let cost;
|
||||
let unlockedAlready = false;
|
||||
|
||||
if (isFullSet) {
|
||||
const alreadyUnlockedItems = setPaths.filter(p => alreadyUnlocked(user, p)).length;
|
||||
const totalItems = setPaths.length;
|
||||
// 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], req);
|
||||
|
||||
const alreadyUnlockedItems = paths
|
||||
.filter(itemPath => alreadyUnlocked(user, setType, itemPath)).length;
|
||||
const totalItems = items.length;
|
||||
|
||||
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(req);
|
||||
}
|
||||
|
||||
cost = getIndividualItemPrice(setType, item, req);
|
||||
|
||||
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
|
||||
|
|
@ -105,7 +223,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);
|
||||
|
||||
|
|
@ -113,9 +231,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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue