From 0817cf96e1018a3858115b66ade304de600837e7 Mon Sep 17 00:00:00 2001 From: Kaitlin Hipkin Date: Tue, 13 Dec 2016 13:48:18 -0500 Subject: [PATCH] Achievement list renovation & Achievements API (#7904) * pull apart achievements into different subcategories * achievs previously hidden to others if unachieved are now always shown * achievs previously always hidden if unachieved are now always shown * pull apart ultimate gear achievs * add achiev wrapper mixin * add achiev mixin for simple counts * add achiev mixin for singular/plural achievs * add simpleAchiev mixin and support attributes * always hide potentially unearnable achievs if unearned * contributor achiev now uses string interpolation for readMore link * transition to basic achiev grid layout * fix npc achievement img bug introduced in c90f7e2 * move surveys and contributor achievs into special section so it is never empty * double size of achievs in achievs grid * achievs in grid are muted if unachieved (includes recompiled sprites) * fix streak notification strings * add counts to achievement badges for applicable achieved achievs * list achievements by api * fix achievement strings in new api * unearned achievs now use dedicated (WIP) 'unearned' badge instead of muted versions of the normal badges * fix & cleanup achievements api * extract generation of the achievements result to a class * clean up achievement counter css using existing classes * simplify exports of new achievementBuilder lib * remove class logic from achievementBuilder lib * move achievs to common, add rebirth achiev logic, misc fixes * replace achievs jade logic with results of api call * fix linting errors * achievs lib now returns achievs object subdivided by type (basic/seasonal/special * add tests for new achievs lib * fix linting errors * update controllers and views for updated achievs lib * add indices to achievements to preserve intended order * move achiev popovers to left * rename achievs lib to achievements * adjust positioning of achieve popovers now that stats and achievs pages are separate * fix: achievements api correctly decides whether to append extra string for master and triadBingo achievs * revert compiled sprites so they don't bog down the PR * pull out achievs api integration tests * parameterize ultimate gear achievements' text string * break out static achievement data from user-specific data * reorg content.achievements to add achiev data in related chunks * cleanup, respond to feedback * improve api documentation * fix merge issues * Helped Habit Grow --> Helped Habitica Grow * achievement popovers are muted if the achiev is unearned * fix singular achievement labels / achievement popover on click * update apidoc for achievements (description, param-type, successExample, error-types) * fix whitespace issues in members.js * move html to a variable * updated json example * fix syntax after merge --- .../members/GET-achievements.test.js | 44 +++ test/common/libs/achievements.test.js | 348 ++++++++++++++++++ website/assets/sprites/css/index.css | 5 + .../achievements/achievement-alpha2x.png | Bin 0 -> 3329 bytes .../achievements/achievement-dilatory2x.png | Bin 0 -> 3507 bytes .../achievements/achievement-partyOn2x.png | Bin 3831 -> 3447 bytes .../achievements/achievement-stoikalm2x.png | Bin 0 -> 3493 bytes .../achievements/achievement-unearned2x.png | Bin 0 -> 2124 bytes .../js/controllers/memberModalCtrl.js | 4 + website/client-old/js/controllers/userCtrl.js | 4 + .../js/services/notificationServices.js | 2 +- website/common/locales/en/character.json | 4 +- website/common/locales/en/contrib.json | 8 +- website/common/locales/en/generic.json | 15 +- website/common/locales/en/groups.json | 4 +- website/common/locales/en/limited.json | 12 +- website/common/locales/en/npc.json | 1 + website/common/locales/en/rebirth.json | 5 +- website/common/locales/en/tasks.json | 9 +- website/common/script/content/achievements.js | 188 ++++++++++ website/common/script/content/index.js | 4 + website/common/script/index.js | 3 + website/common/script/libs/achievements.js | 306 +++++++++++++++ website/server/controllers/api-v3/members.js | 108 ++++++ .../views/shared/profiles/achievements.jade | 289 ++------------- .../shared/profiles/achievs/challenges.jade | 9 + .../views/shared/profiles/achievs/quests.jade | 9 + 27 files changed, 1091 insertions(+), 290 deletions(-) create mode 100644 test/api/v3/integration/members/GET-achievements.test.js create mode 100644 test/common/libs/achievements.test.js create mode 100644 website/assets/sprites/spritesmith/achievements/achievement-alpha2x.png create mode 100644 website/assets/sprites/spritesmith/achievements/achievement-dilatory2x.png create mode 100644 website/assets/sprites/spritesmith/achievements/achievement-stoikalm2x.png create mode 100644 website/assets/sprites/spritesmith/achievements/achievement-unearned2x.png create mode 100644 website/common/script/content/achievements.js create mode 100644 website/common/script/libs/achievements.js create mode 100644 website/views/shared/profiles/achievs/challenges.jade create mode 100644 website/views/shared/profiles/achievs/quests.jade diff --git a/test/api/v3/integration/members/GET-achievements.test.js b/test/api/v3/integration/members/GET-achievements.test.js new file mode 100644 index 0000000000..cb7930adba --- /dev/null +++ b/test/api/v3/integration/members/GET-achievements.test.js @@ -0,0 +1,44 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /members/:memberId/achievements', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('validates req.params.memberId', async () => { + await expect(user.get('/members/invalidUUID/achievements')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns achievements based on given user', async () => { + let member = await generateUser({ + contributor: {level: 1}, + backer: {tier: 3}, + }); + let achievementsRes = await user.get(`/members/${member._id}/achievements`); + + expect(achievementsRes.special.achievements.contributor.earned).to.equal(true); + expect(achievementsRes.special.achievements.contributor.value).to.equal(1); + + expect(achievementsRes.special.achievements.kickstarter.earned).to.equal(true); + expect(achievementsRes.special.achievements.kickstarter.value).to.equal(3); + }); + + it('handles non-existing members', async () => { + let dummyId = generateUUID(); + await expect(user.get(`/members/${dummyId}/achievements`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); +}); diff --git a/test/common/libs/achievements.test.js b/test/common/libs/achievements.test.js new file mode 100644 index 0000000000..ecccfdfbee --- /dev/null +++ b/test/common/libs/achievements.test.js @@ -0,0 +1,348 @@ +import shared from '../../../website/common'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('achievements', () => { + describe('general well-formedness', () => { + let user = generateUser(); + let achievements = shared.achievements.getAchievementsForProfile(user); + + it('each category has \'label\' and \'achievements\' fields', () => { + _.each(achievements, (category) => { + expect(category).to.have.property('label') + .that.is.a('string'); + expect(category).to.have.property('achievements') + .that.is.a('object'); + }); + }); + + it('each achievement has all required fields of correct types', () => { + _.each(achievements, (category) => { + _.each(category.achievements, (achiev) => { + // May have additional fields (such as 'value' and 'optionalCount'). + expect(achiev).to.contain.all.keys(['title', 'text', 'icon', 'earned', 'index']); + expect(achiev.title).to.be.a('string'); + expect(achiev.text).to.be.a('string'); + expect(achiev.icon).to.be.a('string'); + expect(achiev.earned).to.be.a('boolean'); + expect(achiev.index).to.be.a('number'); + }); + }); + }); + + it('categories have unique labels', () => { + let achievementsArray = _.values(achievements).map(cat => cat.label); + let labels = _.uniq(achievementsArray); + + expect(labels.length).to.be.greaterThan(0); + expect(labels.length).to.eql(_.size(achievements)); + }); + + it('achievements have unique keys', () => { + let keysSoFar = {}; + + _.each(achievements, (category) => { + _.keys(category.achievements).forEach((key) => { + expect(keysSoFar[key]).to.be.undefined; + keysSoFar[key] = key; + }); + }); + }); + + it('achievements have unique indices', () => { + let indicesSoFar = {}; + + _.each(achievements, (category) => { + _.each(category.achievements, (achiev) => { + let i = achiev.index; + expect(indicesSoFar[i]).to.be.undefined; + indicesSoFar[i] = i; + }); + }); + }); + + it('all categories have at least 1 achievement', () => { + _.each(achievements, (category) => { + expect(_.size(category.achievements)).to.be.greaterThan(0); + }); + }); + }); + + describe('unearned basic achievements', () => { + let user = generateUser(); + let basicAchievs = shared.achievements.getAchievementsForProfile(user).basic.achievements; + + it('streak and perfect day achievements exist with counts', () => { + let streak = basicAchievs.streak; + let perfect = basicAchievs.perfect; + + expect(streak).to.exist; + expect(streak).to.have.property('optionalCount') + .that.is.a('number'); + expect(perfect).to.exist; + expect(perfect).to.have.property('optionalCount') + .that.is.a('number'); + }); + + it('party up/on achievements exist with no counts', () => { + let partyUp = basicAchievs.partyUp; + let partyOn = basicAchievs.partyOn; + + expect(partyUp).to.exist; + expect(partyUp.optionalCount).to.be.undefined; + expect(partyOn).to.exist; + expect(partyOn.optionalCount).to.be.undefined; + }); + + it('pet/mount master and triad bingo achievements exist with counts', () => { + let beastMaster = basicAchievs.beastMaster; + let mountMaster = basicAchievs.mountMaster; + let triadBingo = basicAchievs.triadBingo; + + expect(beastMaster).to.exist; + expect(beastMaster).to.have.property('optionalCount') + .that.is.a('number'); + expect(mountMaster).to.exist; + expect(mountMaster).to.have.property('optionalCount') + .that.is.a('number'); + expect(triadBingo).to.exist; + expect(triadBingo).to.have.property('optionalCount') + .that.is.a('number'); + }); + + it('ultimate gear achievements exist with no counts', () => { + let gearTypes = ['healer', 'rogue', 'warrior', 'mage']; + gearTypes.forEach((gear) => { + let gearAchiev = basicAchievs[`${gear}UltimateGear`]; + + expect(gearAchiev).to.exist; + expect(gearAchiev.optionalCount).to.be.undefined; + }); + }); + + it('rebirth achievement exists with no count', () => { + let rebirth = basicAchievs.rebirth; + + expect(rebirth).to.exist; + expect(rebirth.optionalCount).to.be.undefined; + }); + }); + + describe('unearned seasonal achievements', () => { + let user = generateUser(); + let seasonalAchievs = shared.achievements.getAchievementsForProfile(user).seasonal.achievements; + + it('habiticaDays and habitBirthdays achievements exist with counts', () => { + let habiticaDays = seasonalAchievs.habiticaDays; + let habitBirthdays = seasonalAchievs.habitBirthdays; + + expect(habiticaDays).to.exist; + expect(habiticaDays).to.have.property('optionalCount') + .that.is.a('number'); + expect(habitBirthdays).to.exist; + expect(habitBirthdays).to.have.property('optionalCount') + .that.is.a('number'); + }); + + it('spell achievements exist with counts', () => { + let spellTypes = ['snowball', 'spookySparkles', 'shinySeed', 'seafoam']; + spellTypes.forEach((spell) => { + let spellAchiev = seasonalAchievs[spell]; + + expect(spellAchiev).to.exist; + expect(spellAchiev).to.have.property('optionalCount') + .that.is.a('number'); + }); + }); + + it('quest achievements exist with counts', () => { + let quests = ['dilatory', 'stressbeast', 'burnout', 'bewilder']; + quests.forEach((quest) => { + let questAchiev = seasonalAchievs[`${quest}Quest`]; + + expect(questAchiev).to.exist; + expect(questAchiev.optionalCount).to.be.undefined; + }); + }); + + it('costumeContests achievement exists with count', () => { + let costumeContests = seasonalAchievs.costumeContests; + + expect(costumeContests).to.exist; + expect(costumeContests).to.have.property('optionalCount') + .that.is.a('number'); + }); + + it('card achievements exist with counts', () => { + let cardTypes = ['greeting', 'thankyou', 'nye', 'valentine', 'birthday']; + cardTypes.forEach((card) => { + let cardAchiev = seasonalAchievs[`${card}Cards`]; + + expect(cardAchiev).to.exist; + expect(cardAchiev).to.have.property('optionalCount') + .that.is.a('number'); + }); + }); + }); + + describe('unearned special achievements', () => { + let user = generateUser(); + let specialAchievs = shared.achievements.getAchievementsForProfile(user).special.achievements; + + it('habitSurveys achievement exists with count', () => { + let habitSurveys = specialAchievs.habitSurveys; + + expect(habitSurveys).to.exist; + expect(habitSurveys).to.have.property('optionalCount') + .that.is.a('number'); + }); + + it('contributor achievement exists with value and no count', () => { + let contributor = specialAchievs.contributor; + + expect(contributor).to.exist; + expect(contributor).to.have.property('value') + .that.is.a('number'); + expect(contributor.optionalCount).to.be.undefined; + }); + + it('npc achievement is hidden if unachieved', () => { + let npc = specialAchievs.npc; + expect(npc).to.not.exist; + }); + + it('kickstarter achievement is hidden if unachieved', () => { + let kickstarter = specialAchievs.kickstarter; + expect(kickstarter).to.not.exist; + }); + + it('veteran achievement is hidden if unachieved', () => { + let veteran = specialAchievs.veteran; + expect(veteran).to.not.exist; + }); + + it('originalUser achievement is hidden if unachieved', () => { + let originalUser = specialAchievs.originalUser; + expect(originalUser).to.not.exist; + }); + }); + + describe('earned special achievements', () => { + let user = generateUser({ + achievements: { + habitSurveys: 2, + veteran: true, + originalUser: true, + }, + backer: {tier: 3}, + contributor: {level: 1}, + }); + let specialAchievs = shared.achievements.getAchievementsForProfile(user).special.achievements; + + it('habitSurveys achievement is earned with correct value', () => { + let habitSurveys = specialAchievs.habitSurveys; + + expect(habitSurveys).to.exist; + expect(habitSurveys.earned).to.eql(true); + expect(habitSurveys.value).to.eql(2); + }); + + it('contributor achievement is earned with correct value', () => { + let contributor = specialAchievs.contributor; + + expect(contributor).to.exist; + expect(contributor.earned).to.eql(true); + expect(contributor.value).to.eql(1); + }); + + it('npc achievement is earned with correct value', () => { + let npcUser = generateUser({ + backer: {npc: 'test'}, + }); + let npc = shared.achievements.getAchievementsForProfile(npcUser).special.achievements.npc; + + expect(npc).to.exist; + expect(npc.earned).to.eql(true); + expect(npc.value).to.eql('test'); + }); + + it('kickstarter achievement is earned with correct value', () => { + let kickstarter = specialAchievs.kickstarter; + + expect(kickstarter).to.exist; + expect(kickstarter.earned).to.eql(true); + expect(kickstarter.value).to.eql(3); + }); + + it('veteran achievement is earned', () => { + let veteran = specialAchievs.veteran; + + expect(veteran).to.exist; + expect(veteran.earned).to.eql(true); + }); + + it('originalUser achievement is earned', () => { + let originalUser = specialAchievs.originalUser; + + expect(originalUser).to.exist; + expect(originalUser.earned).to.eql(true); + }); + }); + + describe('mountMaster, beastMaster, and triadBingo achievements', () => { + it('master and triad bingo achievements do not include *Text2 strings if no keys have been used', () => { + let user = generateUser(); + let basicAchievs = shared.achievements.getAchievementsForProfile(user).basic.achievements; + + let beastMaster = basicAchievs.beastMaster; + let mountMaster = basicAchievs.mountMaster; + let triadBingo = basicAchievs.triadBingo; + + expect(beastMaster.text).to.not.match(/released/); + expect(beastMaster.text).to.not.match(/0 time\(s\)/); + expect(mountMaster.text).to.not.match(/released/); + expect(mountMaster.text).to.not.match(/0 time\(s\)/); + expect(triadBingo.text).to.not.match(/released/); + expect(triadBingo.text).to.not.match(/0 time\(s\)/); + }); + + it('master and triad bingo achievements includes *Text2 strings if keys have been used', () => { + let user = generateUser({ + achievements: { + beastMasterCount: 1, + mountMasterCount: 2, + triadBingoCount: 3, + }, + }); + let basicAchievs = shared.achievements.getAchievementsForProfile(user).basic.achievements; + + let beastMaster = basicAchievs.beastMaster; + let mountMaster = basicAchievs.mountMaster; + let triadBingo = basicAchievs.triadBingo; + + expect(beastMaster.text).to.match(/released/); + expect(beastMaster.text).to.match(/1 time\(s\)/); + expect(mountMaster.text).to.match(/released/); + expect(mountMaster.text).to.match(/2 time\(s\)/); + expect(triadBingo.text).to.match(/released/); + expect(triadBingo.text).to.match(/3 time\(s\)/); + }); + }); + + describe('ultimateGear achievements', () => { + it('title and text contain localized class info', () => { + let user = generateUser(); + let basicAchievs = shared.achievements.getAchievementsForProfile(user).basic.achievements; + let gearTypes = ['healer', 'rogue', 'warrior', 'mage']; + + gearTypes.forEach((gear) => { + let gearAchiev = basicAchievs[`${gear}UltimateGear`]; + let classNameRegex = new RegExp(gear.charAt(0).toUpperCase() + gear.slice(1)); + + expect(gearAchiev.title).to.match(classNameRegex); + expect(gearAchiev.text).to.match(classNameRegex); + }); + }); + }); +}); diff --git a/website/assets/sprites/css/index.css b/website/assets/sprites/css/index.css index 5954342b57..d85a66e838 100644 --- a/website/assets/sprites/css/index.css +++ b/website/assets/sprites/css/index.css @@ -50,6 +50,11 @@ margin-right: 10px; } +.achievement .counter { + bottom: 0; + right: 10px; +} + .multi-achievement { margin: auto; padding-left: 0.5em; diff --git a/website/assets/sprites/spritesmith/achievements/achievement-alpha2x.png b/website/assets/sprites/spritesmith/achievements/achievement-alpha2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a55d68a66f6051d68e43e68caf64ac4ba18f8d9c GIT binary patch literal 3329 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 literal 0 HcmV?d00001 diff --git a/website/assets/sprites/spritesmith/achievements/achievement-partyOn2x.png b/website/assets/sprites/spritesmith/achievements/achievement-partyOn2x.png index ca224b2e7ed996223a611453949633e2be50fe26..6e5fa9c91c5f01081e6bc8c9be24ef8882bdd589 100644 GIT binary patch delta 782 zcmV+p1M&R#9rqfL83+ad007LByOFUWM+y)C24YJ`L;(K){{a7>y{D6tj13=u2oD!B zCX|Osw{PU5i2n+Jc@yJb zf6O{fJTo0zDPmH>Uj-!dCdTjHjGZR_a-zm330K6VgntU?m>-mJS4B`GCMAYmfeX4D zl6lGalb2!U2Y-GaDH(381y;D8B5^_10bT9~f-o?Du$}FF9?pl1(SC7%rmo~)4d`-T zGPtqB?;~gN8sordzqsY4B(oNVUjbe2U%R6I#@~Jt>n8hK)Xmj6GuOdr&&{HyP-JSH zd=5NkWg&fTe`-MHzD|KVQ|WbeC1qlBHyuBDHD>S20JD3C#Bg$aqS;yZoIvbZ4ahgZ z{N-}?3Vmrjy}o$Ea~S=9Te??U-0^Sqyt<6=*9*x&KYsT50TB^>eA`lb9t6fMFP#sB z^L|fktpNOav(B07bUJKYxttbHt@@X(u(j%}6Apoz`SY(R3IsuRAI1uNu-&qBmDCrm z=Yd8wAQ|L+Ub;!V*11tv-0Div6sX+my1pac1!TxQv2>S>wJ;NZ_vu~A^sVb0H#4oC zZ|*)RS9t&S#9#({P<{9 delta 1169 zcmV;C1aABH8uuNL83+Ra008?R{Nn)q zTI5;>(MMc`i0N29+|Ns9E4h|0w4-gO#5fKp*5)u;=6BHB_6%`d078Vy5 z7Z?~A85tQG8X6lL8yp-Q9UUDW9v&YbA0QwgAt50mA|fLrBP1jwB_$;$CMG8*Cn$d? zC@Co^Dk>@~D=RE4EG;c9E-o%FFE21KFflPPGBPqVGczeSLm@ zet&;|fPjF3fq{a8f`fyDgoK2Jg@uNOhKGlTh=_=ZiHVAeii?YjjEszpjg5|uj*pLz zkdTm(k&%*;l9Q8@l$4Z}m6ev3mY0{8n3$NEnVFiJnwy)OoSdAUot>VZo}ZteprD|k zp`oIpqNAguq@<*!rKP5(rl)_Wr>Ll?si~=|s;aB2tE{Z7t*x!DuCA}IuduMNv9YnT zva++Yv$V9dwY9ajwzjvox45{txw*Nzy1Ki&yS%)-y}iA@zP`V|zreu2!NI}8!otJD z!^FhI#l^+O#>U6T$H>UY$;rve%F4^j%goHo&CSiu&d$%z&(P4&(b0d=($dn?)6>+{ z)YaA1*4Eb7*VowC*xA|H+S=ON+uPjS+}+*X-rnBd-{0Wi;Njun;^N}tgww2>+9_7?CtIC?(XjI@9*&N@bU5S^78WY^Yird^!4@i_V)Jo z_xJet`1$$y`uh6&`}->V{QUj>{r>*`|NsBNspv8Q0004WQchC4Knp2nx%>vlnd40%73D57SwNP=B3(0_6Bz78N>}+00000NkvXXu0mjffO$F5 diff --git a/website/assets/sprites/spritesmith/achievements/achievement-stoikalm2x.png b/website/assets/sprites/spritesmith/achievements/achievement-stoikalm2x.png new file mode 100644 index 0000000000000000000000000000000000000000..52976cf1bae264ec716b02701e3c005a105a8e25 GIT binary patch literal 3493 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 stats page and buy up your new class's gear!", "armoireUnlocked": "For more equipment, check out the Enchanted Armoire! Click on the Enchanted Armoire Reward for a random chance at special Equipment! It may also give you random XP or food items.", - "ultimGearName": "Ultimate Gear", - "ultimGearText": "Has upgraded to the maximum weapon and armor set for the following classes:", + "ultimGearName": "Ultimate Gear - <%= ultClass %>", + "ultimGearText": "Has upgraded to the maximum weapon and armor set for the <%= ultClass %> class.", "level": "Level", "levelUp": "Level Up!", "gainedLevel": "You gained a level!", diff --git a/website/common/locales/en/contrib.json b/website/common/locales/en/contrib.json index f00382d04a..19bfc1b2b6 100644 --- a/website/common/locales/en/contrib.json +++ b/website/common/locales/en/contrib.json @@ -21,11 +21,11 @@ "contribModal": "<%= name %>, you awesome person! You're now a tier <%= level %> contributor for helping Habitica. See", "contribLink": "what prizes you've earned for your contribution!", "contribName": "Contributor", - "contribText": "Has contributed to Habitica (code, design, pixel art, legal advice, docs, etc). Want this badge? ", + "contribText": "Has contributed to Habitica (code, design, pixel art, legal advice, docs, etc). Want this badge? Read more.", "readMore": "Read More", - "kickstartName": "Kickstarter Backer - $<%= tier %> Tier", + "kickstartName": "Kickstarter Backer - $<%= key %> Tier", "kickstartText": "Backed the Kickstarter Project", - "helped": "Helped Habit Grow", + "helped": "Helped Habitica Grow", "helpedText1": "Helped Habitica grow by filling out", "helpedText2": "this survey.", "hall": "Hall of Heroes", @@ -59,7 +59,7 @@ "conLearnURL": "http://habitica.wikia.com/wiki/Contributing_to_Habitica", "conRewardsURL": "http://habitica.wikia.com/wiki/Contributor_Rewards", "surveysSingle": "Helped Habitica grow, either by filling out a survey or helping with a major testing effort. Thank you!", - "surveysMultiple": "Helped Habitica grow on <%= surveys %> occasions, either by filling out a survey or helping with a major testing effort. Thank you!", + "surveysMultiple": "Helped Habitica grow on <%= count %> occasions, either by filling out a survey or helping with a major testing effort. Thank you!", "currentSurvey": "Current Survey", "surveyWhen": "The badge will be awarded to all participants when surveys have been processed, in late March.", "blurbInbox": "This is where your private messages are stored! You can send someone a message by clicking on the envelope icon next to their name in Tavern, Party, or Guild Chat. If you've received an inappropriate PM, you should email a screenshot of it to Lemoness (leslie@habitica.com)", diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index 9e4ff7d418..ffa2270a31 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -44,6 +44,9 @@ "unorderedListMarkdown": "+ First item\n+ Second item\n+ Third item", "code": "`code`", "achievements": "Achievements", + "basicAchievs": "Basic Achievements", + "seasonalAchievs": "Seasonal Achievements", + "specialAchievs": "Special Achievements", "modalAchievement": "Achievement!", "special": "Special", "site": "Site", @@ -89,15 +92,15 @@ "originalUserText": "One of the very original early adopters. Talk about alpha tester!", "habitBirthday": "Habitica Birthday Bash", "habitBirthdayText": "Celebrated the Habitica Birthday Bash!", - "habitBirthdayPluralText": "Celebrated <%= number %> Habitica Birthday Bashes!", + "habitBirthdayPluralText": "Celebrated <%= count %> Habitica Birthday Bashes!", "habiticaDay": "Habitica Naming Day", "habiticaDaySingularText": "Celebrated Habitica's Naming Day! Thanks for being a fantastic user.", - "habiticaDayPluralText": "Celebrated <%= number %> Naming Days! Thanks for being a fantastic user.", + "habiticaDayPluralText": "Celebrated <%= count %> Naming Days! Thanks for being a fantastic user.", "achievementDilatory": "Savior of Dilatory", "achievementDilatoryText": "Helped defeat the Dread Drag'on of Dilatory during the 2014 Summer Splash Event!", "costumeContest": "Costume Contestant", "costumeContestText": "Participated in the Habitoween Costume Contest. See some of the entries on the Habitica blog!", - "costumeContestTextPlural": "Participated in <%= number %> Habitoween Costume Contests. See some of the entries on the Habitica blog!", + "costumeContestTextPlural": "Participated in <%= count %> Habitoween Costume Contests. See some of the entries on the Habitica blog!", "memberSince": "- Member since", "lastLoggedIn": "- Last logged in", "notPorted": "This feature is not yet ported from the original site.", @@ -159,7 +162,7 @@ "greeting2": "`waves frantically`", "greeting3": "Hey you!", "greetingCardAchievementTitle": "Cheery Chum", - "greetingCardAchievementText": "Hey! Hi! Hello! Sent or received <%= cards %> greeting cards.", + "greetingCardAchievementText": "Hey! Hi! Hello! Sent or received <%= count %> greeting cards.", "thankyouCard": "Thank-You Card", "thankyouCardExplanation": "You both receive the Greatly Grateful achievement!", "thankyouCardNotes": "Send a Thank-You card to a party member.", @@ -168,13 +171,13 @@ "thankyou2": "Sending you a thousand thanks.", "thankyou3": "I'm very grateful - thank you!", "thankyouCardAchievementTitle": "Greatly Grateful", - "thankyouCardAchievementText": "Thanks for being thankful! Sent or received <%= cards %> Thank-You cards.", + "thankyouCardAchievementText": "Thanks for being thankful! Sent or received <%= count %> Thank-You cards.", "birthdayCard": "Birthday Card", "birthdayCardExplanation": "You both receive the Birthday Bonanza achievement!", "birthdayCardNotes": "Send a birthday card to a party member.", "birthday0": "Happy birthday to you!", "birthdayCardAchievementTitle": "Birthday Bonanza", - "birthdayCardAchievementText": "Many happy returns! Sent or received <%= cards %> birthday cards.", + "birthdayCardAchievementText": "Many happy returns! Sent or received <%= count %> birthday cards.", "streakAchievement": "You earned a streak achievement!", "firstStreakAchievement": "21-Day Streak", "streakAchievementCount": "<%= streaks %> 21-Day Streaks", diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 8f126775f0..f0c27c1cc5 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -172,8 +172,8 @@ "requestAcceptGuidelines": "If you would like to post messages in the Tavern or any party or guild chat, please first read our <%= linkStart %>Community Guidelines<%= linkEnd %> and then click the button below to indicate that you accept them.", "partyUpName": "Party Up", "partyOnName": "Party On", - "partyUpAchievement": "Joined a Party with another person! Have fun battling monsters and supporting each other.", - "partyOnAchievement": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!", + "partyUpText": "Joined a Party with another person! Have fun battling monsters and supporting each other.", + "partyOnText": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!", "largeGroupNote": "Note: This Guild is now too large to support notifications! Be sure to check back every day to see new messages.", "groupIdRequired": "\"groupId\" must be a valid UUID", "groupNotFound": "Group not found or you don't have access.", diff --git a/website/common/locales/en/limited.json b/website/common/locales/en/limited.json index 8cf71a2052..04acbe1c71 100644 --- a/website/common/locales/en/limited.json +++ b/website/common/locales/en/limited.json @@ -3,13 +3,13 @@ "seasonalEdition": "Seasonal Edition", "winterColors": "Winter Colors", "annoyingFriends": "Annoying Friends", - "annoyingFriendsText": "Got snowballed <%= snowballs %> times by party members.", + "annoyingFriendsText": "Got snowballed <%= count %> times by party members.", "alarmingFriends": "Alarming Friends", - "alarmingFriendsText": "Got spooked <%= spookySparkles %> times by party members.", + "alarmingFriendsText": "Got spooked <%= count %> times by party members.", "agriculturalFriends": "Agricultural Friends", - "agriculturalFriendsText": "Got transformed into a flower <%= seeds %> times by party members.", + "agriculturalFriendsText": "Got transformed into a flower <%= count %> times by party members.", "aquaticFriends": "Aquatic Friends", - "aquaticFriendsText": "Got splashed <%= seafoam %> times by party members.", + "aquaticFriendsText": "Got splashed <%= count %> times by party members.", "valentineCard": "Valentine's Day Card", "valentineCardExplanation": "For enduring such a saccharine poem, you both receive the \"Adoring Friends\" badge!", "valentineCardNotes": "Send a Valentine's Day card to a party member.", @@ -18,7 +18,7 @@ "valentine2": "\"Roses are red\n\nThis poem style is old\n\nI hope that you like this\n\n'Cause it cost ten Gold.\"", "valentine3": "\"Roses are red\n\nIce Drakes are blue\n\nNo treasure is better\n\nThan time spent with you!\"", "valentineCardAchievementTitle": "Adoring Friends", - "valentineCardAchievementText": "Aww, you and your friend must really care about each other! Sent or received <%= cards %> Valentine's Day cards.", + "valentineCardAchievementText": "Aww, you and your friend must really care about each other! Sent or received <%= count %> Valentine's Day cards.", "polarBear": "Polar Bear", "turkey": "Turkey", "gildedTurkey": "Gilded Turkey", @@ -49,7 +49,7 @@ "nyeCardNotes": "Send a New Year's card to a party member.", "seasonalItems": "Seasonal Items", "nyeCardAchievementTitle": "Auld Acquaintance", - "nyeCardAchievementText": "Happy New Year! Sent or received <%= cards %> New Year's cards.", + "nyeCardAchievementText": "Happy New Year! Sent or received <%= count %> New Year's cards.", "nye0": "Happy New Year! May you slay many a bad Habit.", "nye1": "Happy New Year! May you reap many Rewards.", "nye2": "Happy New Year! May you earn many a Perfect Day.", diff --git a/website/common/locales/en/npc.json b/website/common/locales/en/npc.json index e6164e0986..aab6ad8e15 100644 --- a/website/common/locales/en/npc.json +++ b/website/common/locales/en/npc.json @@ -1,5 +1,6 @@ { "npc": "NPC", + "npcAchievementName": "<%= key %> NPC", "npcAchievementText": "Backed the Kickstarter project at the maximum level!", "mattBoch": "Matt Boch", "mattShall": "Shall I bring you your steed, <%= name %>? Once you've fed a pet enough food to turn it into a mount, it will appear here. Click a mount to saddle up!", diff --git a/website/common/locales/en/rebirth.json b/website/common/locales/en/rebirth.json index 58ea71dfa5..2a22a2ed6e 100644 --- a/website/common/locales/en/rebirth.json +++ b/website/common/locales/en/rebirth.json @@ -18,8 +18,9 @@ "rebirthAchievement100": "You've begun a new adventure! This is Rebirth <%= number %> for you, and the highest Level you've attained is 100 or higher. To stack this Achievement, begin your next new adventure when you've reached at least 100!", "rebirthBegan": "Began a New Adventure", "rebirthText": "Began <%= rebirths %> New Adventures", - "rebirthOrb": "Used an Orb of Rebirth to start over after attaining Level", - "rebirthOrb100": "Used an Orb of Rebirth to start over after attaining Level 100 or higher", + "rebirthOrb": "Used an Orb of Rebirth to start over after attaining Level <%= level %>.", + "rebirthOrb100": "Used an Orb of Rebirth to start over after attaining Level 100 or higher.", + "rebirthOrbNoLevel": "Used an Orb of Rebirth to start over.", "rebirthPop": "Begin a new character at Level 1 while retaining achievements, collectibles, equipment, and tasks with history.", "rebirthName": "Orb of Rebirth", "reborn": "Reborn, max level <%= reLevel %>", diff --git a/website/common/locales/en/tasks.json b/website/common/locales/en/tasks.json index 0793668e95..6fbd35f688 100644 --- a/website/common/locales/en/tasks.json +++ b/website/common/locales/en/tasks.json @@ -78,12 +78,13 @@ "startDate": "Start Date", "startDateHelpTitle": "When should this task start?", "startDateHelp": "Set the date for which this task takes effect. Will not be due on earlier days.", - "streakName": "Streak Achievements", - "streakText": "Has performed <%= streaks %> 21-day streaks on Dailies", + "streaks": "Streak Achievements", + "streakName": "<%= count %> Streak Achievements", + "streakText": "Has performed <%= count %> 21-day streaks on Dailies", "streakSingular": "Streaker", "streakSingularText": "Has performed a 21-day streak on a Daily", - "perfectName": "Perfect Days", - "perfectText": "Completed all active Dailies on <%= perfects %> days. With this achievement you get a +level/2 buff to all attributes for the next day. Levels greater than 100 don't have any additional effects on buffs.", + "perfectName": "<%= count %> Perfect Days", + "perfectText": "Completed all active Dailies on <%= count %> days. With this achievement you get a +level/2 buff to all attributes for the next day. Levels greater than 100 don't have any additional effects on buffs.", "perfectSingular": "Perfect Day", "perfectSingularText": "Completed all active Dailies in one day. With this achievement you get a +level/2 buff to all attributes for the next day. Levels greater than 100 don't have any additional effects on buffs.", "streakerAchievement": "You have attained the \"Streaker\" Achievement! The 21-day mark is a milestone for habit formation. You can continue to stack this Achievement for every additional 21 days, on this Daily or any other!", diff --git a/website/common/script/content/achievements.js b/website/common/script/content/achievements.js new file mode 100644 index 0000000000..4bee61a9eb --- /dev/null +++ b/website/common/script/content/achievements.js @@ -0,0 +1,188 @@ +import { each } from 'lodash'; + +let achievementsData = {}; + +let worldQuestAchievs = { + dilatoryQuest: { + icon: 'achievement-dilatory', + titleKey: 'achievementDilatory', + textKey: 'achievementDilatoryText', + }, + stressbeastQuest: { + icon: 'achievement-stoikalm', + titleKey: 'achievementStressbeast', + textKey: 'achievementStressbeastText', + }, + burnoutQuest: { + icon: 'achievement-burnout', + titleKey: 'achievementBurnout', + textKey: 'achievementBurnoutText', + }, + bewilderQuest: { + icon: 'achievement-bewilder', + titleKey: 'achievementBewilder', + textKey: 'achievementBewilderText', + }, +}; +Object.assign(achievementsData, worldQuestAchievs); + +let seasonalSpellAchievs = { + snowball: { + icon: 'achievement-snowball', + titleKey: 'annoyingFriends', + textKey: 'annoyingFriendsText', + }, + spookySparkles: { + icon: 'achievement-spookySparkles', + titleKey: 'alarmingFriends', + textKey: 'alarmingFriendsText', + }, + shinySeed: { + icon: 'achievement-shinySeed', + titleKey: 'agriculturalFriends', + textKey: 'agriculturalFriendsText', + }, + seafoam: { + icon: 'achievement-seafoam', + titleKey: 'aquaticFriends', + textKey: 'aquaticFriendsText', + }, +}; +Object.assign(achievementsData, seasonalSpellAchievs); + +let masterAchievs = { + beastMaster: { + icon: 'achievement-rat', + titleKey: 'beastMasterName', + textKey: 'beastMasterText', + text2Key: 'beastMasterText2', + }, + mountMaster: { + icon: 'achievement-wolf', + titleKey: 'mountMasterName', + textKey: 'mountMasterText', + text2Key: 'mountMasterText2', + }, + triadBingo: { + icon: 'achievement-triadbingo', + titleKey: 'triadBingoName', + textKey: 'triadBingoText', + text2Key: 'triadBingoText2', + }, +}; +Object.assign(achievementsData, masterAchievs); + +let basicAchievs = { + partyUp: { + icon: 'achievement-partyUp', + titleKey: 'partyUpName', + textKey: 'partyUpText', + }, + partyOn: { + icon: 'achievement-partyOn', + titleKey: 'partyOnName', + textKey: 'partyOnText', + }, + streak: { + icon: 'achievement-thermometer', + singularTitleKey: 'streakSingular', + singularTextKey: 'streakSingularText', + pluralTitleKey: 'streakName', + pluralTextKey: 'streakText', + }, + perfect: { + icon: 'achievement-perfect', + singularTitleKey: 'perfectSingular', + singularTextKey: 'perfectSingularText', + pluralTitleKey: 'perfectName', + pluralTextKey: 'perfectText', + }, +}; +Object.assign(achievementsData, basicAchievs); + +let specialAchievs = { + contributor: { + icon: 'achievement-boot', + titleKey: 'contribName', + textKey: 'contribText', + }, + npc: { + icon: 'achievement-ultimate-warrior', + titleKey: 'npcAchievementName', + textKey: 'npcAchievementText', + }, + kickstarter: { + icon: 'achievement-heart', + titleKey: 'kickstartName', + textKey: 'kickstartText', + }, + veteran: { + icon: 'achievement-cake', + titleKey: 'veteran', + textKey: 'veteranText', + }, + originalUser: { + icon: 'achievement-alpha', + titleKey: 'originalUser', + textKey: 'originalUserText', + }, + habitSurveys: { + icon: 'achievement-tree', + singularTitleKey: 'helped', + singularTextKey: 'surveysSingle', + pluralTitleKey: 'helped', + pluralTextKey: 'surveysMultiple', + }, +}; +Object.assign(achievementsData, specialAchievs); + +let holidayAchievs = { + habiticaDays: { + icon: 'achievement-habiticaDay', + singularTitleKey: 'habiticaDay', + singularTextKey: 'habiticaDaySingularText', + pluralTitleKey: 'habiticaDay', + pluralTextKey: 'habiticaDayPluralText', + }, + habitBirthdays: { + icon: 'achievement-habitBirthday', + singularTitleKey: 'habitBirthday', + singularTextKey: 'habitBirthdayText', + pluralTitleKey: 'habitBirthday', + pluralTextKey: 'habitBirthdayPluralText', + }, + costumeContests: { + icon: 'achievement-costumeContest', + singularTitleKey: 'costumeContest', + singularTextKey: 'costumeContestText', + pluralTitleKey: 'costumeContest', + pluralTextKey: 'costumeContestTextPlural', + }, +}; +Object.assign(achievementsData, holidayAchievs); + +let ultimateGearAchievs = ['healer', 'rogue', 'warrior', 'mage'].reduce((achievs, type) => { + achievs[`${type}UltimateGear`] = { + icon: `achievement-ultimate-${type}`, + titleKey: 'ultimGearName', + textKey: 'ultimGearText', + }; + return achievs; +}, {}); +Object.assign(achievementsData, ultimateGearAchievs); + +let cardAchievs = ['greeting', 'thankyou', 'nye', 'valentine', 'birthday'].reduce((achievs, type) => { + achievs[`${type}Cards`] = { + icon: `achievement-${type}`, + titleKey: `${type}CardAchievementTitle`, + textKey: `${type}CardAchievementText`, + }; + return achievs; +}, {}); +Object.assign(achievementsData, cardAchievs); + +each(achievementsData, (value, key) => { + value.key = key; +}); + +module.exports = achievementsData; diff --git a/website/common/script/content/index.js b/website/common/script/content/index.js index 1758a971e2..b807f7f18d 100644 --- a/website/common/script/content/index.js +++ b/website/common/script/content/index.js @@ -12,6 +12,8 @@ import { let api = module.exports; +import achievements from './achievements'; + import mysterySets from './mystery-sets'; import eggs from './eggs'; @@ -25,6 +27,8 @@ import spells from './spells'; import faq from './faq'; import loginIncentives from './loginIncentives'; +api.achievements = achievements; + api.mystery = mysterySets; api.itemList = ITEM_LIST; diff --git a/website/common/script/index.js b/website/common/script/index.js index 1d0d90c6f1..146ca35841 100644 --- a/website/common/script/index.js +++ b/website/common/script/index.js @@ -93,6 +93,9 @@ api.statsComputed = statsComputed; import shops from './libs/shops'; api.shops = shops; +import achievements from './libs/achievements'; +api.achievements = achievements; + import randomVal from './libs/randomVal'; api.randomVal = randomVal; diff --git a/website/common/script/libs/achievements.js b/website/common/script/libs/achievements.js new file mode 100644 index 0000000000..1e5583f7be --- /dev/null +++ b/website/common/script/libs/achievements.js @@ -0,0 +1,306 @@ +import content from '../content/index'; +import i18n from '../i18n'; +import { get } from 'lodash'; + +let achievs = {}; +let achievsContent = content.achievements; +let index = 0; + +function contribText (contrib, backer, language) { + if (!contrib && !backer) return; + if (backer && backer.npc) return backer.npc; + let lvl = contrib && contrib.level; + if (lvl && lvl > 0) { + let contribTitle = ''; + + if (lvl < 3) { + contribTitle = i18n.t('friend', language); + } else if (lvl < 5) { + contribTitle = i18n.t('elite', language); + } else if (lvl < 7) { + contribTitle = i18n.t('champion', language); + } else if (lvl < 8) { + contribTitle = i18n.t('legendary', language); + } else if (lvl < 9) { + contribTitle = i18n.t('guardian', language); + } else { + contribTitle = i18n.t('heroic', language); + } + + return `${contribTitle} ${contrib.text}`; + } +} + +function _add (result, data) { + result[data.key] = { + title: data.title, + text: data.text, + icon: data.icon, + earned: data.earned, + value: data.value, + index: index++, + optionalCount: data.optionalCount, + }; +} + +function _addSimpleWithCustomPath (result, user, data) { + let value = get(user, data.path); + let thisContent = achievsContent[data.key]; + + _add(result, { + title: i18n.t(thisContent.titleKey, {key: value}, data.language), + text: i18n.t(thisContent.textKey, data.language), + icon: thisContent.icon, + key: data.key, + value, + earned: Boolean(value), + }); +} + +function _addQuest (result, user, data) { + data.key = `${data.path}Quest`; + data.path = `achievements.quests.${data.path}`; + _addSimpleWithCustomPath(result, user, data); +} + +function _addSimple (result, user, data) { + let value = user.achievements[data.path]; + + let key = data.key || data.path; + let thisContent = achievsContent[key]; + + _add(result, { + title: i18n.t(thisContent.titleKey, data.language), + text: i18n.t(thisContent.textKey, data.language), + icon: thisContent.icon, + key, + value, + earned: Boolean(value), + }); +} + +function _addSimpleWithMasterCount (result, user, data) { + let language = data.language; + let value = user.achievements[`${data.path}Count`] || 0; + + let thisContent = achievsContent[data.path]; + + let text = i18n.t(thisContent.textKey, language); + if (value > 0) { + text += i18n.t(thisContent.text2Key, {count: value}, language); + } + + _add(result, { + title: i18n.t(thisContent.titleKey, language), + text, + icon: thisContent.icon, + key: data.path, + value, + optionalCount: value, + earned: Boolean(value), + }); +} + +function _addSimpleWithCount (result, user, data) { + let value = user.achievements[data.path] || 0; + + let key = data.key || data.path; + let thisContent = achievsContent[key]; + + _add(result, { + title: i18n.t(thisContent.titleKey, data.language), + text: i18n.t(thisContent.textKey, {count: value}, data.language), + icon: thisContent.icon, + key, + value, + optionalCount: value, + earned: Boolean(value), + }); +} + +function _addPlural (result, user, data) { + let value = user.achievements[data.path] || 0; + + let key = data.key || data.path; + let thisContent = achievsContent[key]; + + let titleKey; + let textKey; + // If value === 1, use singular versions of strings. + // If value !== 1, use plural versions of strings. + if (value === 1) { + titleKey = thisContent.singularTitleKey; + textKey = thisContent.singularTextKey; + } else { + titleKey = thisContent.pluralTitleKey; + textKey = thisContent.pluralTextKey; + } + + _add(result, { + title: i18n.t(titleKey, {count: value}, data.language), + text: i18n.t(textKey, {count: value}, data.language), + icon: thisContent.icon, + key, + value, + optionalCount: value, + earned: Boolean(value), + }); +} + +function _addUltimateGear (result, user, data) { + if (!data.altPath) { + data.altPath = data.path; + } + + let value = user.achievements.ultimateGearSets[data.altPath]; + + let key = `${data.path}UltimateGear`; + let thisContent = achievsContent[key]; + + let localizedClass = i18n.t(data.path, data.language); + let title = i18n.t(thisContent.titleKey, {ultClass: localizedClass}, data.language); + let text = i18n.t(thisContent.textKey, {ultClass: localizedClass}, data.language); + + _add(result, { + title, + text, + icon: thisContent.icon, + key, + value, + earned: Boolean(value), + }); +} + +function _getBasicAchievements (user, language) { + let result = {}; + + _addPlural(result, user, {path: 'streak', language}); + _addPlural(result, user, {path: 'perfect', language}); + + _addSimple(result, user, {path: 'partyUp', language}); + _addSimple(result, user, {path: 'partyOn', language}); + + _addSimpleWithMasterCount(result, user, {path: 'beastMaster', language}); + _addSimpleWithMasterCount(result, user, {path: 'mountMaster', language}); + _addSimpleWithMasterCount(result, user, {path: 'triadBingo', language}); + + _addUltimateGear(result, user, {path: 'healer', language}); + _addUltimateGear(result, user, {path: 'rogue', language}); + _addUltimateGear(result, user, {path: 'warrior', language}); + _addUltimateGear(result, user, {path: 'mage', altpath: 'wizard', language}); + + let rebirthTitle; + let rebirthText; + + if (user.achievements.rebirths > 1) { + rebirthTitle = i18n.t('rebirthText', {rebirths: user.achievements.rebirths}, language); + } else { + rebirthTitle = i18n.t('rebirthBegan', language); + } + + if (!user.achievements.rebirthLevel) { + rebirthText = i18n.t('rebirthOrbNoLevel', language); + } else if (user.achievements.rebirthLevel < 100) { + rebirthText = i18n.t('rebirthOrb', {level: user.achievements.rebirthLevel}, language); + } else { + rebirthText = i18n.t('rebirthOrb100', language); + } + + _add(result, { + key: 'rebirth', + title: rebirthTitle, + text: rebirthText, + icon: 'achievement-sun', + earned: Boolean(user.achievements.rebirths), + }); + + return result; +} + +function _getSeasonalAchievements (user, language) { + let result = {}; + + _addPlural(result, user, {path: 'habiticaDays', language}); + _addPlural(result, user, {path: 'habitBirthdays', language}); + + let spellAchievements = ['snowball', 'spookySparkles', 'shinySeed', 'seafoam']; + spellAchievements.forEach(path => { + _addSimpleWithCount(result, user, {path, language}); + }); + + let questAchievements = ['dilatory', 'stressbeast', 'burnout', 'bewilder']; + questAchievements.forEach(path => { + _addQuest(result, user, {path, language}); + }); + + _addPlural(result, user, {path: 'costumeContests', language}); + + let cardAchievements = ['greeting', 'thankyou', 'nye', 'valentine', 'birthday']; + cardAchievements.forEach(path => { + _addSimpleWithCount(result, user, {path, key: `${path}Cards`, language}); + }); + + return result; +} + +function _getSpecialAchievements (user, language) { + let result = {}; + + _addPlural(result, user, {path: 'habitSurveys', language}); + + let contribKey = 'contributor'; + let contribContent = achievsContent[contribKey]; + let contributorAchiev = { + key: contribKey, + text: i18n.t(contribContent.textKey, language), + icon: contribContent.icon, + earned: Boolean(user.contributor && user.contributor.level), + }; + if (user.contributor && user.contributor.level) { + contributorAchiev.value = user.contributor.level; + contributorAchiev.title = contribText(user.contributor, user.backer, language); + } else { + contributorAchiev.value = 0; + contributorAchiev.title = i18n.t(contribContent.titleKey, language); + } + _add(result, contributorAchiev); + + if (user.backer && user.backer.npc) { + _addSimpleWithCustomPath(result, user, {key: 'npc', path: 'backer.npc', language}); + } + + if (user.backer && user.backer.tier) { + _addSimpleWithCustomPath(result, user, {key: 'kickstarter', path: 'backer.tier', language}); + } + + if (user.achievements.veteran) { + _addSimple(result, user, {path: 'veteran', language}); + } + + if (user.achievements.originalUser) { + _addSimple(result, user, {path: 'originalUser', language}); + } + + return result; +} + +// Build and return the given user's achievement data. +achievs.getAchievementsForProfile = function getAchievementsForProfile (user, language) { + let result = { + basic: { + label: 'Basic', + achievements: _getBasicAchievements(user, language), + }, + seasonal: { + label: 'Seasonal', + achievements: _getSeasonalAchievements(user, language), + }, + special: { + label: 'Special', + achievements: _getSpecialAchievements(user, language), + }, + }; + return result; +}; + +module.exports = achievs; diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 3e5ed25927..40208a8f9f 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -17,6 +17,7 @@ import { } from '../../libs/email'; import Bluebird from 'bluebird'; import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; +import { achievements } from '../../../../website/common/'; let api = {}; @@ -58,6 +59,113 @@ api.getMember = { }, }; +/** + * @api {get} /api/v3/members/:memberId/achievements Get member achievements object + * @apiName GetMemberAchievements + * @apiGroup Member + * @apiDescription Get a list of achievements of the requested member, grouped by basic / seasonal / special. + * + * @apiParam (Path) {UUID} memberId The member's id + * + * @apiSuccess {Object} data The achievements object + * + * @apiSuccess {Object} data.basic The basic achievements object + * @apiSuccess {Object} data.seasonal The seasonal achievements object + * @apiSuccess {Object} data.special The special achievements object + * + * @apiSuccess {String} data.*.label The label for that category + * @apiSuccess {Object} data.*.achievements The achievements in that category + * + * @apiSuccess {String} data.*.achievements.title The localized title string + * @apiSuccess {String} data.*.achievements.text The localized description string + * @apiSuccess {Boolean} data.*.achievements.earned Whether the user has earned the achievement + * @apiSuccess {Number} data.*.achievements.index The unique index assigned to the achievement (only for sorting purposes) + * @apiSuccess {Anything} data.*.achievements.value The value related to the achievement (if applicable) + * @apiSuccess {Number} data.*.achievements.optionalCount The count related to the achievement (if applicable) + * + * @apiSuccessExample {json} Successful Response + * { + * basic: { + * label: "Basic", + * achievements: { + * streak: { + * title: "0 Streak Achievements", + * text: "Has performed 0 21-day streaks on Dailies", + * icon: "achievement-thermometer", + * earned: false, + * value: 0, + * index: 60, + * optionalCount: 0 + * }, + * perfect: { + * title: "5 Perfect Days", + * text: "Completed all active Dailies on 5 days. With this achievement you get a +level/2 buff to all attributes for the next day. Levels greater than 100 don't have any additional effects on buffs.", + * icon: "achievement-perfect", + * earned: true, + * value: 5, + * index: 61, + * optionalCount: 5 + * } + * } + * }, + * seasonal: { + * label: "Seasonal", + * achievements: { + * habiticaDays: { + * title: "Habitica Naming Day", + * text: "Celebrated 0 Naming Days! Thanks for being a fantastic user.", + * icon: "achievement-habiticaDay", + * earned: false, + * value: 0, + * index: 72, + * optionalCount: 0 + * } + * } + * }, + * special: { + * label: "Special", + * achievements: { + * habitSurveys: { + * title: "Helped Habitica Grow", + * text: "Helped Habitica grow on 0 occasions, either by filling out a survey or helping with a major testing effort. Thank you!", + * icon: "achievement-tree", + * earned: false, + * value: 0, + * index: 88, + * optionalCount: 0 + * } + * } + * } + * } + * + * @apiError (400) {BadRequest} MemberIdRequired The `id` param is required and must be a valid `UUID` + * @apiError (404) {NotFound} UserWithIdNotFound The `id` param did not belong to an existing member + */ +api.getMemberAchievements = { + method: 'GET', + url: '/members/:memberId/achievements', + middlewares: [], + async handler (req, res) { + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let memberId = req.params.memberId; + + let member = await User + .findById(memberId) + .select(memberFields) + .exec(); + + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + let achievsObject = achievements.getAchievementsForProfile(member, req.language); + + res.respond(200, achievsObject); + }, +}; + // Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge // type is `invites` or `members` function _getMembersForItem (type) { diff --git a/website/views/shared/profiles/achievements.jade b/website/views/shared/profiles/achievements.jade index cf6f35fb3d..085167a07c 100644 --- a/website/views/shared/profiles/achievements.jade +++ b/website/views/shared/profiles/achievements.jade @@ -1,268 +1,31 @@ -if mobile - .item.item-divider=env.t('achievements') +mixin simpleAchiev(achiev) + - var popoverHtml = '
{{achiev.title}}
{{achiev.text}}
'; + div(ng-init='earnedClass = achiev.earned ? "" : "muted"', + data-popover-html='#{popoverHtml}', + popover-placement='{{achievPopoverPlacement}}', + popover-append-to-body='{{achievAppendToBody}}')&attributes(attributes) + button.pet-button(popover-trigger='mouseenter', + data-popover-html='#{popoverHtml}', + popover-placement='{{achievPopoverPlacement}}', + popover-append-to-body='{{achievAppendToBody}}') -div(ng-if='::profile.achievements.habitSurveys || user._id == profile._id') - .achievement.achievement-tree(ng-if='::profile.achievements.habitSurveys') - div(ng-class='::{muted: !profile.achievements.habitSurveys}') - h5=env.t('helped') - small(ng-if='::profile.achievements.habitSurveys > 1') - =env.t('surveysMultiple', {surveys: "{{::profile.achievements.habitSurveys}}"}) - small(ng-if='::!(profile.achievements.habitSurveys > 1)') - =env.t('surveysSingle') - hr + .achievement(ng-class='achiev.icon + "2x"', ng-if='achiev.earned') + .counter.badge.badge-info.stack-count(ng-if='(achiev.optionalCount)') + {{::achiev.optionalCount}} + .achievement(class='achievement-unearned2x', ng-if='!(achiev.earned)') -div(ng-if='::profile.backer.npc') - .achievement.achievement-helm - h5 - span.label.label-npc - | {{::profile.backer.npc}} - =env.t('npc') - small=env.t('npcAchievementText') - hr -div(ng-if='::profile.contributor.level || user._id == profile._id') - .achievement.achievement-boot(ng-if='::profile.contributor.level') - div(ng-class='::{muted: !profile.contributor.level}') - h5 - span.label.label-default(ng-if='::profile.contributor.level', class='label-contributor-{{::profile.contributor.level}}') {{::contribText(profile.contributor, profile.backer)}} - span.label.label-default(ng-if='::!profile.contributor.level')=env.t('contribName') - small - =env.t('contribText') - |  - a(href=env.t('conLearnURL'), target='_blank') - =env.t('readMore') - | . - hr +.container-fluid + .row + .col-md-12(ng-repeat='(key,cat) in achievements', ng-init='heading=env.t(key+"Achievs")') + h4 {{heading}} + menu.pets.inventory-list(type='list') + li.customize-menu + menu + div(ng-repeat='achiev in cat.achievements | toArray | orderBy: "index"') + +simpleAchiev('achiev') -div(ng-if='::profile.backer.tier') - .achievement.achievement-heart - h5=env.t('kickstartName', {tier: "{{::profile.backer.tier}}"}) - small=env.t('kickstartText') - hr +hr -div(ng-if='::profile.achievements.streak || user._id == profile._id') - .achievement.achievement-thermometer(ng-if='::profile.achievements.streak') - div(ng-class='::{muted: !profile.achievements.streak}') - h5(ng-if='::profile.achievements.streak > 1 || !profile.achievements.streak') - - | {{::profile.achievements.streak || 0 }}  - =env.t('streakName') - small(ng-if='::profile.achievements.streak > 1 || !profile.achievements.streak')=env.t('streakText', {streaks: "{{::profile.achievements.streak || 0 }}"}) - h5(ng-if='::profile.achievements.streak == 1') - =env.t('streakSingular') - small(ng-if='::profile.achievements.streak == 1')=env.t('streakSingularText') - hr - -div(ng-if='::profile.achievements.perfect || user._id == profile._id') - .achievement.achievement-perfect(ng-if='::profile.achievements.perfect') - div(ng-class='::{muted: !profile.achievements.perfect}') - h5(ng-if='::profile.achievements.perfect > 1 || !profile.achievements.perfect') - - | {{::profile.achievements.perfect || 0 }}  - =env.t('perfectName') - small(ng-if='::profile.achievements.perfect > 1 || !profile.achievements.perfect')=env.t('perfectText', {perfects: "{{::profile.achievements.perfect || 0 }}"}) - h5(ng-if='::profile.achievements.perfect == 1') - =env.t('perfectSingular') - small(ng-if='::profile.achievements.perfect == 1')=env.t('perfectSingularText') - hr - -- var ultimateGearCheck = 'profile.achievements.ultimateGearSets.healer || profile.achievements.ultimateGearSets.wizard || profile.achievements.ultimateGearSets.rogue || profile.achievements.ultimateGearSets.warrior' -div(ng-if='(user._id == profile._id) || #{ultimateGearCheck}') - .achievement.achievement-armor(ng-if='#{ultimateGearCheck}') - div(ng-class='::{muted: !(#{ultimateGearCheck})}') - h5=env.t('ultimGearName') - small=env.t('ultimGearText') - table.multi-achievement - tr - td(ng-if='::profile.achievements.ultimateGearSets.healer').multi-achievement - .achievement-ultimate-healer.multi-achievement - =env.t('healer') - td(ng-if='::profile.achievements.ultimateGearSets.wizard').multi-achievement - .achievement-ultimate-mage.multi-achievement - =env.t('mage') - td(ng-if='::profile.achievements.ultimateGearSets.rogue').multi-achievement - .achievement-ultimate-rogue.multi-achievement - =env.t('rogue') - td(ng-if='::profile.achievements.ultimateGearSets.warrior').multi-achievement - .achievement-ultimate-warrior.multi-achievement - =env.t('warrior') - hr - -div(ng-if='::profile.achievements.partyUp || user._id == profile._id') - .achievement.achievement-partyUp(ng-if='::profile.achievements.partyUp') - div(ng-class='::{muted: !profile.achievements.partyUp}') - h5=env.t('partyUpName') - small=env.t('partyUpAchievement') - hr - -div(ng-if='::profile.achievements.partyOn || user._id == profile._id') - .achievement.achievement-partyOn(ng-if='::profile.achievements.partyOn') - div(ng-class='::{muted: !profile.achievements.partyOn}') - h5=env.t('partyOnName') - small=env.t('partyOnAchievement') - hr - -div(ng-if='::profile.achievements.beastMaster || user._id == profile._id') - .achievement.achievement-rat(ng-if='::profile.achievements.beastMaster') - div(ng-class='::{muted: !profile.achievements.beastMaster}') - h5=env.t('beastMasterName') - small=env.t('beastMasterText') - small(ng-if='::profile.achievements.beastMasterCount') - =env.t('beastMasterText2', {count: "{{::profile.achievements.beastMasterCount}}"}) - hr - -div(ng-if='::profile.achievements.mountMaster || user._id == profile._id') - .achievement.achievement-wolf(ng-if='::profile.achievements.mountMaster') - div(ng-class='::{muted: !profile.achievements.mountMaster}') - h5=env.t('mountMasterName') - small=env.t('mountMasterText') - small(ng-if='::profile.achievements.mountMasterCount') - =env.t('mountMasterText2', {count: "{{::profile.achievements.mountMasterCount}}"}) - hr - -div(ng-if='::profile.achievements.triadBingo || user._id == profile._id') - .achievement.achievement-triadbingo(ng-if='::profile.achievements.triadBingo') - div(ng-class='::{muted: !profile.achievements.triadBingo}') - h5=env.t('triadBingoName') - - small=env.t('triadBingoText') - small(ng-if='::profile.achievements.triadBingoCount') - =env.t('triadBingoText2', {count: "{{::profile.achievements.triadBingoCount}}"}) - hr - -div(ng-if='::profile.achievements.rebirths') - .achievement.achievement-sun - h5(ng-if='::profile.achievements.rebirths == 1')=env.t('rebirthBegan') - h5(ng-if='::profile.achievements.rebirths > 1') - =env.t('rebirthText', {rebirths: "{{::profile.achievements.rebirths}}"}) - small(ng-if='::profile.achievements.rebirthLevel < 100') - =env.t('rebirthOrb') - | {{::profile.achievements.rebirthLevel}}. - small(ng-if='::profile.achievements.rebirthLevel >= 100') - =env.t('rebirthOrb100') - - hr - -div(ng-if='::profile.achievements.veteran') - .achievement.achievement-cake - div(ng-if='::profile.achievements.veteran') - h5=env.t('veteran') - small=env.t('veteranText') - hr - -div(ng-if='::profile.achievements.originalUser') - .achievement.achievement-alpha - div(ng-if='::profile.achievements.originalUser') - h5=env.t('originalUser') - small!=env.t('originalUserText') - hr - -div(ng-if='::profile.achievements.challenges.length || user._id == profile._id') - // This is a very strange icon to use. revisit - .achievement.achievement-karaoke(ng-if='::profile.achievements.challenges.length') - div(ng-class='::{muted: !profile.achievements.challenges.length}') - h5=env.t('challengeWinner') - table.table.table-striped - tr(ng-repeat='chal in profile.achievements.challenges track by $index') - td: markdown(text='::chal') - hr - -div(ng-if='::profile.achievements.quests || user._id == profile._id') - .achievement.achievement-alien(ng-if='::profile.achievements.quests') - div(ng-class='::{muted: !profile.achievements.quests}') - h5=env.t('completedQuests') - table.table.table-striped - tr(ng-repeat='(k,v) in profile.achievements.quests') - td {{::Content.quests[k].text()}} - td x{{::v}} - hr - -div(ng-if='::profile.achievements.snowball') - .achievement.achievement-snowball - h5=env.t('annoyingFriends') - small - =env.t('annoyingFriendsText', {snowballs: "{{::profile.achievements.snowball}}"}) - hr - -div(ng-if='::profile.achievements.spookySparkles') - .achievement.achievement-spookySparkles - h5=env.t('alarmingFriends') - small - =env.t('alarmingFriendsText', {spookySparkles: "{{::profile.achievements.spookySparkles}}"}) - hr - -div(ng-if='::profile.achievements.shinySeed') - .achievement.achievement-shinySeed - h5=env.t('agriculturalFriends') - small - =env.t('agriculturalFriendsText', {seeds: "{{::profile.achievements.shinySeed}}"}) - hr - -div(ng-if='::profile.achievements.seafoam') - .achievement.achievement-seafoam - h5=env.t('aquaticFriends') - small - =env.t('aquaticFriendsText', {seafoam: "{{::profile.achievements.seafoam}}"}) - hr - -div(ng-if='::profile.achievements.habiticaDays') - .achievement.achievement-habiticaDay - h5=env.t('habiticaDay') - small(ng-if='::profile.achievements.habiticaDays == 1') - =env.t('habiticaDaySingularText') - small(ng-if='::profile.achievements.habiticaDays > 1') - =env.t('habiticaDayPluralText', {number: "{{::profile.achievements.habiticaDays}}"}) - hr - -div(ng-if='::profile.achievements.habitBirthdays') - .achievement.achievement-habitBirthday - h5=env.t('habitBirthday') - small(ng-if='::profile.achievements.habitBirthdays == 1') - =env.t('habitBirthdayText') - small(ng-if='::profile.achievements.habitBirthdays > 1') - =env.t('habitBirthdayPluralText', {number: "{{::profile.achievements.habitBirthdays}}"}) - hr - -div(ng-if='::profile.achievements.quests.dilatory') - .achievement.achievement-dilatory - h5=env.t('achievementDilatory') - small - =env.t('achievementDilatoryText') - hr - -div(ng-if='::profile.achievements.quests.stressbeast') - .achievement.achievement-stoikalm - h5=env.t('achievementStressbeast') - small - =env.t('achievementStressbeastText') - hr - -div(ng-if='::profile.achievements.quests.burnout') - .achievement.achievement-burnout - h5=env.t('achievementBurnout') - small - =env.t('achievementBurnoutText') - hr - -div(ng-if='::profile.achievements.quests.bewilder') - .achievement.achievement-bewilder - h5=env.t('achievementBewilder') - small - =env.t('achievementBewilderText') - hr - -div(ng-if='::profile.achievements.costumeContests') - .achievement.achievement-costumeContest - h5=env.t('costumeContest') - small(ng-if='::profile.achievements.costumeContests === 1') - !=env.t('costumeContestText') - small(ng-if='::profile.achievements.costumeContests > 1') - !=env.t('costumeContestTextPlural', {number: "{{::profile.achievements.costumeContests}}"}) - hr - -each card in ['greeting', 'thankyou', 'nye', 'valentine', 'birthday'] - div(ng-if='::profile.achievements.#{card}') - div(class='achievement achievement-#{card}') - h5=env.t(card + 'CardAchievementTitle') - small=env.t(card + 'CardAchievementText', {cards: "{{::profile.achievements." + card + "}}"}) - hr +include ./achievs/challenges +include ./achievs/quests diff --git a/website/views/shared/profiles/achievs/challenges.jade b/website/views/shared/profiles/achievs/challenges.jade new file mode 100644 index 0000000000..dc0cffa6f1 --- /dev/null +++ b/website/views/shared/profiles/achievs/challenges.jade @@ -0,0 +1,9 @@ +div + // This is a very strange icon to use. revisit + .achievement.achievement-karaoke(ng-if='::profile.achievements.challenges.length') + div(ng-class='::{muted: !profile.achievements.challenges.length}') + h5=env.t('challengeWinner') + table.table.table-striped + tr(ng-repeat='chal in profile.achievements.challenges track by $index') + td: markdown(text='::chal') + hr diff --git a/website/views/shared/profiles/achievs/quests.jade b/website/views/shared/profiles/achievs/quests.jade new file mode 100644 index 0000000000..fe6773a2c9 --- /dev/null +++ b/website/views/shared/profiles/achievs/quests.jade @@ -0,0 +1,9 @@ +div + .achievement.achievement-alien(ng-if='::profile.achievements.quests') + div(ng-class='::{muted: !profile.achievements.quests}') + h5=env.t('completedQuests') + table.table.table-striped + tr(ng-repeat='(k,v) in profile.achievements.quests') + td {{::Content.quests[k].text()}} + td x{{::v}} + hr