diff --git a/common/locales/en/subscriber.json b/common/locales/en/subscriber.json index 64db64bb2d..9c68a31979 100644 --- a/common/locales/en/subscriber.json +++ b/common/locales/en/subscriber.json @@ -59,11 +59,21 @@ "timeTravelers": "Time Travelers", "timeTravelersTitleNoSub": "<%= linkStartTyler %>Tyler<%= linkEnd %> and <%= linkStartVicky %>Vicky<%= linkEnd %>", "timeTravelersTitle": "Mysterious Time Travelers", - "timeTravelersPopoverNoSub": "You'll need a Mystic Hourglass to summon the mysterious Time Travelers! <%= linkStart %>Subscribers<%= linkEnd %> earn one Mystic Hourglass for every three months of consecutive subscribing. Come back when you have a Mystic Hourglass, and the Time Travelers will fetch you a Subscriber Item Set from the past.... or maybe even the future.", - "timeTravelersPopover": "We see you have a Mystic Hourglass, so we will happily travel back in time for you! Please choose the Mystery Item Set you would like. You can see a list of the past item sets <%= linkStart %>here<%= linkEnd %>! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets?", - "timeTravelersAlreadyOwned": "Congratulations! You already own all of the Mystery Items. Thanks for supporting the site!", + "timeTravelersPopoverNoSub": "You'll need a Mystic Hourglass to summon the mysterious Time Travelers! <%= linkStart %>Subscribers<%= linkEnd %> earn one Mystic Hourglass for every three months of consecutive subscribing. Come back when you have a Mystic Hourglass, and the Time Travelers will fetch you a rare pet, mount, or Subscriber Item Set from the past... or maybe even the future.", + "timeTravelersPopover": "We see you have a Mystic Hourglass, so we will happily travel back in time for you! Please choose the pet, mount, or Mystery Item Set you would like. You can see a list of the past item sets <%= linkStart %>here<%= linkEnd %>! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets?", + "timeTravelersAlreadyOwned": "Congratulations! You already own everything the Time Travelers currently offer. Thanks for supporting the site!", "mysticHourglassPopover": "Mystic Hourglass allows you to purchase previous months' subscriber sets.", "subUpdateCard": "Update Card", "subUpdateTitle": "Update", - "subUpdateDescription": "Update the card to be charged." + "subUpdateDescription": "Update the card to be charged.", + "notEnoughHourglasses": "You don't have enough Mystic Hourglasses.", + "hourglassBuyEquipSetConfirm": "Buy this full set of items for 1 Mystic Hourglass?", + "hourglassBuyItemConfirm": "Buy this item for 1 Mystic Hourglass?", + "petsAlreadyOwned": "Pet already owned.", + "mountsAlreadyOwned": "Mount already owned.", + "typeNotAllowedHourglass": "Item type not supported for purchase with Mystic Hourglass. Allowed types: ", + "petsNotAllowedHourglass": "Pet not available for purchase with Mystic Hourglass.", + "mountsNotAllowedHourglass": "Mount not available for purchase with Mystic Hourglass.", + "hourglassPurchase": "Purchased an item using a Mystic Hourglass!", + "hourglassPurchaseSet": "Purchased an item set using a Mystic Hourglass!" } diff --git a/common/script/content/index.coffee b/common/script/content/index.coffee index 02cf7b8fed..af2685ffca 100644 --- a/common/script/content/index.coffee +++ b/common/script/content/index.coffee @@ -1126,6 +1126,14 @@ api.specialMounts = 'Orca-Base': 'orca' 'Gryphon-RoyalPurple': 'royalPurpleGryphon' +api.timeTravelStable = + pets: + 'Mammoth-Base': t('mammoth') + 'MantisShrimp-Base': t('mantisShrimp') + mounts: + 'Mammoth-Base': t('mammoth') + 'MantisShrimp-Base': t('mantisShrimp') + api.hatchingPotions = Base: value: 2, text: t('hatchingPotionBase') White: value: 2, text: t('hatchingPotionWhite') @@ -2042,7 +2050,7 @@ api.quests = ] gp: 0 exp: 650 - + cheetah: text: t('questCheetahText') notes: t('questCheetahNotes') diff --git a/common/script/index.coffee b/common/script/index.coffee index 8e5094b7f2..02b1d5c4bc 100644 --- a/common/script/index.coffee +++ b/common/script/index.coffee @@ -999,10 +999,10 @@ api.wrap = (user, main=true) -> cb? {code:200, message}, user.items.quests buyMysterySet: (req, cb, analytics)-> - return cb?({code:401, message:"You don't have enough Mystic Hourglasses"}) unless user.purchased.plan.consecutive.trinkets>0 + return cb?({code:401, message:i18n.t('notEnoughHourglasses', req.language)}) unless user.purchased.plan.consecutive.trinkets > 0 mysterySet = content.timeTravelerStore(user.items.gear.owned)?[req.params.key] if window?.confirm? - return unless window.confirm("Buy this full set of items for 1 Mystic Hourglass?") + return unless window.confirm(i18n.t('hourglassBuyEquipSetConfirm')) return cb?({code:404, message:"Mystery set not found, or set already owned"}) unless mysterySet _.each mysterySet.items, (i)-> user.items.gear.owned[i.key]=true @@ -1016,7 +1016,28 @@ api.wrap = (user, main=true) -> analytics?.track('acquire item', analyticsData) user.purchased.plan.consecutive.trinkets-- - cb? null, _.pick(user,$w 'items purchased.plan.consecutive') + cb? {code:200, message:i18n.t('hourglassPurchaseSet', req.language)}, _.pick(user,$w 'items purchased.plan.consecutive') + + hourglassPurchase: (req, cb, analytics)-> + {type, key} = req.params + return cb?({code:403, message:i18n.t('typeNotAllowedHourglass', req.language) + JSON.stringify(_.keys(content.timeTravelStable))}) unless content.timeTravelStable[type] + return cb?({code:403, message:i18n.t(type+'NotAllowedHourglass', req.language)}) unless _.contains(_.keys(content.timeTravelStable[type]), key) + return cb?({code:403, message:i18n.t(type+'AlreadyOwned', req.language)}) if user.items[type][key] + return cb?({code:403, message:i18n.t('notEnoughHourglasses', req.language)}) unless user.purchased.plan.consecutive.trinkets > 0 + user.purchased.plan.consecutive.trinkets-- + if type is 'pets' + user.items.pets[key] = 5 + if type is 'mounts' + user.items.mounts[key] = true + analyticsData = { + uuid: user._id, + itemKey: key, + itemType: type, + acquireMethod: 'Hourglass', + category: 'behavior' + } + analytics?.track('acquire item', analyticsData) + cb? {code:200, message:i18n.t('hourglassPurchase', req.language)}, _.pick(user,$w 'items purchased.plan.consecutive') sell: (req, cb) -> {key, type} = req.params diff --git a/test/common/user.ops.buyMysterySet.test.js b/test/common/user.ops.buyMysterySet.test.js new file mode 100644 index 0000000000..f0a4a7b2f8 --- /dev/null +++ b/test/common/user.ops.buyMysterySet.test.js @@ -0,0 +1,84 @@ +var sinon = require('sinon'); +var chai = require("chai") +chai.use(require("sinon-chai")) +var expect = chai.expect + +require('coffee-script'); +var shared = require('../../common/script/index.coffee'); +var Content = require('../../common/script/content/index.coffee'); + +describe('user.ops.buyMysterySet', function() { + var user; + + beforeEach(function() { + user = { + items: { + gear: { + owned: { + weapon_warrior_0: true + } + } + }, + purchased: { + plan: { + consecutive: { + trinkets: 0 + } + } + } + }; + + shared.wrap(user); + }); + + context('Mystery Sets', function() { + + context('failure conditions', function() { + + it('does not grant mystery sets without Mystic Hourglasses', function(done) { + user.ops.buyMysterySet({params:{key:'201501'}}, function(response) { + expect(response.message).to.eql("You don't have enough Mystic Hourglasses."); + expect(user.items.gear.owned).to.eql({'weapon_warrior_0': true}); + done(); + }); + }); + + it('does not grant mystery set that has already been purchased', function(done) { + user.purchased.plan.consecutive.trinkets = 1; + user.items.gear.owned = { + weapon_warrior_0: true, + weapon_mystery_301404: true, + armor_mystery_301404: true, + head_mystery_301404: true, + eyewear_mystery_301404: true + }; + + user.ops.buyMysterySet({params:{key:'301404'}}, function(response) { + expect(response.message).to.eql("Mystery set not found, or set already owned"); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + }); + + context('successful purchases', function() { + + it('buys Steampunk Accessories Set', function(done) { + user.purchased.plan.consecutive.trinkets = 1; + + user.ops.buyMysterySet({params:{key:'301404'}}, function() { + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + weapon_mystery_301404: true, + armor_mystery_301404: true, + head_mystery_301404: true, + eyewear_mystery_301404: true + }); + done(); + }); + }); + }); + }); +}); + diff --git a/test/common/user.ops.hourglassPurchase.test.js b/test/common/user.ops.hourglassPurchase.test.js new file mode 100644 index 0000000000..458029c6df --- /dev/null +++ b/test/common/user.ops.hourglassPurchase.test.js @@ -0,0 +1,132 @@ +var sinon = require('sinon'); +var chai = require("chai") +chai.use(require("sinon-chai")) +var expect = chai.expect + +require('coffee-script'); +var shared = require('../../common/script/index.coffee'); +var Content = require('../../common/script/content/index.coffee'); + +describe('user.ops.hourglassPurchase', function() { + var user; + + beforeEach(function() { + user = { + items: { + pets: {}, + mounts: {}, + hatchingPotions: {} + }, + purchased: { + plan: { + consecutive: { + trinkets: 0 + } + } + } + }; + + shared.wrap(user); + }); + + context('Time Travel Stable', function() { + + context('failure conditions', function() { + + it('does not allow purchase of unsupported item types', function(done) { + user.ops.hourglassPurchase({params:{type: 'hatchingPotions', key: 'Base'}}, function(response) { + expect(response.message).to.eql('Item type not supported for purchase with Mystic Hourglass. Allowed types: ["pets","mounts"]'); + expect(user.items.hatchingPotions).to.eql({}); + done(); + }); + }); + + it('does not grant pets without Mystic Hourglasses', function(done) { + user.ops.hourglassPurchase({params:{type: 'pets', key: 'MantisShrimp-Base'}}, function(response) { + expect(response.message).to.eql("You don't have enough Mystic Hourglasses."); + expect(user.items.pets).to.eql({}); + done(); + }); + }); + + it('does not grant mounts without Mystic Hourglasses', function(done) { + user.ops.hourglassPurchase({params:{type: 'mounts', key: 'MantisShrimp-Base'}}, function(response) { + expect(response.message).to.eql("You don't have enough Mystic Hourglasses."); + expect(user.items.mounts).to.eql({}); + done(); + }); + }); + + it('does not grant pet that has already been purchased', function(done) { + user.purchased.plan.consecutive.trinkets = 1; + user.items.pets = { + 'MantisShrimp-Base': true + }; + + user.ops.hourglassPurchase({params:{type: 'pets', key: 'MantisShrimp-Base'}}, function(response) { + expect(response.message).to.eql("Pet already owned."); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + + it('does not grant mount that has already been purchased', function(done) { + user.purchased.plan.consecutive.trinkets = 1; + user.items.mounts = { + 'MantisShrimp-Base': true + }; + + user.ops.hourglassPurchase({params:{type: 'mounts', key: 'MantisShrimp-Base'}}, function(response) { + expect(response.message).to.eql("Mount already owned."); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + + it('does not grant pet that is not part of the Time Travel Stable', function(done) { + user.purchased.plan.consecutive.trinkets = 1; + + user.ops.hourglassPurchase({params: {type: 'pets', key: 'Wolf-Veteran'}}, function(response) { + expect(response.message).to.eql('Pet not available for purchase with Mystic Hourglass.'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + + it('does not grant mount that is not part of the Time Travel Stable', function(done) { + user.purchased.plan.consecutive.trinkets = 1; + + user.ops.hourglassPurchase({params: {type: 'mounts', key: 'Orca-Base'}}, function(response) { + expect(response.message).to.eql('Mount not available for purchase with Mystic Hourglass.'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + }); + + context('successful purchases', function() { + + it('buys a pet', function(done) { + user.purchased.plan.consecutive.trinkets = 2; + + user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, function(response) { + expect(response.message).to.eql('Purchased an item using a Mystic Hourglass!'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.pets).to.eql({'MantisShrimp-Base':5}); + done(); + }); + }); + + it('buys a mount', function(done) { + user.purchased.plan.consecutive.trinkets = 2; + + user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, function(response) { + expect(response.message).to.eql('Purchased an item using a Mystic Hourglass!'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.mounts).to.eql({'MantisShrimp-Base':true}); + done(); + }); + }); + }); + }); +}); diff --git a/test/spec/controllers/inventoryCtrlSpec.js b/test/spec/controllers/inventoryCtrlSpec.js index f1c5bc3b73..5b484ac6f8 100644 --- a/test/spec/controllers/inventoryCtrlSpec.js +++ b/test/spec/controllers/inventoryCtrlSpec.js @@ -224,14 +224,64 @@ describe('Inventory Controller', function() { }); describe('#hasAllTimeTravelerItems', function() { - it('returns false if there are items left in the time traveler store', function() { + it('returns false if items remain for purchase with Mystic Hourglasses', function() { expect(scope.hasAllTimeTravelerItems()).to.eql(false); }); it('returns true if there are no items left to purchase', inject(function(Content) { - sandbox.stub(Content, 'timeTravelerStore').returns({}); - + for (var item in Content.gear.flat) { + if (item.indexOf('mystery') > -1) { + user.items.gear.owned[item] = true; + } + } + for (var pet in Content.timeTravelStable.pets) { + user.items.pets[pet] = 5; + } + for (var mount in Content.timeTravelStable.mounts) { + user.items.mounts[mount] = true; + } + expect(scope.hasAllTimeTravelerItems()).to.eql(true); })); }); + + describe('#hasAllTimeTravelerItemsOfType', function() { + it('returns false for Mystery Sets if there are sets left in the time traveler store', function() { + expect(scope.hasAllTimeTravelerItemsOfType('mystery')).to.eql(false); + }); + + it('returns true for Mystery Sets if there are no sets left to purchase', inject(function(Content) { + for (var item in Content.gear.flat) { + if (item.indexOf('mystery') > -1) { + user.items.gear.owned[item] = true; + } + } + + expect(scope.hasAllTimeTravelerItemsOfType('mystery')).to.eql(true); + })); + + it('returns false for pets if user does not own all pets in the Time Travel Stable', function() { + expect(scope.hasAllTimeTravelerItemsOfType('pets')).to.eql(false); + }); + + it('returns true for pets if user owns all pets in the Time Travel Stable', inject(function(Content) { + for (var pet in Content.timeTravelStable.pets) { + user.items.pets[pet] = 5; + } + + expect(scope.hasAllTimeTravelerItemsOfType('pets')).to.eql(true); + })); + + it('returns false for mounts if user does not own all mounts in the Time Travel Stable', function() { + expect(scope.hasAllTimeTravelerItemsOfType('mounts')).to.eql(false); + }); + + it('returns true for mounts if user owns all mounts in the Time Travel Stable', inject(function(Content) { + for (var mount in Content.timeTravelStable.mounts) { + user.items.mounts[mount] = true; + } + + expect(scope.hasAllTimeTravelerItemsOfType('mounts')).to.eql(true); + })); + }); }); diff --git a/website/public/js/controllers/inventoryCtrl.js b/website/public/js/controllers/inventoryCtrl.js index 102303a76e..70e1d5c9fc 100644 --- a/website/public/js/controllers/inventoryCtrl.js +++ b/website/public/js/controllers/inventoryCtrl.js @@ -250,11 +250,32 @@ habitrpg.controller("InventoryCtrl", }); }; - $scope.hasAllTimeTravelerItems = function(items) { - var itemsLeftInTimeTravlerStore = Content.timeTravelerStore(user.items.gear.owned); - var keys = Object.keys(itemsLeftInTimeTravlerStore); + $scope.hasAllTimeTravelerItems = function() { + return ($scope.hasAllTimeTravelerItemsOfType('mystery') && + $scope.hasAllTimeTravelerItemsOfType('pets') && + $scope.hasAllTimeTravelerItemsOfType('mounts')); + }; - return keys.length === 0; + $scope.hasAllTimeTravelerItemsOfType = function(type) { + if (type === 'mystery') { + var itemsLeftInTimeTravelerStore = Content.timeTravelerStore(user.items.gear.owned); + var keys = Object.keys(itemsLeftInTimeTravelerStore); + + return keys.length === 0; + } + if (type === 'pets' || type === 'mounts') { + for (var key in Content.timeTravelStable[type]) { + if (!user.items[type][key]) return false; + } + return true; + } + else return Console.log('Time Traveler item type must be in ["pets","mounts","mystery"]'); + }; + + $scope.clickTimeTravelItem = function(type,key) { + if (user.purchased.plan.consecutive.trinkets < 1) return user.ops.hourglassPurchase({params:{type:type,key:key}}); + if (!window.confirm(window.env.t('hourglassBuyItemConfirm'))) return; + user.ops.hourglassPurchase({params:{type:type,key:key}}); }; function _updateDropAnimalCount(items) { diff --git a/website/src/routes/apiv2.coffee b/website/src/routes/apiv2.coffee index db26ac57a7..876295a82f 100644 --- a/website/src/routes/apiv2.coffee +++ b/website/src/routes/apiv2.coffee @@ -180,13 +180,31 @@ module.exports = (swagger, v2) -> "/user/inventory/purchase/{type}/{key}": spec: method: 'POST' - description: "Purchase a gem-purchaseable item from Alexander" + description: "Purchase a Gem-purchasable item from Alexander" parameters:[ path('type',"The type of object you're purchasing.",'string',['eggs','hatchingPotions','food','quests','special']) path('key',"The object key you're purchasing (call /content route for available keys)",'string') ] action: user.purchase + "/user/inventory/hourglass/{type}/{key}": + spec: + method: 'POST' + description: "Purchase a pet or mount using a Mystic Hourglass" + parameters:[ + path('type',"The type of object you're purchasing.",'string',['pets','mounts']) + path('key',"The object key you're purchasing (call /content route for available keys)",'string') + ] + action: user.hourglassPurchase + + "/user/inventory/hourglass/mystery/{key}": + spec: + method: 'POST' + description: "Purchase a Mystery Item Set using a Mystic Hourglass" + parameters:[ + path('key',"The key for the Mystery Set you're purchasing (call /content route for available keys)",'string') + ] + action: user.buyMysterySet "/user/inventory/feed/{pet}/{food}": spec: diff --git a/website/views/options/inventory/time-travelers.jade b/website/views/options/inventory/time-travelers.jade index 4b8277038f..82dd6227b4 100644 --- a/website/views/options/inventory/time-travelers.jade +++ b/website/views/options/inventory/time-travelers.jade @@ -17,12 +17,22 @@ .row: .col-md-7 .alert.alert-info(ng-if='hasAllTimeTravelerItems()')=env.t('timeTravelersAlreadyOwned') - .row: .col-md-12 - li.customize-menu.inventory-gear - menu.pets-menu(label='{{::set.text}}', ng-repeat='set in Content.timeTravelerStore(user.items.gear.owned)') - div(ng-repeat='item in set.items') - button.customize-option(class='shop_{{::item.key}}', - popover='{{::item.notes()}}', popover-title='{{::item.text()}}', - popover-trigger='mouseenter', popover-placement='right', - popover-append-to-body='true', - ng-click='user.ops.buyMysterySet({params:{key:set.key}})') + .row + .col-md-12 + li.customize-menu.inventory-gear + each prepend, type in {pets:'Pet-', mounts:'Mount_Head_'} + menu.pets-menu(label=env.t('#{type}'), ng-if='!hasAllTimeTravelerItemsOfType("#{type}")') + div(ng-repeat='(item, text) in Content.timeTravelStable["#{type}"]', style='margin-top:0') + button.pet-button(class='#{prepend}{{::item}}', style='margin-top:0', + ng-if='!user.items["#{type}"][item]', + popover='{{::text()}}', popover-trigger='mouseenter', + popover-placement='right', popover-append-to-body='true', + ng-click='clickTimeTravelItem("#{type}",item)') + li.customize-menu.inventory-gear + menu.pets-menu(label='{{::set.text}}', ng-repeat='set in Content.timeTravelerStore(user.items.gear.owned)') + div(ng-repeat='item in set.items') + button.customize-option(class='shop_{{::item.key}}', + popover='{{::item.notes()}}', popover-title='{{::item.text()}}', + popover-trigger='mouseenter', popover-placement='right', + popover-append-to-body='true', + ng-click='user.ops.buyMysterySet({params:{key:set.key}})') diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index 008429081d..928878ff69 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -1,32 +1,51 @@ -h2 CHALLENGE SPOTLIGHT, NEW SOUND THEME, START QUEST BUTTON, AND CUSTOM DAY START FIXES +h2 9/16/2015 - MAMMOTHS AND MANTIS SHRIMPS IN TIME TRAVELER SHOP! PLUS, FALL FESTIVAL PLOT-LINE CONTINUES hr tr td - h3 Challenge Spotlight - p There's a new Challenge Spotlight on our blog! Check it out for some great recommended Challenges, including some about studying and self-discipline. - p.small.muted by Lemoness - p.small.muted Challenges by shanaqui, keyes, White Rose Duelist, and Elo + .Pet-MantisShrimp-Base.pull-right + .Pet-Mammoth-Base.pull-right + h3 Woolly Mammoths and Mantis Shrimps in Time Traveler Shop + p The Time Travelers have traveled back to Summer 2014 and Winter 2015 to obtain some Mammoth and Mantis Shrimp pets and mounts! You can buy them with Mystic Hourglasses, which are awarded to long-term subscribers. Thanks for helping us to keep Habitica running! + p.small.muted by SabreCat, Blade, and cheerskevin + p.small.muted Art by Ottl and Baconsaur tr td - h3 New Sound Theme - p There's a new sound effect theme on the site: LuneFox's Theme! Select it from the megaphone in the upper right. It will play sound effects when you click things! - p.small.muted by LuneFox and Alys - tr - td - h3 Start Quest Button - p Now you can begin a boss fight or collection quest directly from your Party Page! Just click the green Start Quest button on the left. If you already have quest scrolls, they will pop up automatically. Otherwise, it will take you directly to the Quest Shop. Enjoy the added convenience! - p.small.muted by SabreCat and cheerskevin - tr - td - h3 Custom Day Start Fixes - p The Custom Day Start feature has been fixed so that it can now be changed at any time with complete safety! For those who don't know, this feature enables you to set what time your Dailies will roll over to a new day. To set a Custom Day Start, go to Settings > Site > Custom Day Start. - p.small.muted by carolstone and Blade + h3 Fall Festival Plot-Line Continues + p Last minute preparations for the Fall Festival are underway! Lemoness has been bustling about with tomes of potion-brewing, and conferring with the artisans in cheerful conspiracy. SabreCat has been seen in the smithy, toiling on projects that he has loudly announced to be "very hush-hush." Even the elusive Royal Chancellor, Redphoenix, has taken a break from sending Boring Business Letters to roast marshmallows outside the Tavern. Everyone has happily gathered around her little campfire, which she is feeding with a steady supply of envelopes. + br + p Only one thing has disrupted the anticipation. You catch Lemoness frowning as she reads a message attached to the latest shipment from the Flourishing Fields. + br + p "They're apologizing for not sending as many pumpkins as they promised, due to an 'unanticipated setback,'" she explains. "There's no need for them to be sorry, though -- they've sent us hundreds already! They said the same thing last week about the candy vegetable harvest. I told the Joyful Reaper not to worry about it, but she just promises to work harder." + br + p Well, soon the Festival will launch, and they'll be able to take a break. Nothing to be concerned about! if menuItem !== 'oldNews' hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews + h2 CHALLENGE SPOTLIGHT, NEW SOUND THEME, START QUEST BUTTON, AND CUSTOM DAY START FIXES + tr + td + h3 Challenge Spotlight + p There's a new Challenge Spotlight on our blog! Check it out for some great recommended Challenges, including some about studying and self-discipline. + p.small.muted by Lemoness + p.small.muted Challenges by shanaqui, keyes, White Rose Duelist, and Elo + tr + td + h3 New Sound Theme + p There's a new sound effect theme on the site: LuneFox's Theme! Select it from the megaphone in the upper right. It will play sound effects when you click things! + p.small.muted by LuneFox and Alys + tr + td + h3 Start Quest Button + p Now you can begin a boss fight or collection quest directly from your Party Page! Just click the green Start Quest button on the left. If you already have quest scrolls, they will pop up automatically. Otherwise, it will take you directly to the Quest Shop. Enjoy the added convenience! + p.small.muted by SabreCat and cheerskevin + tr + td + h3 Custom Day Start Fixes + p The Custom Day Start feature has been fixed so that it can now be changed at any time with complete safety! For those who don't know, this feature enables you to set what time your Dailies will roll over to a new day. To set a Custom Day Start, go to Settings > Site > Custom Day Start. + p.small.muted by carolstone and Blade h2 HORSE PET QUEST, NEW PRODUCTIVITY BLOG, AND FALL PLOT-LINE BEGINS tr td