Hourglass Quest (#11325)

* feat(content): Hourglass Quest

* fix(hourglasses): NaN from undefined

* fix(quests): sanity check for negative scrolls

* fix(hourglasses): don't show quantity selection for binary items

* fix(route): validate number, use body not params

* test(timetrav): add quest tests
This commit is contained in:
Sabe Jones
2019-08-29 15:22:12 -04:00
committed by GitHub
parent 9077290ea3
commit fc841d0ad4
61 changed files with 209 additions and 42 deletions

View File

@@ -14,7 +14,7 @@ describe('POST /user/purchase-hourglass/:type/:key', () => {
// More tests in common code unit tests
it('buys a hourglass pet', async () => {
it('buys an hourglass pet', async () => {
let response = await user.post('/user/purchase-hourglass/pets/MantisShrimp-Base');
await user.sync();
@@ -22,4 +22,22 @@ describe('POST /user/purchase-hourglass/:type/:key', () => {
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.items.pets['MantisShrimp-Base']).to.eql(5);
});
it('buys an hourglass quest', async () => {
let response = await user.post('/user/purchase-hourglass/quests/robot');
await user.sync();
expect(response.message).to.eql(t('hourglassPurchase'));
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.items.quests.robot).to.eql(1);
});
it('buys multiple hourglass quests', async () => {
let response = await user.post('/user/purchase-hourglass/quests/robot', {quantity: 2});
await user.sync();
expect(response.message).to.eql(t('hourglassPurchase'));
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.quests.robot).to.eql(2);
});
});

View File

@@ -306,7 +306,7 @@
const hideAmountSelectionForPurchaseTypes = [
'gear', 'backgrounds', 'mystery_set', 'card',
'rebirth_orb', 'fortify', 'armoire', 'keys',
'debuffPotion',
'debuffPotion', 'pets', 'mounts',
];
export default {

View File

@@ -23,7 +23,7 @@
strong {{ $t('howManyToBuy') }}
.box
input(type='number', min='0', step='1', v-model.number='selectedAmountToBuy')
span.svg-icon.inline.icon-32(aria-hidden="true", v-html="(priceType === 'gems') ? icons.gem : icons.gold")
span.svg-icon.inline.icon-32(aria-hidden="true", v-html="currencyIcon")
span.value(:class="priceType") {{ item.value }}
button.btn.btn-primary(
@@ -44,6 +44,7 @@
div.clearfix(slot="modal-footer")
span.balance.float-left {{ $t('yourBalance') }}
balanceInfo(
:withHourglass="priceType === 'hourglasses'",
:currencyNeeded="priceType",
:amountNeeded="item.value"
).float-right
@@ -202,6 +203,7 @@
import svgGem from 'assets/svg/gem.svg';
import svgPin from 'assets/svg/pin.svg';
import svgExperience from 'assets/svg/experience.svg';
import svgHourglasses from 'assets/svg/hourglass.svg';
import BalanceInfo from '../balanceInfo.vue';
import currencyMixin from '../_currencyMixin';
@@ -229,6 +231,7 @@
gem: svgGem,
pin: svgPin,
experience: svgExperience,
hourglass: svgHourglasses,
}),
isPinned: false,
@@ -258,6 +261,11 @@
return this.item.notes;
}
},
currencyIcon () {
if (this.priceType === 'gold') return this.icons.gold;
if (this.priceType === 'hourglasses') return this.icons.hourglass;
return this.icons.gem;
},
},
methods: {
onChange ($event) {

View File

@@ -68,9 +68,13 @@
:emptyItem="false",
@click="selectItemToBuy(ctx.item)"
)
span(slot="popoverContent", slot-scope="ctx")
span(slot="popoverContent", slot-scope="ctx", v-if="category !== 'quests'")
div
h4.popover-content-title {{ ctx.item.text }}
span(slot="popoverContent", slot-scope="ctx", v-if="category === 'quests'")
div.questPopover
h4.popover-content-title {{ item.text }}
questInfo(:quest="item")
template(slot="itemBadge", slot-scope="ctx")
span.badge.badge-pill.badge-item.badge-svg(
@@ -79,6 +83,18 @@
@click.prevent.stop="togglePinned(ctx.item)"
)
span.svg-icon.inline.icon-12.color(v-html="icons.pin")
buyQuestModal(
:item="selectedItemToBuy || {}",
:priceType="selectedItemToBuy ? selectedItemToBuy.currency : ''",
:withPin="true",
@change="resetItemToBuy($event)",
)
template(slot="item", slot-scope="ctx")
item.flat(
:item="ctx.item",
:itemContentClass="ctx.item.class",
:showPopover="false"
)
</template>
<style lang="scss">
@@ -225,8 +241,10 @@
import ItemRows from 'client/components/ui/itemRows';
import toggleSwitch from 'client/components/ui/toggleSwitch';
import Avatar from 'client/components/avatar';
import QuestInfo from '../quests/questInfo.vue';
import BuyModal from '../buyModal.vue';
import BuyQuestModal from '../quests/buyQuestModal.vue';
import svgPin from 'assets/svg/pin.svg';
import svgHourglass from 'assets/svg/hourglass.svg';
@@ -250,9 +268,11 @@
CountBadge,
ItemRows,
toggleSwitch,
QuestInfo,
Avatar,
BuyModal,
BuyQuestModal,
},
watch: {
searchText: _throttle(function throttleSearch () {
@@ -274,6 +294,8 @@
sortItemsBy: ['AZ', 'sortByNumber'],
selectedSortItemsBy: 'AZ',
selectedItemToBuy: null,
hidePinned: false,
backgroundUpdate: new Date(),
@@ -303,11 +325,11 @@
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let normalGroups = _filter(apiCategories, (c) => {
return c.identifier === 'mounts' || c.identifier === 'pets';
return c.identifier === 'mounts' || c.identifier === 'pets' || c.identifier === 'quests';
});
let setGroups = _filter(apiCategories, (c) => {
return c.identifier !== 'mounts' && c.identifier !== 'pets';
return c.identifier !== 'mounts' && c.identifier !== 'pets' && c.identifier !== 'quests';
});
let setCategory = {
@@ -375,7 +397,18 @@
return _groupBy(entries, 'group');
},
selectItemToBuy (item) {
this.$root.$emit('buyModal::showItem', item);
if (item.purchaseType === 'quests') {
this.selectedItemToBuy = item;
this.$root.$emit('bv::show::modal', 'buy-quest-modal');
} else {
this.$root.$emit('buyModal::showItem', item);
}
},
resetItemToBuy ($event) {
if (!$event) {
this.selectedItemToBuy = null;
}
},
},
created () {

View File

@@ -112,12 +112,13 @@ export function purchaseMysterySet (store, params) {
}
export function purchaseHourglassItem (store, params) {
const quantity = params.quantity || 1;
const user = store.state.user.data;
let opResult = hourglassPurchaseOp(user, {params});
let opResult = hourglassPurchaseOp(user, {params, quantity});
return {
result: opResult,
httpCall: axios.post(`/api/v4/user/purchase-hourglass/${params.type}/${params.key}`),
httpCall: axios.post(`/api/v4/user/purchase-hourglass/${params.type}/${params.key}`, {quantity}),
};
}

View File

@@ -253,6 +253,10 @@
"questEggDolphinMountText": "Dolphin",
"questEggDolphinAdjective": "a chipper",
"questEggRobotText": "Robot",
"questEggRobotMountText": "Robot",
"questEggRobotAdjective": "a futuristic",
"eggNotes": "Find a hatching potion to pour on this egg, and it will hatch into <%= eggAdjective(locale) %> <%= eggText(locale) %>.",
"hatchingPotionBase": "Base",

View File

@@ -763,5 +763,14 @@
"questSilverCollectMoonRunes": "Moon Runes",
"questSilverCollectSilverIngots": "Silver Ingots",
"questSilverDropSilverPotion": "Silver Hatching Potion",
"questSilverUnlockText": "Unlocks purchasable Silver hatching potions in the Market"
"questSilverUnlockText": "Unlocks purchasable Silver hatching potions in the Market",
"questRobotText": "Mysterious Mechanical Marvels!",
"questRobotNotes": "At Max Capacity labs, @Rev is putting the finishing touches on their newest invention, a robotic Accountability Buddy, when a strange metal vehicle suddenly appears in a plume of smoke, inches from the robots Fluctuation Detector! Its occupants, two strange figures dressed in silver, emerge and remove their space helmets, revealing themselves as @FolleMente and @McCoyly.<br><br>“I hypothesize that there was an anomaly in our productivity implementation,” @FolleMente says sheepishly.<br><br>@McCoyly crosses her arms. “That means they neglected to complete their Dailies, which I postulate led to the disintegration of our Productivity Stabilizer. Its an essential component to time travel that needs consistency to work properly. Our accomplishments power our movement through time and space! I dont have time to explain further, @Rev. Youll discover it in 37 years, or perhaps your allies the Mysterious Time Travelers can fill you in. For now, can you help us fix our time machine?”",
"questRobotCompletion": "As @Rev and the Accountability Buddy place the last bolt in place, the time machine buzzes to life. @FolleMente and @McCoyly jump aboard. “Thanks for the assist! Well see you in the future! By the way, these should help you with your next invention!” With that, the time travelers disappear, but left behind in the wreckage of the old Productivity Stabilizer are three clockwork eggs. Perhaps these will be the crucial components for a new production line of Accountability Buddies!",
"questRobotCollectBolts": "Bolts",
"questRobotCollectGears": "Gears",
"questRobotCollectSprings": "Springs",
"questRobotDropRobotEgg": "Robot (Egg)",
"questRobotUnlockText": "Unlocks purchasable Robot Eggs in the Market"
}

View File

@@ -386,6 +386,12 @@ let quests = {
adjective: t('questEggDolphinAdjective'),
canBuy: hasQuestAchievementFunction('dolphin'),
},
Robot: {
text: t('questEggRobotText'),
mountText: t('questEggRobotMountText'),
adjective: t('questEggRobotAdjective'),
canBuy: hasQuestAchievementFunction('robot'),
},
};
applyEggDefaults(drops, {

View File

@@ -3458,6 +3458,50 @@ let quests = {
unlock: t('questSilverUnlockText'),
},
},
robot: {
text: t('questRobotText'),
notes: t('questRobotNotes'),
completion: t('questRobotCompletion'),
value: 1,
category: 'timeTravelers',
canBuy () {
return false;
},
collect: {
bolt: {
text: t('questRobotCollectBolts'),
count: 15,
},
gear: {
text: t('questRobotCollectGears'),
count: 10,
},
spring: {
text: t('questRobotCollectSprings'),
count: 10,
},
},
drop: {
items: [
{
type: 'eggs',
key: 'Robot',
text: t('questRobotDropRobotEgg'),
}, {
type: 'eggs',
key: 'Robot',
text: t('questRobotDropRobotEgg'),
}, {
type: 'eggs',
key: 'Robot',
text: t('questRobotDropRobotEgg'),
},
],
gp: 40,
exp: 75,
unlock: t('questRobotUnlockText'),
},
},
};
each(quests, (v, key) => {

View File

@@ -132,7 +132,6 @@ module.exports = function getItemInfo (user, type, item, officialPinnedItems, la
notes: item.notes(language),
group: item.group,
value: item.goldValue ? item.goldValue : item.value,
currency: item.goldValue ? 'gold' : 'gems',
locked,
previous: content.quests[item.previous] ? content.quests[item.previous].text(language) : null,
unlockCondition: item.unlockCondition,
@@ -150,6 +149,13 @@ module.exports = function getItemInfo (user, type, item, officialPinnedItems, la
path: `quests.${item.key}`,
pinType: 'quests',
};
if (item.goldValue) {
itemInfo.currency = 'gold';
} else if (item.category === 'timeTravelers') {
itemInfo.currency = 'hourglasses';
} else {
itemInfo.currency = 'gems';
}
break;
case 'timeTravelers':

View File

@@ -333,6 +333,20 @@ shops.getTimeTravelersCategories = function getTimeTravelersCategories (user, la
let stable = {pets: 'Pet-', mounts: 'Mount_Icon_'};
let officialPinnedItems = getOfficialPinnedItems(user);
let questCategory = {
identifier: 'quests',
text: i18n.t('quests', language),
items: [],
};
for (let key in content.quests) {
if (content.quests[key].category === 'timeTravelers') {
let item = getItemInfo(user, 'quests', content.quests[key], officialPinnedItems, language);
questCategory.items.push(item);
}
}
categories.push(questCategory);
for (let type in stable) {
if (stable.hasOwnProperty(type)) {
let category = {

View File

@@ -19,8 +19,10 @@ import {BuyQuestWithGemOperation} from './buyQuestGem';
// @TODO: when we are sure buy is the only function used, let's move the buy files to a folder
module.exports = function buy (user, req = {}, analytics) {
module.exports = function buy (user, req = {}, analytics, options = {quantity: 1, hourglass: false}) {
let key = get(req, 'params.key');
const hourglass = options.hourglass;
const quantity = options.quantity;
if (!key) throw new BadRequest(errorMessage('missingKeyParam'));
// @TODO: Slowly remove the need for key and use type instead
@@ -54,9 +56,13 @@ module.exports = function buy (user, req = {}, analytics) {
break;
}
case 'quests': {
const buyOp = new BuyQuestWithGemOperation(user, req, analytics);
if (hourglass) {
buyRes = hourglassPurchase(user, req, analytics, quantity);
} else {
const buyOp = new BuyQuestWithGemOperation(user, req, analytics);
buyRes = buyOp.purchase();
buyRes = buyOp.purchase();
}
break;
}
case 'eggs':

View File

@@ -9,39 +9,52 @@ import {
} from '../../libs/errors';
import errorMessage from '../../libs/errorMessage';
module.exports = function purchaseHourglass (user, req = {}, analytics) {
module.exports = function purchaseHourglass (user, req = {}, analytics, quantity = 1) {
let key = get(req, 'params.key');
if (!key) throw new BadRequest(errorMessage('missingKeyParam'));
let type = get(req, 'params.type');
if (!type) throw new BadRequest(errorMessage('missingTypeParam'));
if (!content.timeTravelStable[type]) {
throw new NotAuthorized(i18n.t('typeNotAllowedHourglass', {allowedTypes: keys(content.timeTravelStable).toString()}, req.language));
}
if (type === 'quests') {
if (!content.quests[key] || content.quests[key].category !== 'timeTravelers') throw new NotAuthorized(i18n.t('notAllowedHourglass', req.language));
if (user.purchased.plan.consecutive.trinkets < quantity) {
throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language));
}
if (!includes(keys(content.timeTravelStable[type]), key)) {
throw new NotAuthorized(i18n.t('notAllowedHourglass', req.language));
}
if (!user.items.quests[key] || user.items.quests[key] < 0) user.items.quests[key] = 0;
user.items.quests[key] += quantity;
user.purchased.plan.consecutive.trinkets -= quantity;
if (user.items[type][key]) {
throw new NotAuthorized(i18n.t(`${type}AlreadyOwned`, req.language));
}
if (user.markModified) user.markModified('items.quests');
} else {
if (!content.timeTravelStable[type]) {
throw new NotAuthorized(i18n.t('typeNotAllowedHourglass', {allowedTypes: keys(content.timeTravelStable).toString()}, req.language));
}
if (user.purchased.plan.consecutive.trinkets <= 0) {
throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language));
}
if (!includes(keys(content.timeTravelStable[type]), key)) {
throw new NotAuthorized(i18n.t('notAllowedHourglass', req.language));
}
user.purchased.plan.consecutive.trinkets--;
if (user.items[type][key]) {
throw new NotAuthorized(i18n.t(`${type}AlreadyOwned`, req.language));
}
if (type === 'pets') {
user.items.pets[key] = 5;
if (user.markModified) user.markModified('items.pets');
}
if (user.purchased.plan.consecutive.trinkets <= 0) {
throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language));
}
if (type === 'mounts') {
user.items.mounts[key] = true;
if (user.markModified) user.markModified('items.mounts');
user.purchased.plan.consecutive.trinkets--;
if (type === 'pets') {
user.items.pets[key] = 5;
if (user.markModified) user.markModified('items.pets');
}
if (type === 'mounts') {
user.items.mounts[key] = true;
if (user.markModified) user.markModified('items.mounts');
}
}
if (analytics) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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 = 'AUGUST SUBSCRIBER ITEMS REVEALED!';
const LAST_ANNOUNCEMENT_TITLE = 'SPECIAL TIME TRAVELERS QUEST!';
const worldDmg = { // @TODO
bailey: false,
};
@@ -30,14 +30,14 @@ api.getNews = {
<div class="mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center">${res.t('newStuff')}</h1>
<h2>8/27/2019 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<h2>8/22/2019 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
</div>
</div>
<hr/>
<div class="promo_mystery_201908 center-block"></div>
<p>The August Subscriber Item Set has been revealed, and it could be the GOAT: <a href='/user/settings/subscription'>the Footloose Faun Item Set</a>! You only have until August 31 to receive the item set when you subscribe. 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="small mb-3">by beffymaroo</div>
<div class="quest_robot center-block"></div>
<h3>Special Time Travelers' Pet Quest: Mysterious Mechanical Marvels!</h3>
<p>Hello Habiticans! We've released a brand-new quest in the Time Travelers' shop! It will be available at the cost of one <a href='https://habitica.fandom.com/wiki/Mystic_Hourglass' target='_blank'>Mystic Hourglass</a>, and is not limited, so you can buy it anytime you like, and as many times as you like. Get "<a href='/shops/time'>Mysterious Mechanical Marvels</a>", and earn some futuristic Robot pets by completing your real-life tasks!</p>
<div class="small mb-3">by Beffymaroo, Rev, artemie, McCoyly, FolleMente, elyons1, QuartzFox, and SabreCat</div>
</div>
`,
});

View File

@@ -983,12 +983,15 @@ api.purchase = {
* @apiParam (Path) {String="pets","mounts"} type The type of item to purchase
* @apiParam (Path) {String} key Ex: {Phoenix-Base}. The key for the mount/pet
*
* @apiParam (Body) {Integer} [quantity=1] Count of items to buy. Defaults to 1 and is ignored for items where quantity is irrelevant.
*
* @apiSuccess {Object} data.items user.items
* @apiSuccess {Object} data.purchasedPlanConsecutive user.purchased.plan.consecutive
* @apiSuccess {String} message Success message
*
* @apiError {NotAuthorized} NotAvailable Item is not available to be purchased or is not valid.
* @apiError {NotAuthorized} Hourglasses User does not have enough Mystic Hourglasses.
* @apiError {BadRequest} Quantity Quantity to purchase must be a number.
* @apiError {NotFound} Type Type invalid.
*
* @apiErrorExample {json}
@@ -1000,7 +1003,9 @@ api.userPurchaseHourglass = {
url: '/user/purchase-hourglass/:type/:key',
async handler (req, res) {
let user = res.locals.user;
let purchaseHourglassRes = common.ops.buy(user, req, res.analytics);
const quantity = req.body.quantity || 1;
if (quantity < 1 || !Number.isInteger(quantity)) throw new BadRequest(res.t('invalidQuantity'), req.language);
let purchaseHourglassRes = common.ops.buy(user, req, res.analytics, {quantity, hourglass: true});
await user.save();
res.respond(200, ...purchaseHourglassRes);
},