diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 0000000000..4a52096b99 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "website/public/bower_components" +} diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000000..b57b4bd3b0 --- /dev/null +++ b/.buildpacks @@ -0,0 +1,2 @@ +https://github.com/heroku/heroku-buildpack-nodejs.git +https://github.com/stomita/heroku-buildpack-phantomjs.git diff --git a/.gitignore b/.gitignore index 03a0cf1740..1cb23ccadb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,25 @@ -coverage.html +.DS_Store +website/public/gen +website/public/common node_modules +*.swp +.idea* +config.json npm-debug.log -.idea -.swo -.swp +lib +website/public/bower_components +website/build +newrelic_agent.log +.bower-tmp +.bower-registry +.bower-cache +.vagrant + +*.log +src/*/*.map +src/*/*/*.map +test/*.js +test/*.map +website/public/docs +*.sublime-workspace +coverage.html diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000000..d1064e9ba9 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,12 @@ +{ + "globals": { + "confirm": false + }, + + "browser": true, + "node": true, + + "asi": true, + "boss": true, + "newcap": false +} diff --git a/.nodemonignore b/.nodemonignore new file mode 100644 index 0000000000..5aa436cfa3 --- /dev/null +++ b/.nodemonignore @@ -0,0 +1,15 @@ +node_modules/** +.bower-cache/** +.bower-tmp/** +.bower-registry/** +website/public/** +website/views/** +website/build/** +.git/** +Gruntfile.js +CHANGELOG.md +.idea* +*.log +newrelic_agent.log +*.swp +*.swx diff --git a/.travis.yml b/.travis.yml index 6e5919de39..3f33964032 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,9 @@ language: node_js node_js: - - "0.10" + - '0.10' + - mongodb +before_script: + - 'npm install -g grunt-cli mocha' + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + - cp config.json.example config.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..bacc54d3f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,282 @@ +My app - Changelog +# (2014-02-15) + + +## Documentation + +- **rebirth:** Bullet point about repurchase of limited ed gear after Rebirth + ([d3f4a561](watch/commits/d3f4a561fdf137e5d8f406bae03be4fef1caff22)) + + +## Bug Fixes + +- **#2003:** healer gear not showing + ([949cd97b](watch/commits/949cd97b91b42e9450eba559bbfea17e239ab100)) +- **#2375:** merge in @SabreCat's stats.jade changes "More elegant show/hide setup for attribute bonuses" + ([518f200a](watch/commits/518f200a8fc7373b44ed7d7b5f016d921b0746bd)) +- **allocateNow:** Send empty object to user.ops per @colegleason suggestion + ([f6e12fa2](watch/commits/f6e12fa25e4366622db3e6f1b6ab03e848b49e10)) +- **batch-update:** send errors to client if batch-update found an error, crash pid. + ([f9679629](watch/commits/f967962996be69a5335454610af76d10e1db08b8)) +- **beastmaster:** fixes #2557, adds opacity to previously-owned pets after they're mounted. You can earn them back again + ([5caaff1c](watch/commits/5caaff1cea1a68fe572e7ddf4aac50248b13df5d)) +- **bosses:** don't reset progress.up when starting a new quest. We want to be able to carry over damage from the same day a boss battle begins, even if the dailies were completed before battle-start. Fixes #2168 + ([4efd0f5e](watch/commits/4efd0f5ed8708f2491dd483f93e3d7a268a6337d)) +- **bower:** updated jquery directory + ([191b789d](watch/commits/191b789d760a7bdc7d1b53727f6127b677c78c94)) +- **bs3:** + - fix to MemberModalCtrl parameter + ([ebd1df93](watch/commits/ebd1df932e28263e5cc01e8a35f545ab26f1e8bd)) + - port pet feeding bar + ([5db96ebc](watch/commits/5db96ebca2fbd5b64f49af03a5137ea80f6b1673)) +- **buffs:** Move help bubble to left of special buffs + ([4f911a68](watch/commits/4f911a68d805742e6744383948eea6f224f2b0ea)) +- **challenges:** + - better handling of deleted challenges. If !chal, break the task.challenge. Move the function into userController#score so we have access to next, etc. fixes #1883 + ([33b326b5](watch/commits/33b326b59685ea6e50f9950094d009460ce80094)) + - challenge csv export now has proper filename + ([36f21196](watch/commits/36f21196f466260b7cd52b283c50b9e16943f668), + [#2689](watch/issues/2689)) +- **classes:** + - misc fixes + ([d2121a85](watch/commits/d2121a858716cb5a532a53ee9c5a1adaa74a7f69)) + - misc class fixes (not @snicker, ng-if on item store since we dynamically swap it sometimes) + ([478be611](watch/commits/478be6111337cd200374f7f31b959725c6a0b945)) +- **css:** + - temp fix for bailey height + ([c8faffcc](watch/commits/c8faffcc7289090990c3a17ab8c07a00069f5ce4)) + - menu and gems wallet margin + ([975b5165](watch/commits/975b5165730477310aa64bac27ddc07a34ea6c1d)) + - lighter columns title + ([a22e2814](watch/commits/a22e28143f74302c8340c3d33b01af9714875523)) + - better food tray + ([1c41c4dd](watch/commits/1c41c4ddb9a5b04297a371bc4d6aba013ce33f17)) +- **errors:** + - `return next(err)` when experiencing errors, instead of res.json(500,{err:err}). Let the top-level error handler handle this (needed for upcoming versionerror discarding) + ([bf5e9016](watch/commits/bf5e9016a4cb7889b3a9e39b90eb35cb8f7f9ec8)) + - handle if err.message == undefined, send err + ([b42dacf2](watch/commits/b42dacf2035d62453b585cfcf453829a423b59de)) +- **event-tracking:** + - typo + ([ff9d4b88](watch/commits/ff9d4b886ef7a98da0514975441a8bb845496c31)) + - stripe sub, not pp + ([0c99976b](watch/commits/0c99976bf5a3c7f04f031d62a8b07c862c85a0a9)) +- **find_uniq_user:** fix + ([ecbe780e](watch/commits/ecbe780e70549b1470504efe052f238c89a9db14)) +- **footer:** ensure window.env is accessible from static pages, so we can get deferred scripts on frontpage (esp google analytics) + ([67ee011a](watch/commits/67ee011aa35969db93e2d7dc1cd1e1f587f146de)) +- **groups:** + - pass missing next into Like function + ([afee0968](watch/commits/afee0968f8f6923847e186d3e11b9745ced9606e)) + - send error if +1 errored + ([5b6c4427](watch/commits/5b6c4427b504b6143f24bfee314f562b9803c5a4)) +- **hall:** let's try $gt instead of $ne:null, the query is still slow + ([a72b0131](watch/commits/a72b013131cfc7fa5d3affdbfe59b5b3cb15ae89)) +- **i18n:** do not save user language for now + ([094a4be0](watch/commits/094a4be0015f0f0deaaf94a0734193eb40a8beae)) +- **misc:** + - some styles & translations + ([8f19f225](watch/commits/8f19f225f104960b3cf27e229a5571e014be697c)) + - isStaticPage and debug buttons + ([19139f56](watch/commits/19139f562b8e68ed43f4cab748920f1e0634e86e)) +- **missing-gems:** remove ad-removal from script, since ads are part of subscription + ([e1240dde](watch/commits/e1240dde1d3dcaca4235fad384fea5c07a3706bf)) +- **mongoose:** typo + ([2786b362](watch/commits/2786b362067efdd245c3efa3a4891021fcfaab2d)) +- **mounts:** + - fix pets & mounts css to position the user based on pet/mount equip + ([37340d23](watch/commits/37340d23180da02d3742dc9be40a5fb780ecb13b)) + - Move avatar upward when mounted regardless of pet + ([bc1adeb1](watch/commits/bc1adeb1277103a5ca1f756e175ed68bbe837a2f)) +- **nodemon:** + - Add another ignore for weirdsauce Windoze dev environments + ([3fda08c3](watch/commits/3fda08c366793c8fbcbf701a9594ae3b2fd8bbea)) + - ignore CHANGELOG.md on watch + ([d6c55952](watch/commits/d6c55952da8b49f36e9d8e4570d80931d081343d)) +- **party:** Round boss health up instead of to nearest integer + ([626da568](watch/commits/626da5681f5ea95700f8ddf40587c7184926971c), + [#2504](watch/issues/2504)) +- **paypal:** fixes #2492, remove environment check for now, only have production-mode option. revisit + ([1dc68112](watch/commits/1dc68112d131e4ebdec32ddff938eb6311d6565f)) +- **performance:** cache spritesmith image, fix #2633 + ([f03d7d7d](watch/commits/f03d7d7dde4f8cb39babd2b982d77e7f88f349b7)) +- **pets:** add questPets to UserSchema.items.mounts too fixes #2814 + ([42766125](watch/commits/42766125d5c8870f25c3a0a001473f700b8f6cc1)) +- **profile:** fix bug where empty profile displayed on username click + ([0579c432](watch/commits/0579c432489c4a038e8c9f95ea3b285f5abc146f), + [#2465](watch/issues/2465)) +- **quests:** + - quests with a level cap cannot be bought before that level. + ([dab9ddbd](watch/commits/dab9ddbda27f5e10e4545fea703deebfe2dd9975), + [#2707](watch/issues/2707)) + - bug fix to multi-drop + ([f478d10c](watch/commits/f478d10c20f816cd104b3f0da814c189957f45f5)) + - list multiple rewards in dialog + ([e48c7277](watch/commits/e48c7277f8256cf827790aece51e897fe0439374)) +- **readme:** remove text about translations wip + ([f2bb1fd2](watch/commits/f2bb1fd26e44a9eb0ba325776bf335e021beeece)) +- **settings:** + - remove unnecessary code + ([5f0cf657](watch/commits/5f0cf6575c0dc4cfc041956e3dc27898d8b4242d)) + - reintroduce space between captions and help bubbles stripped during localization + ([5ddf09fe](watch/commits/5ddf09fe13c7f8d844c8c47be0fb8f8b2fd1df33)) +- **spells:** + - temp workaround for spell & task being undefined. #2649 #2640 + ([241d0414](watch/commits/241d04140f5db77929d9f597d232f55843bb0f5d)) + - more $rootScope spell-casting bug fixes + ([47bd6dcb](watch/commits/47bd6dcb79778d90d6f3ddeb003c3d8e45433333)) + - add some spells tests, don't send up body to spell paths + ([e0646bb9](watch/commits/e0646bb98d44b6874b5259107c9be5fa34c58933)) + - some $rootScope.applying action fixes so cast-ending is immediate instead of waiting on response. Also, slim down party population to the essentials to avoid RequestEntityTooLarge + ([c6f7ab8a](watch/commits/c6f7ab8a5c6f4e382208a928b90ba5f4eba9cd37)) + - to cancel spell-casting + ([a1df41ad](watch/commits/a1df41ad8165cd9eb6d2d5d59c7fe404edde716c)) +- **stable:** show hatchable combo when petOwned>0 (fyi @deilann) + ([51bff238](watch/commits/51bff23885ca0080e7e71ff752daa0950ae923ae)) +- **stats:** Better layout for attribute point allocation + ([d782fc6b](watch/commits/d782fc6b6a3cd7e90d327c93a5764626b2990c74)) +- **swagger:** fix jade script warning in swagger + ([2e2fcfcf](watch/commits/2e2fcfcf464fbae21bff9e1be1ca915f071b976b)) +- **tests:** + - include select2 in test manifest + ([38b4cea7](watch/commits/38b4cea73299f51c4db7f6b2eb12533d219745f8)) + - don't use cluster in tests, else we get "connection refused" + ([7a479098](watch/commits/7a479098dc6535654e322c737d80813790967941)) +- **todos:** + - add migration for dateCreated & dateCompleted #2478 + ([4cc39f16](watch/commits/4cc39f16a13f5fb9f0e3ddde7d274c0f224f4a0e)) + - add dateCompleted to todos so they're archived 3 days after completion, not 3 days after creation. Fixes #2478 + ([b1afc177](watch/commits/b1afc177aa4bfc4cbd9b847e40431db91666d9c3)) +- **toolbar:** + - Tweak Settings drop-down + ([e241429c](watch/commits/e241429cc3d2eca18d2f5a9726f6caa6270a1b02)) + - Tweak icon popovers + ([4454204f](watch/commits/4454204f47f80e64119f7896bf246259173d115b)) + - tweaks + ([5501d57e](watch/commits/5501d57e107c0bc7085847b0c808f027360fa405)) +- **translation:** Fix #2585. + ([06200acc](watch/commits/06200accada462c3234ab407cfb0f6b684e5effe)) +- **translations:** + - fix #2564 and similar ones + ([42740902](watch/commits/42740902055a3807532028a5dfb39eff905c104f)) + - add env.t to rootScope + ([13131087](watch/commits/13131087ff9563d2d174b2c978102f0dc2b87387)) + - remove translations for privacy & terms + ([a9095f34](watch/commits/a9095f346479336be13b2bf341666b908fa30b3d)) + - merge @luveluen pull request, fix some syntax + ([a6c67f17](watch/commits/a6c67f17815558f19895b8f67d29c40c14689f09)) + - @lefnire now everything is ok + ([52decb7e](watch/commits/52decb7edeefb4755ea832b0cf63eaeea5e93259)) + - correct some variables + ([fba73953](watch/commits/fba739535bc1b630d73eb469448e9c3706043efd)) + - revert some views + ([d000c706](watch/commits/d000c70679ae0e13d9bec749295e42cc8e299c95)) +- **user:** + - make sure next is passed to all routes, and is available in err-back of batch updates + ([0c21f54c](watch/commits/0c21f54c67b52b07c417fd8216c6b04bce59d0ab)) + - if need to upgrade site, send 501, not 400 + ([ab86ba11](watch/commits/ab86ba11bdb3379a8d8fa1814879640d61c57227)) + - PUT user retricted path errors are 401, not 500 + ([0aec4caa](watch/commits/0aec4caa785c3b12e15f1c2e19c5b67b20d1a6e1)) +- **winston:** typo + ([83b3739f](watch/commits/83b3739f4671a08466e057242f936140d5c739ef)) + + +## Features + +- **administrators:** start adding features page for admin accounts + ([f7f4a0c1](watch/commits/f7f4a0c166558ba7e5461732f7bb6d7bcac25f88)) +- **attributes:** Add backfill button in flat and classbased allocation modes + ([76a7ab5b](watch/commits/76a7ab5bcce2d486dab3f447f0659ba870d1ff7e)) +- **bailey:** notif about STWC updates + scroll-purchase deadlines (@colegleason) + ([90176444](watch/commits/90176444e9c7a318040829e8b71d1493b5d58e9e)) +- **bug-crushing:** add the critical hammer of bug-crushing + ([00af5f7d](watch/commits/00af5f7d0258b0f7dddef8ede40bd825b057748a)) +- **challenges:** + - add angular-ui-select2 for simpler find/select challenge winner. + ([9fa45217](watch/commits/9fa452173989889c48ed696a45cf4a1dc16294a4)) + - add button for csv export + ([ae0d758d](watch/commits/ae0d758d8fc751219a693fee7f3e3ebcfbd67590)) + - add csv export for challenge progress. WIP, will refine this over time - but we need it something like this for the STWC come 1/31. + ([16a602f9](watch/commits/16a602f94c3b7c99d49e42b47b4835b65a243690)) + - markdown in challenge-descriptions + ([41233c7b](watch/commits/41233c7b167905eeccfdff5589789e002ec23f97)) +- **cheating:** prevent +habit spamming with a 10s timer + ([ad4ca665](watch/commits/ad4ca6655a3bdd870bb08173530372f81fdc9102)) +- **event-tracking:** + - better page-view tracking via ui-router + ([b093717b](watch/commits/b093717b8d54b61e5d4b44b0d56a1f43308f078c)) + - track registration count + ([72b6c9bc](watch/commits/72b6c9bc9189275909804f9ecab18e9fe1f69d27)) + - pass ga to server user.ops + ([9217b517](watch/commits/9217b5174ab9ab4754269263b214f6bfe45d4f1d)) + - track ecommerce events + ([d89fb17b](watch/commits/d89fb17b03b2e2c0fb1da77fb13cc660a5b6c9d1)) + - add server-side GA tracking for ecommerce events + ([f7b4a04a](watch/commits/f7b4a04a590ade26871abc726ade2c666176488e)) + - start adding some client-side GA event-tracking + ([ffb42906](watch/commits/ffb42906e1d7c6bd8f01e715d98d96426bc6d0de)) +- **groups:** add group chat notifications + ([ce82be63](watch/commits/ce82be637d1d707e899aeee5f315da69367fa367)) +- **habitBirthday:** add habitrpg birthday event. includes cakes for all pets, absurd party robes, npc swap, badge, etc. @lemoness + ([aff885c0](watch/commits/aff885c05c03bd70beeb0db8d68922671fc46309)) +- **homepage:** + - start cleaning up homepage, add navbar for play button & upcoming links + ([0ddaae4d](watch/commits/0ddaae4d7525277e696a57d20234e49cd6fd1cbc)) + - use .marketing for centering, add playbutton as static in footer. This is pretty ugly (http://gyazo.com/215e20729569689ab48cf56c71c1fe28), let's iterate / prettify. @deilann + ([47bcaf83](watch/commits/47bcaf83e760dbb266ae7ff2f7299c2a1cdf3712)) +- **marketing:** + - more copy for mobile + ([cbb44847](watch/commits/cbb448478edfd0003c43d20ed216bab20d25dadd)) + - start fleshing out the about page with images, content, etc. Create separate videos page + ([cb079977](watch/commits/cb079977e6f35f9308ab28158373dd3e1de9f798)) + - add video tuts on "learn more" page until we have some copy + ([5028707c](watch/commits/5028707c7b174b5e050c7c1662155e781a6b415b)) + - some frontpage updates, a screenshot, & "contact us" button mods + ([a582a054](watch/commits/a582a0546d680d36a21c507deff725a6c38fdb28)) +- **premium:** + - subscriber mystery item (doesn't yet do anything) + ([d0342628](watch/commits/d0342628340ce7dce95fa20177ccbcfe1ebf93e6)) + - backport server code for premium subs (it's just ccard handling & uer model stuff) + ([3660f1a8](watch/commits/3660f1a85c1447de118f334a145d0d7698b93981)) + - updates to group plans info page + ([66f95cdd](watch/commits/66f95cdd4cfb698fddc765a77b66d29e31eb1361)) + - backport client-side premium code to public repo, it's client-side anyway (@colegleason @paglias) + ([2e18f0eb](watch/commits/2e18f0eb82f5efc77544d33d1db3fbb9cc583124)) +- **quests:** + - add flags.levelDrops for dropping items at certain levels + ([78315d82](watch/commits/78315d828ba9a1033526b9a72b7c385281e6ad0a)) + - allow dropping scrolls in quests + ([54064deb](watch/commits/54064debf3c95390b5507acd826f9db3339b9f09)) + - allow html in quest notes + ([800231cf](watch/commits/800231cf6481351032d4e5143edd54f5e7e3a179)) + - add level requirement for quests + ([9e69d795](watch/commits/9e69d7959f174955f44429a94f22ce40fc5f7861)) + - add canBuy so we can exclude certain items from the market (if you can only find them on quest-drop, etc). This isn't the prettiest, change? + ([f16654d2](watch/commits/f16654d2354dc86cc7c52e1cf0562f850cf203be)) + - allow quests to drop multiple items + ([d9e5725e](watch/commits/d9e5725ee13f7e9ad329fc548537d5265cf483ca)) +- **rainbow-hair:** add rainbow hair colors + ([82d9233d](watch/commits/82d9233d99167d6704c878884dcc49a55cc7d884)) +- **restore:** add restore-gp back in. Parly to end the winter event, partly due to the convo at #2681 regarding subscriptions. Fixes #2681 + ([179316e1](watch/commits/179316e10fa7597b08573d94721861baa3dbbb1c)) +- **toolbar:** + - try with icons instead of text, test against prod / beta & get a vote. + ([7456f00d](watch/commits/7456f00dc6122ad293652b7a32fb4ce671f75241)) + - add toolbar featuring navigation, gems / subs, bailey, & chat / invite notifications + ([f72cb213](watch/commits/f72cb21300c078b439b3334bfa3e205ba04dc949)) +- **tracking:** gems > toolbar separately from wallet + ([f6abfc67](watch/commits/f6abfc67b31808c0e2d325c235747260855338c9)) +- **valentine:** valentine event + ([fd6eb872](watch/commits/fd6eb8724eae38d02849ffccb09f1f9c7d8e490d)) +- **winter:** + - remove purchasable winter hair colors, keep available if they purchased during event + ([f8796e90](watch/commits/f8796e9028d4f4cd2b5c5ede1734d2876d174dc9)) + - remove winter scrolls & snowballs + ([52f8f0d5](watch/commits/52f8f0d5b0fdf4271fcb5f7d497ad3bf544c24e8)) + + +## Docs + +- **rebirth:** Bullet point about repurchase of limited ed gear after Rebirth + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..b3259456fb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Reporting Bugs + +[Please see these instructions for reporting bugs](https://github.com/HabitRPG/habitrpg/issues/2760) + +## Frequently Asked Questions +You might find help with your issue on the [Frequently Asked Questions](http://habitrpg.wikia.com/wiki/FAQ) page. + +# Requesting a feature + +HabitRPG uses [Trello](https://trello.com/b/EpoYEYod/habitrpg) to track feature requests. [Read more](https://trello.com/c/odmhIqyW/440-read-first-table-of-contents). + +# Contributing Code + +See [Contributing to HabitRPG](http://habitrpg.wikia.com/wiki/Contributing_to_HabitRPG#Coders_.28Web_.26_Mobile.29) diff --git a/DOCS-README.md b/DOCS-README.md new file mode 100644 index 0000000000..e21c420b16 --- /dev/null +++ b/DOCS-README.md @@ -0,0 +1,77 @@ +# HabitRPG Docs Project + +Generated documentation for all of HabitRPG's source files will be kept in the folder and subfolders. If you would like to use the existing documentation, or contribute to the documentation efforts, read on. + +## Viewing Docs + +You're looking at it! + +Unless you are viewing this file directly from GitHub, you should see a list of files and folders to the left of this readme. + +If you are working locally, you can goto `localhost:3000/docs/` and view the Docs. + +All documentation is generated from comments in the code, into HTML files in the `public/docs/` folder. After you have cloned the HabitRPG repo locally, and done all the `npm install` goodness, the Docs should generate automagickly when you run `grunt run:dev` + +## What I do now? + +Well if you know Markdown, simply add detailed comments in the code using Markdown syntax. + +```` +/* +User.js +======= + +Defines the user data model (schema) for use via the API. +*/ + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var helpers = require('habitrpg-shared/script/helpers'); +var _ = require('lodash');.... +```` + +As you can see, you can use both multiline style comments `/* fancy stuff */` and inline comments `// Ooooh my`. + +The exception being end of line comments +`text: String, // example: Wolf ` + +The above will not be on the "pretty print" side of the Docs, but will stay in the code. An example use case for end of line comments would be for FIXME notes. + +Add anything that would be helpful to a developer regarding how to use the functions, variables, and objects associated with HabitRPG. + +**All documentation should be committed as pull request to the `docs-project` branch of HabitRPG.** Since we are adding comments directly to the code, I don't want to be editing files used for beta or master. We can merge in the docs after we're sure we didn't break anything. + +### jsDoc Syntax + +Yes, the generator also supports jsDoc-style comments such as +```` +@param {Array} files Array of file paths relative to the `inDir` to generate documentation for. +```` + +**Important Note:** If you use the `@param` syntax, you must use multiline comment blocks (ie `/* stuff */`), otherwise they won't be parsed like parameters. + +This may or may not be useful for HabitRPG. Example use cases: +- Documenting the API +- Javascript Models + +## Okay, I added great comments. Now what? + +If you're running locally, just re-run `grunt run:dev`. Any changed docs will be automagickly updated. + +Once you're satisfied with the output, push your changes to your fork of HabitRPG and issue a Pull Request on the `docs-project` branch. + +It's that easy! + +## Tech Info + +The generator we are using is [Docker](https://github.com/jbt/docker), which is a fork of [Docco](http://jashkenas.github.io/docco/). Docker supports the same wide-range of filetypes, including being able to generate documentation for a whole project, including an index. + +We also use the [Grunt-Docker](https://github.com/Prevole/grunt-docker) node module for automatic processing. + +## Road Map + +- Customize CSS with HabitRPG specific Styling +- Explore possibilities of importing Wiki content +- Specify style guide for consistency of comments \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..42573acb10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM ubuntu:trusty + +MAINTAINER Thibault Cohen + +ENV DEBIAN_FRONTEND noninteractive + +### Init + +RUN apt-get update + +### Utils + +RUN apt-get install -y git vim graphicsmagick nodejs phantomjs npm pkgconf libcairo2-dev libjpeg8-dev + +### Installation + +RUN cd /opt && git clone https://github.com/HabitRPG/habitrpg.git + +#RUN cd /opt/habitrpg && git checkout -t origin/develop + +RUN cd /opt/habitrpg && git pull + +RUN cd /opt/habitrpg && npm install -g grunt-cli bower nodemon + +RUN ln -s /usr/bin/nodejs /usr/bin/node + +RUN cd /opt/habitrpg && npm install + +# Add config file + +ADD ./config.json /opt/habitrpg/ + +RUN mkdir -p /opt/habitrpg/build + +RUN cd /opt/habitrpg && bower install --allow-root + +# Run server + +RUN cd /opt/habitrpg && grunt build:prod + +CMD cd /opt/habitrpg && grunt nodemon diff --git a/EXTENDEDCHANGELOG.md b/EXTENDEDCHANGELOG.md new file mode 100644 index 0000000000..0fe028c6dd --- /dev/null +++ b/EXTENDEDCHANGELOG.md @@ -0,0 +1,64 @@ +HabitRPG +# (2014-01-28) + + +## Documentation + +- **rebirth:** Bullet point about repurchase of limited ed gear after Rebirth + ([d3f4a561](https://github.com/habitrpg/habitrpg/commits/d3f4a561fdf137e5d8f406bae03be4fef1caff22)) + + +## Bug Fixes + +- **#2003:** healer gear not showing + ([949cd97b](https://github.com/habitrpg/habitrpg/commits/949cd97b91b42e9450eba559bbfea17e239ab100)) +- **#2375:** merge in @SabreCat's stats.jade changes "More elegant show/hide setup for attribute bonuses" + ([518f200a](https://github.com/habitrpg/habitrpg/commits/518f200a8fc7373b44ed7d7b5f016d921b0746bd)) +- **beastmaster:** fixes #2557, adds opacity to previously-owned pets after they're mounted. You can earn them back again + ([5caaff1c](https://github.com/habitrpg/habitrpg/commits/5caaff1cea1a68fe572e7ddf4aac50248b13df5d)) +- **bosses:** don't reset progress.up when starting a new quest. We want to be able to carry over damage from the same day a boss battle begins, even if the dailies were completed before battle-start. Fixes #2168 + ([4efd0f5e](https://github.com/habitrpg/habitrpg/commits/4efd0f5ed8708f2491dd483f93e3d7a268a6337d)) +- **classes:** + - misc fixes + ([d2121a85](https://github.com/habitrpg/habitrpg/commits/d2121a858716cb5a532a53ee9c5a1adaa74a7f69)) + - misc class fixes (not @snicker, ng-if on item store since we dynamically swap it sometimes) + ([478be611](https://github.com/habitrpg/habitrpg/commits/478be6111337cd200374f7f31b959725c6a0b945)) +- **find_uniq_user:** fix + ([ecbe780e](https://github.com/habitrpg/habitrpg/commits/ecbe780e70549b1470504efe052f238c89a9db14)) +- **mounts:** Move avatar upward when mounted regardless of pet + ([bc1adeb1](https://github.com/habitrpg/habitrpg/commits/bc1adeb1277103a5ca1f756e175ed68bbe837a2f)) +- **nodemon:** ignore CHANGELOG.md on watch + ([d6c55952](https://github.com/habitrpg/habitrpg/commits/d6c55952da8b49f36e9d8e4570d80931d081343d)) +- **party:** Round boss health up instead of to nearest integer + ([626da568](https://github.com/habitrpg/habitrpg/commits/626da5681f5ea95700f8ddf40587c7184926971c), + [#2504](https://github.com/habitrpg/habitrpg/issues/2504)) +- **paypal:** fixes #2492, remove environment check for now, only have production-mode option. revisit + ([1dc68112](https://github.com/habitrpg/habitrpg/commits/1dc68112d131e4ebdec32ddff938eb6311d6565f)) +- **profile:** fix bug where empty profile displayed on username click + ([0579c432](https://github.com/habitrpg/habitrpg/commits/0579c432489c4a038e8c9f95ea3b285f5abc146f), + [#2465](https://github.com/habitrpg/habitrpg/issues/2465)) +- **quests:** + - bug fix to multi-drop + ([f478d10c](https://github.com/habitrpg/habitrpg/commits/f478d10c20f816cd104b3f0da814c189957f45f5)) + - list multiple rewards in dialog + ([e48c7277](https://github.com/habitrpg/habitrpg/commits/e48c7277f8256cf827790aece51e897fe0439374)) +- **settings:** reintroduce space between captions and help bubbles stripped during localization + ([5ddf09fe](https://github.com/habitrpg/habitrpg/commits/5ddf09fe13c7f8d844c8c47be0fb8f8b2fd1df33)) +- **spells:** + - more $rootScope spell-casting bug fixes + ([47bd6dcb](https://github.com/habitrpg/habitrpg/commits/47bd6dcb79778d90d6f3ddeb003c3d8e45433333)) + - add some spells tests, don't send up body to spell paths + ([e0646bb9](https://github.com/habitrpg/habitrpg/commits/e0646bb98d44b6874b5259107c9be5fa34c58933)) + - some $rootScope.applying action fixes so cast-ending is immediate instead of waiting on response. Also, slim down party population to the essentials to avoid RequestEntityTooLarge + ([c6f7ab8a](https://github.com/habitrpg/habitrpg/commits/c6f7ab8a5c6f4e382208a928b90ba5f4eba9cd37)) + - to cancel spell-casting + ([a1df41ad](https://github.com/habitrpg/habitrpg/commits/a1df41ad8165cd9eb6d2d5d59c7fe404edde716c)) +- **stable:** show hatchable combo when petOwned>0 (fyi @deilann) + ([51bff238](https://github.com/habitrpg/habitrpg/commits/51bff23885ca0080e7e71ff752daa0950ae923ae)) +- **stats:** Better layout for attribute point allocation + ([d782fc6b](https://github.com/habitrpg/habitrpg/commits/d782fc6b6a3cd7e90d327c93a5764626b2990c74)) +- **tests:** + - include select2 in test manifest + ([38b4cea7](https://github.com/habitrpg/habitrpg/commits/38b4cea73299f51c4db7f6b2eb12533d219745f8)) + - don't use cluster in tests, else we get "connection refused" + ([7a479098](https://github.com/habitrpg/habitrpg/commits/7a479098dc6535654e322c737d80813790967941) \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js index c03dc90c95..2fe125c003 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,13 +1,11 @@ -//var sizeOf = require('image-size'); +/*global module:false*/ var _ = require('lodash'); - module.exports = function(grunt) { - var timestamp = +new Date; - + // Ported from shared // So this sucks. Mobile Safari can't render image files > 1024x1024*3, so we have to break it down to multiple // files in this hack approach. See https://github.com/Ensighten/grunt-spritesmith/issues/67#issuecomment-34786248 - var images = grunt.file.expand('img/sprites/spritesmith/**/*.png'); + var images = grunt.file.expand('common/img/sprites/spritesmith/**/*.png'); // var totalDims = {width:0,height:0}; // _.each(images, function(img){ // var dims = sizeOf(img); @@ -23,12 +21,12 @@ module.exports = function(grunt) { var sliced = images.slice(i * (images.length/COUNT), (i+1) * images.length/COUNT) sprite[''+i] = { src: sliced, - dest: 'dist/spritesmith'+i+'.png', - destCss: 'dist/spritesmith'+i+'.css', + dest: 'common/dist/sprites/spritesmith'+i+'.png', + destCss: 'common/dist/sprites/spritesmith'+i+'.css', engine: 'phantomjssmith', algorithm: 'binary-tree', padding:1, - cssTemplate: 'css/css.template.mustache', + cssTemplate: 'common/css/css.template.mustache', cssVarMap: function (sprite) { // For hair, skins, beards, etc. we want to output a '.customize-options.WHATEVER' class, which works as a // 60x60 image pointing at the proper part of the 90x90 sprite. @@ -52,18 +50,48 @@ module.exports = function(grunt) { } }*/ } - }) + }); + + // Project configuration. grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), - // Cleanup previous spritesmith files - clean: { - main: ['dist/spritesmith*.*'] + git_changelog: { + minimal: { + options: { + repo_url: 'https://github.com/habitrpg/habitrpg', + appName : 'HabitRPG', + branch_name: 'develop' + } + }, + extended: { + options: { + file: 'EXTENDEDCHANGELOG.md', + repo_url: 'https://github.com/habitrpg/habitrpg', + appName : 'HabitRPG', + branch_name: 'develop', + grep_commits: '^perf|^style|^fix|^feat|^docs|^refactor|^chore|BREAKING' + } + } + }, + + karma: { + unit: { + configFile: 'karma.conf.js' + }, + continuous: { + configFile: 'karma.conf.js', + singleRun: true, + autoWatch: false + } + }, + + clean: { + build: ['website/build'], + sprite: ['common/dist/sprites'] }, - /** - * Converts our individual image files in img/spritesmith into a unified spritesheet. - */ sprite: sprite, cssmin: { @@ -72,36 +100,158 @@ module.exports = function(grunt) { report: 'gzip' }, files:{ - "dist/habitrpg-shared.css": [ - "dist/spritesmith*.css", - "css/backer.css", - "css/Mounts.css", - "css/index.css" + "common/dist/sprites/habitrpg-shared.css": [ + "common/dist/sprites/spritesmith*.css", + "common/css/backer.css", + "common/css/Mounts.css", + "common/css/index.css" ] } } }, + stylus: { + build: { + options: { + compress: false, // AFTER + 'include css': true, + paths: ['website/public'] + }, + files: { + 'website/build/app.css': ['website/public/css/index.styl'], + 'website/build/static.css': ['website/public/css/static.styl'] + } + } + }, + browserify: { dist: { - src: ["index.js"], - dest: "dist/habitrpg-shared.js" + src: ["common/index.js"], + dest: "common/dist/scripts/habitrpg-shared.js" }, options: { transform: ['coffeeify'] //debug: true Huge data uri source map (400kb!) } - } + }, + copy: { + build: { + files: [ + {expand: true, cwd: 'website/public/', src: 'favicon.ico', dest: 'website/build/'}, + {expand: true, cwd: '', src: 'common/dist/sprites/spritesmith*.png', dest: 'website/build/'}, + {expand: true, cwd: '', src: 'common/img/sprites/backer-only/*.gif', dest: 'website/build/'}, + {expand: true, cwd: '', src: 'common/img/sprites/npc_ian.gif', dest: 'website/build/'}, + {expand: true, cwd: 'website/public/', src: 'bower_components/bootstrap/dist/fonts/*', dest: 'website/build/'} + ] + } + }, + + // UPDATE IT WHEN YOU ADD SOME FILES NOT ALREADY MATCHED! + hashres: { + build: { + options: { + fileNameFormat: '${name}-${hash}.${ext}' + }, + src: [ + 'website/build/*.js', + 'website/build/*.css', + 'website/build/favicon.ico', + 'website/build/common/dist/sprites/*.png', + 'website/build/common/img/sprites/backer-only/*.gif', + 'website/build/common/img/sprites/npc_ian.gif', + 'website/build/bower_components/bootstrap/dist/fonts/*' + ], + dest: 'website/build/*.css' + } + }, + + nodemon: { + dev: { + script: '<%= pkg.main %>' + } + }, + + watch: { + dev: { + files: ['website/public/**/*.styl'], // 'public/**/*.js' Not needed because not in production + tasks: [ 'build:dev' ], + options: { + nospawn: true + } + } + }, + + concurrent: { + dev: ['nodemon', 'watch'], + options: { + logConcurrentOutput: true + } + } }); - grunt.loadNpmTasks('grunt-contrib-clean'); - grunt.loadNpmTasks('grunt-spritesmith'); - grunt.loadNpmTasks('grunt-contrib-cssmin'); // Load the plugin that provides the "uglify" task. - grunt.loadNpmTasks('grunt-browserify'); + //Load build files from public/manifest.json + grunt.registerTask('loadManifestFiles', 'Load all build files from public/manifest.json', function(){ + var files = grunt.file.readJSON('./website/public/manifest.json'); + var uglify = {}; + var cssmin = {}; - // Default task(s). - grunt.registerTask('default', ['cssmin', 'browserify']); - grunt.registerTask('full', ['clean', 'sprite', 'cssmin', 'browserify']); + _.each(files, function(val, key){ + + var js = uglify['website/build/' + key + '.js'] = []; + + _.each(files[key].js, function(val){ + var path = "./"; + if( val.indexOf('common/') == -1) + path = './website/public/'; + js.push(path + val); + }); + + var css = cssmin['website/build/' + key + '.css'] = []; + + _.each(files[key].css, function(val){ + var path = "./"; + if( val.indexOf('common/') == -1) { + path = (val == 'app.css' || val == 'static.css') ? './website/build/' : './website/public/'; + } + css.push(path + val) + }); + + }); + + grunt.config.set('uglify.build.files', uglify); + grunt.config.set('uglify.build.options', {compress: false}); + + grunt.config.set('cssmin.build.files', cssmin); + // Rewrite urls to relative path + grunt.config.set('cssmin.build.options', {'target': 'website/public/css/whatever-css.css'}); + }); + + // Register tasks. + grunt.registerTask('compile:sprites', ['clean:sprite', 'sprite', 'cssmin']); + grunt.registerTask('build:prod', ['loadManifestFiles', 'clean:build', 'browserify', 'uglify', 'stylus', 'cssmin', 'copy:build', 'hashres']); + grunt.registerTask('build:dev', ['browserify', 'stylus']); + + grunt.registerTask('run:dev', [ 'build:dev', 'concurrent' ]); + + if(process.env.NODE_ENV == 'production') + grunt.registerTask('default', ['build:prod']); + else + grunt.registerTask('default', ['build:dev']); + + // Load tasks + grunt.loadNpmTasks('grunt-browserify'); + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-contrib-clean'); + grunt.loadNpmTasks('grunt-contrib-stylus'); + grunt.loadNpmTasks('grunt-contrib-cssmin'); + grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.loadNpmTasks('grunt-nodemon'); + grunt.loadNpmTasks('grunt-concurrent'); + grunt.loadNpmTasks('grunt-contrib-watch'); + grunt.loadNpmTasks('grunt-spritesmith'); + grunt.loadNpmTasks('grunt-hashres'); + grunt.loadNpmTasks('grunt-karma'); + grunt.loadNpmTasks('git-changelog'); }; diff --git a/LICENSE b/LICENSE index 40ef2ff1c9..2daed42a38 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,9 @@ -This project's license can be obtained at https://raw2.github.com/HabitRPG/habitrpg/develop/LICENSE \ No newline at end of file +* Code is GPL v3 licensed: +This Source Code is subject to the terms of the GNU General Public License, v. 3.0. +If a copy of the GPL was not distributed with this file, You can obtain one at http://www.gnu.org/licenses/gpl-3.0.txt + +* Assets and content designed for Mozilla BrowserQuest is licensed under CC-BY-SA 3.0: +http://creativecommons.org/licenses/by-sa/3.0/ + +* Assets and content designed for HabitRPG is licensed under CC-BY-NC-SA 3.0: +http://creativecommons.org/licenses/by-nc-sa/3.0/ \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000000..4b612b697b --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: ./node_modules/.bin/grunt nodemon; diff --git a/README.md b/README.md index 22f976df22..65113909eb 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,12 @@ -[![Build Status](https://travis-ci.org/HabitRPG/habitrpg-shared.png?branch=master)](https://travis-ci.org/HabitRPG/habitrpg-shared) +HabitRPG [![Build Status](https://travis-ci.org/HabitRPG/habitrpg.png?branch=develop)](https://travis-ci.org/HabitRPG/habitrpg) [![Code Climate](https://codeclimate.com/github/HabitRPG/habitrpg.png)](https://codeclimate.com/github/HabitRPG/habitrpg) [![Bountysource](https://api.bountysource.com/badge/tracker?tracker_id=68393)](https://www.bountysource.com/trackers/68393-habitrpg?utm_source=68393&utm_medium=shield&utm_campaign=TRACKER_BADGE) [![Dependency Status](https://gemnasium.com/HabitRPG/habitrpg.svg)](https://gemnasium.com/HabitRPG/habitrpg) +=============== -## We're in the process of migrating this repository to the main HabitRPG repository, you can report any issue [here](https://github.com/HabitRPG/habitrpg). -Shared resources useful for the multiple HabitRPG repositories, that way all the repositories remain in-sync with common characteristics. Includes things like: - * Assets - sprites, images, etc - * CSS - especially, esp. sprite-sheet mapping - * Algorithms - level up algorithm, scoring functions, etc - * View helper functions that may come in handy for multiple client MVCs - * Item definitions - weapons, armor, pets +[HabitRPG](https://habitrpg.com) is an open source habit building program which treats your life like a Role Playing Game. Level up as you succeed, lose HP as you fail, earn money to buy weapons and armor. -##Installation -* `npm install` -* `grunt` - after you've made modifications and want to compile the dist files for browser +We need more programmers! Your assistance will be greatly appreciated. -* Node.js - * `require ('coffee-script')` - * `require('./script/algos.coffee')`, `require('./script/helpers.coffee')`, etc. -* Browser - * Use ` + + +
+

+ fontelico + font demo +

+ +
+
+
+
icon-crown0xe800
+
icon-crown-plus0xe801
+
icon-crown-minus0xe802
+
icon-spin60xe804
+
+
+
icon-spin50xe805
+
icon-spin40xe806
+
icon-spin30xe807
+
icon-spin20xe808
+
+
+
icon-spin10xe809
+
icon-marquee0xe80a
+
+
+ + + \ No newline at end of file diff --git a/website/public/fontello/font/fontelico.eot b/website/public/fontello/font/fontelico.eot new file mode 100644 index 0000000000..8173787224 Binary files /dev/null and b/website/public/fontello/font/fontelico.eot differ diff --git a/website/public/fontello/font/fontelico.svg b/website/public/fontello/font/fontelico.svg new file mode 100644 index 0000000000..dc56a24db8 --- /dev/null +++ b/website/public/fontello/font/fontelico.svg @@ -0,0 +1,21 @@ + + + +Copyright (C) 2014 by original authors @ fontello.com + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/public/fontello/font/fontelico.ttf b/website/public/fontello/font/fontelico.ttf new file mode 100644 index 0000000000..5046777e22 Binary files /dev/null and b/website/public/fontello/font/fontelico.ttf differ diff --git a/website/public/fontello/font/fontelico.woff b/website/public/fontello/font/fontelico.woff new file mode 100644 index 0000000000..c603762754 Binary files /dev/null and b/website/public/fontello/font/fontelico.woff differ diff --git a/website/public/google280633b772b94345.html b/website/public/google280633b772b94345.html new file mode 100644 index 0000000000..bc1dc760d2 --- /dev/null +++ b/website/public/google280633b772b94345.html @@ -0,0 +1 @@ +google-site-verification: google280633b772b94345.html \ No newline at end of file diff --git a/website/public/google8ca65b6ff3506fb8.html b/website/public/google8ca65b6ff3506fb8.html new file mode 100644 index 0000000000..fd984ec97f --- /dev/null +++ b/website/public/google8ca65b6ff3506fb8.html @@ -0,0 +1 @@ +google-site-verification: google8ca65b6ff3506fb8.html \ No newline at end of file diff --git a/website/public/googlef3b1402b0e28338a.html b/website/public/googlef3b1402b0e28338a.html new file mode 100644 index 0000000000..0b28dc0c82 --- /dev/null +++ b/website/public/googlef3b1402b0e28338a.html @@ -0,0 +1 @@ +google-site-verification: googlef3b1402b0e28338a.html diff --git a/website/public/js/app.js b/website/public/js/app.js new file mode 100644 index 0000000000..742f31cfcc --- /dev/null +++ b/website/public/js/app.js @@ -0,0 +1,227 @@ +"use strict"; + +window.habitrpg = angular.module('habitrpg', + ['ui.bootstrap', 'ui.keypress', 'ui.router', 'chieffancypants.loadingBar', 'At', 'infinite-scroll', 'ui.select2', 'angular.filter', 'ngResource', 'ngSanitize']) + + // @see https://github.com/angular-ui/ui-router/issues/110 and https://github.com/HabitRPG/habitrpg/issues/1705 + // temporary hack until they have a better solution + + .constant("API_URL", "") + .constant("STORAGE_USER_ID", 'habitrpg-user') + .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') + .constant("MOBILE_APP", false) + //.constant("STORAGE_GROUPS_ID", "") // if we decide to take groups offline + + .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'STORAGE_SETTINGS_ID', + function($stateProvider, $urlRouterProvider, $httpProvider, STORAGE_SETTINGS_ID) { + + $urlRouterProvider + // Setup default selected tabs + .when('/options', '/options/profile/avatar') + .when('/options/profile', '/options/profile/avatar') + .when('/options/groups', '/options/groups/tavern') + .when('/options/groups/guilds', '/options/groups/guilds/public') + .when('/options/groups/hall', '/options/groups/hall/heroes') + .when('/options/inventory', '/options/inventory/drops') + .when('/options/settings', '/options/settings/settings') + + // redirect states that don't match + .otherwise("/tasks"); + + $stateProvider + + // Tasks + .state('tasks', { + url: "/tasks", + templateUrl: "partials/main.html" + }) + + // Options + .state('options', { + url: "/options", + templateUrl: "partials/options.html", + controller: function(){} + }) + + // Options > Profile + .state('options.profile', { + url: "/profile", + templateUrl: "partials/options.profile.html", + controller: 'UserCtrl' + }) + .state('options.profile.avatar', { + url: "/avatar", + templateUrl: "partials/options.profile.avatar.html" + }) + .state('options.profile.backgrounds', { + url: '/backgrounds', + templateUrl: "partials/options.profile.backgrounds.html" + }) + .state('options.profile.stats', { + url: "/stats", + templateUrl: "partials/options.profile.stats.html" + }) + .state('options.profile.profile', { + url: "/profile", + templateUrl: "partials/options.profile.profile.html" + }) + + // Options > Groups + .state('options.social', { + url: "/groups", + templateUrl: "partials/options.social.html" + }) + + .state('options.social.inbox', { + url: "/inbox", + templateUrl: "partials/options.social.inbox.html" + }) + + .state('options.social.tavern', { + url: "/tavern", + templateUrl: "partials/options.social.tavern.html", + controller: 'TavernCtrl' + }) + + .state('options.social.party', { + url: '/party', + templateUrl: "partials/options.social.party.html", + controller: 'PartyCtrl' + }) + + .state('options.social.hall', { + url: '/hall', + templateUrl: "partials/options.social.hall.html" + }) + .state('options.social.hall.heroes', { + url: '/heroes', + templateUrl: "partials/options.social.hall.heroes.html", + controller: 'HallHeroesCtrl' + }) + .state('options.social.hall.patrons', { + url: '/patrons', + templateUrl: "partials/options.social.hall.patrons.html", + controller: 'HallPatronsCtrl' + }) + + .state('options.social.guilds', { + url: '/guilds', + templateUrl: "partials/options.social.guilds.html", + controller: 'GuildsCtrl' + }) + .state('options.social.guilds.public', { + url: '/public', + templateUrl: "partials/options.social.guilds.public.html" + }) + .state('options.social.guilds.create', { + url: '/create', + templateUrl: "partials/options.social.guilds.create.html" + }) + .state('options.social.guilds.detail', { + url: '/:gid', + templateUrl: 'partials/options.social.guilds.detail.html', + controller: ['$scope', 'Groups', '$stateParams', + function($scope, Groups, $stateParams){ + Groups.Group.get({gid:$stateParams.gid}, function(group){ + $scope.group = group; + Groups.seenMessage(group._id); + }); + }] + }) + + // Options > Social > Challenges + .state('options.social.challenges', { + url: "/challenges", + controller: 'ChallengesCtrl', + templateUrl: "partials/options.social.challenges.html" + }) + .state('options.social.challenges.detail', { + url: '/:cid', + templateUrl: 'partials/options.social.challenges.detail.html', + controller: ['$scope', 'Challenges', '$stateParams', + function($scope, Challenges, $stateParams){ + $scope.obj = $scope.challenge = Challenges.Challenge.get({cid:$stateParams.cid}, function(){ + $scope.challenge._locked = true; + }); + }] + }) + .state('options.social.challenges.detail.member', { + url: '/:uid', + templateUrl: 'partials/options.social.challenges.detail.member.html', + controller: ['$scope', 'Challenges', '$stateParams', + function($scope, Challenges, $stateParams){ + $scope.obj = Challenges.Challenge.getMember({cid:$stateParams.cid, uid:$stateParams.uid}, function(){ + $scope.obj._locked = true; + }); + }] + }) + + // Options > Inventory + .state('options.inventory', { + url: '/inventory', + templateUrl: "partials/options.inventory.html", + controller: 'InventoryCtrl' + }) + .state('options.inventory.drops', { + url: '/drops', + templateUrl: "partials/options.inventory.drops.html" + }) + .state('options.inventory.pets', { + url: '/pets', + templateUrl: "partials/options.inventory.pets.html" + }) + .state('options.inventory.mounts', { + url: '/mounts', + templateUrl: "partials/options.inventory.mounts.html" + }) + .state('options.inventory.equipment', { + url: '/equipment', + templateUrl: "partials/options.inventory.equipment.html" + }) + .state('options.inventory.timetravelers', { + url: '/timetravelers', + templateUrl: "partials/options.inventory.timetravelers.html" + }) + .state('options.inventory.seasonalshop', { + url: '/seasonalshop', + templateUrl: "partials/options.inventory.seasonalshop.html" + }) + + // Options > Settings + .state('options.settings', { + url: "/settings", + controller: 'SettingsCtrl', + templateUrl: "partials/options.settings.html" + }) + .state('options.settings.settings', { + url: "/settings", + templateUrl: "partials/options.settings.settings.html" + }) + .state('options.settings.api', { + url: "/api", + templateUrl: "partials/options.settings.api.html" + }) + .state('options.settings.export', { + url: "/export", + templateUrl: "partials/options.settings.export.html" + }) + .state('options.settings.coupon', { + url: "/coupon", + templateUrl: "partials/options.settings.coupon.html" + }) + .state('options.settings.subscription', { + url: "/subscription", + templateUrl: "partials/options.settings.subscription.html" + }) + .state('options.settings.notifications', { + url: "/notifications", + templateUrl: "partials/options.settings.notifications.html" + }) + + var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); + if (settings && settings.auth) { + $httpProvider.defaults.headers.common['Content-Type'] = 'application/json;charset=utf-8'; + $httpProvider.defaults.headers.common['x-api-user'] = settings.auth.apiId; + $httpProvider.defaults.headers.common['x-api-key'] = settings.auth.apiToken; + } + }]) diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js new file mode 100644 index 0000000000..6bdd4c0b83 --- /dev/null +++ b/website/public/js/controllers/authCtrl.js @@ -0,0 +1,132 @@ +"use strict"; + +/* + The authentication controller (login & facebook) + */ + +angular.module('habitrpg') + .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrl', '$modal', + function($scope, $rootScope, User, $http, $location, $window, ApiUrl, $modal) { + + $scope.logout = function() { + localStorage.clear(); + window.location.href = '/logout'; + }; + + var runAuth = function(id, token) { + User.authenticate(id, token, function(err) { + $window.location.href = ('/' + window.location.hash); + }); + }; + + function errorAlert(data, status, headers, config) { + if (status === 0) { + $window.alert(window.env.t('noReachServer')); + } else if (!!data && !!data.err) { + $window.alert(data.err); + } else { + $window.alert(window.env.t('errorUpCase') + ' ' + status); + } + }; + + $scope.register = function() { + /*TODO highlight invalid inputs + we have this as a workaround for https://github.com/HabitRPG/habitrpg-mobile/issues/64 + */ + if ($scope.registrationForm.$invalid) { + return; + } + var url = ApiUrl.get() + "/api/v2/register"; + if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; + $http.post(url, $scope.registerVals).success(function(data, status, headers, config) { + runAuth(data.id, data.apiToken); + }).error(errorAlert); + }; + + $scope.auth = function() { + var data = { + username: $scope.loginUsername || $('#login-tab input[name="username"]').val(), + password: $scope.loginPassword || $('#login-tab input[name="password"]').val() + }; + $http.post(ApiUrl.get() + "/api/v2/user/auth/local", data) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); + }).error(errorAlert); + }; + + $scope.playButtonClick = function(){ + window.ga && ga('send', 'event', 'button', 'click', 'Play'); + if (User.authenticated()) { + window.location.href = ('/' + window.location.hash); + } else { + $modal.open({ + templateUrl: 'modals/login.html' + // Using controller: 'AuthCtrl' it causes problems + }); + } + }; + + $scope.passwordReset = function(email){ + if(email == null || email.length == 0) { + alert(window.env.t('invalidEmail')); + } else { + $http.post(ApiUrl.get() + '/api/v2/user/reset-password', {email:email}) + .success(function(){ + alert(window.env.t('newPassSent')); + }) + .error(function(data){ + alert(data.err); + }); + } + }; + + $scope.expandMenu = function(menu) { + $scope._expandedMenu = ($scope._expandedMenu == menu) ? null : menu; + }; + + function selectNotificationValue(mysteryValue, invitationValue, unallocatedValue, messageValue, noneValue) { + var user = $scope.user; + if (user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems && user.purchased.plan.mysteryItems.length) { + return mysteryValue; + } else if ((user.invitations.party && user.invitations.party.id) || (user.invitations.guilds && user.invitations.guilds.length > 0)) { + return invitationValue; + } else if (user.flags.classSelected && !(user.preferences && user.preferences.disableClasses) && user.stats.points) { + return unallocatedValue; + } else if (!(_.isEmpty(user.newMessages))) { + return messageValue; + } else { + return noneValue; + } + }; + + $scope.iconClasses = function() { + return selectNotificationValue( + "glyphicon-gift", + "glyphicon-user", + "glyphicon-plus-sign", + "glyphicon-comment", + "glyphicon-comment inactive"); + }; + + $scope.hasNoNotifications = function() { + return selectNotificationValue(false, false, false, false, true); + } + + // ------ Social ---------- + + hello.init({ + facebook : window.env.FACEBOOK_KEY, + }); + + $scope.socialLogin = function(network){ + hello(network).login({scope:'email'}).then(function(auth){ + $http.post(ApiUrl.get() + "/api/v2/user/auth/social", auth) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); + }).error(errorAlert); + }, function( e ){ + alert("Signin error: " + e.error.message ); + }); + } + } +]); diff --git a/website/public/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js new file mode 100644 index 0000000000..dd397f6a45 --- /dev/null +++ b/website/public/js/controllers/challengesCtrl.js @@ -0,0 +1,235 @@ +"use strict"; + +habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', 'Challenges', 'Notification', '$compile', 'Groups', '$state', + function($rootScope, $scope, Shared, User, Challenges, Notification, $compile, Groups, $state) { + + // FIXME $scope.challenges needs to be resolved first (see app.js) + $scope.groups = Groups.Group.query({type:'party,guilds,tavern'}); + Challenges.Challenge.query(function(challenges){ + $scope.challenges = challenges; + $scope.groupsFilter = _.uniq(_.pluck(challenges, 'group'), function(g){return g._id}); + $scope.search = { + group: _.transform($scope.groups, function(m,g){m[g._id]=true;}) + }; + }); + // we should fix this, that's pretty brittle + + // override score() for tasks listed in challenges-editing pages, so that nothing happens + $scope.score = function(){} + + //------------------------------------------------------------ + // Challenge + //------------------------------------------------------------ + + /** + * Create + */ + $scope.create = function() { + $scope.obj = $scope.newChallenge = new Challenges.Challenge({ + name: '', + description: '', + habits: [], + dailys: [], + todos: [], + rewards: [], + leader: User.user._id, + group: $scope.groups[0]._id, + timestamp: +(new Date), + members: [], + official: false + }); + }; + + /** + * Save + */ + $scope.save = function(challenge) { + if (!challenge.group) return alert(window.env.t('selectGroup')); + var isNew = !challenge._id; + challenge.$save(function(_challenge){ + if (isNew) { + Notification.text(window.env.t('challengeCreated')); + $state.go('options.social.challenges.detail', {cid: _challenge._id}); + $scope.discard(); + $scope.challenges = Challenges.Challenge.query(); + User.sync(); + } else { + // TODO figure out a more elegant way about this + //challenge._editing = false; + challenge._locked = true; + } + }); + }; + + /** + * Discard + */ + $scope.discard = function() { + $scope.newChallenge = null; + }; + + + /** + * Close Challenge + * ------------------ + */ + function backToChallenges(){ + $scope.popoverEl.popover('destroy'); + $state.go('options.social.challenges'); + $scope.challenges = Challenges.Challenge.query(); + User.log({}); + } + $scope.cancelClosing = function() { + $scope.popoverEl.popover('destroy'); + $scope.popoverEl = undefined; + $scope.closingChal = undefined; + } + $scope["delete"] = function(challenge) { + if (!confirm(window.env.t('sureDelCha'))) return; + challenge.$delete(function(){ + $scope.popoverEl.popover('destroy'); + backToChallenges(); + }); + }; + $scope.selectWinner = function(challenge) { + if (!confirm(window.env.t('youSure'))) return; + challenge.$close({uid:challenge.winner}, function(){ + $scope.popoverEl.popover('destroy'); + backToChallenges(); + }) + } + $scope.close = function(challenge, $event) { + $scope.closingChal = challenge; + $scope.popoverEl = $($event.target); + var html = $compile('
')($scope); + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'right', + trigger: 'manual', + title: window.env.t('closeCha'), + content: html + }).popover('show'); + + } + + $scope.toggle = function(id){ + if($state.includes('options.social.challenges.detail', {cid: id})){ + $state.go('options.social.challenges') + }else{ + $state.go('options.social.challenges.detail', {cid: id}); + } + } + + $scope.toggleMember = function(cid, uid){ + if($state.includes('options.social.challenges.detail.member', {cid: cid, uid: uid})){ + $state.go('options.social.challenges.detail') + }else{ + $state.go('options.social.challenges.detail.member', {cid: cid, uid: uid}); + } + } + + //------------------------------------------------------------ + // Tasks + //------------------------------------------------------------ + + $scope.addTask = function(addTo, listDef) { + var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); + addTo.unshift(task); + //User.log({op: "addTask", data: task}); //TODO persist + delete listDef.newTask; + }; + + $scope.removeTask = function(list, $index) { + if (!confirm(window.env.t('sureDelete'))) return; + //TODO persist + // User.log({op: "delTask", data: task}); + list.splice($index, 1); + }; + + $scope.saveTask = function(task){ + task._editing = false; + // TODO persist + } + + /* + -------------------------- + Subscription + -------------------------- + */ + + $scope.join = function(challenge){ + challenge.$join(function(){ + $scope.challenges = Challenges.Challenge.query(); + User.log({}); + }); + + } + + $scope.leave = function(keep) { + if (keep == 'cancel') { + $scope.selectedChal = undefined; + } else { + $scope.selectedChal.$leave({keep:keep}, function(){ + $scope.challenges = Challenges.Challenge.query(); + User.log({}); + }); + } + $scope.popoverEl.popover('destroy'); + } + + /** + * Named "clickLeave" to distinguish between "actual" leave above, since this triggers the + * "are you sure?" dialog. + */ + $scope.clickLeave = function(chal, $event) { + $scope.selectedChal = chal; + $scope.popoverEl = $($event.target); + var html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: window.env.t('leaveCha'), + content: html + }).popover('show'); + } + + //------------------------------------------------------------ + // Filtering + //------------------------------------------------------------ + +// $scope.$watch('search', function(search){ +// if (!search) $scope.filteredChallenges = $scope.challenges; +// $scope.filteredChallenges = $filter('filter')($scope.challenges, function(chal) { +// return (search.group[chal.group._id] && +// (typeof search._isMember == 'undefined' || search._isMember == chal._isMember)); +// }) +// }) + // TODO probably better to use $watch above, to avoid this being calculated on every digest cycle + $scope.filterChallenges = function(chal){ + return (!$scope.search) ? true : + ($scope.search.group[chal.group._id] && + (typeof $scope.search._isMember == 'undefined' || $scope.search._isMember == chal._isMember)); + } + + $scope.$watch('newChallenge.group', function(gid){ + if (!gid) return; + var group = _.find($scope.groups, {_id:gid}); + $scope.maxPrize = User.user.balance*4 + ((group && group.balance && group.leader==User.user._id) ? group.balance*4 : 0); + if (gid == 'habitrpg') $scope.newChallenge.prize = 1; + }) + + $scope.selectAll = function(){ + $scope.search.group = _.transform($scope.groups, function(m,g){m[g._id] = true}); + } + + $scope.selectNone = function(){ + $scope.search.group = _.transform($scope.groups, function(m,g){m[g._id] = false}); + } + + $scope.shouldShow = function(task, list, prefs){ + return true; + }; +}]); diff --git a/website/public/js/controllers/filtersCtrl.js b/website/public/js/controllers/filtersCtrl.js new file mode 100644 index 0000000000..201c206977 --- /dev/null +++ b/website/public/js/controllers/filtersCtrl.js @@ -0,0 +1,36 @@ +"use strict"; + +habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', + function($scope, $rootScope, User, Shared) { + var user = User.user; + $scope._editing = false; + + var tagsSnap; // used to compare which tags need updating + + $scope.saveOrEdit = function(){ + if ($scope._editing) { + _.each(User.user.tags, function(tag){ + // Send an update op for each changed tag (excluding new tags & deleted tags, this if() packs a punch) + if (tagsSnap[tag.id] && tagsSnap[tag.id].name != tag.name) + User.user.ops.updateTag({params:{id:tag.id},body:{name:tag.name}}); + }) + $scope._editing = false; + } else { + tagsSnap = angular.copy(user.tags); + tagsSnap = _.object(_.pluck(tagsSnap,'id'), tagsSnap); + $scope._editing = true; + } + }; + + $scope.toggleFilter = function(tag) { + user.filters[tag.id] = !user.filters[tag.id]; + // no longer persisting this, it was causing a lot of confusion - users thought they'd permanently lost tasks + // Note: if we want to persist for just this computer, easy method is: + // User.save(); + }; + + $scope.createTag = function(name) { + User.user.ops.addTag({body:{name:name, id:Shared.uuid()}}); + $scope._newTag = ''; + }; +}]); diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js new file mode 100644 index 0000000000..d250e72b6e --- /dev/null +++ b/website/public/js/controllers/footerCtrl.js @@ -0,0 +1,108 @@ +"use strict"; + +angular.module('habitrpg').controller("FooterCtrl", +['$scope', '$rootScope', 'User', '$http', 'Notification', 'ApiUrl', +function($scope, $rootScope, User, $http, Notification, ApiUrl) { + + if(env.isStaticPage){ + $scope.languages = env.avalaibleLanguages; + $scope.selectedLanguage = _.find(env.avalaibleLanguages, {code: env.language.code}); + + $rootScope.selectedLanguage = $scope.selectedLanguage; + + $scope.changeLang = function(){ + window.location = '?lang='+$scope.selectedLanguage.code; + } + } + + /** + External Scripts + JS files not needed right away (google charts) or entirely optional (analytics) + Each file gets loaded async via $.getScript, so it doesn't bog page-load + */ + $scope.deferredScripts = function(){ + + // Stripe + $.getScript('//checkout.stripe.com/v2/checkout.js'); + + // Google Analytics, only in production + if (window.env.NODE_ENV === 'production') { + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + ga('create', window.env.GA_ID, {userId:User.user._id}); + ga('require', 'displayfeatures'); + ga('send', 'pageview'); + } + + // Scripts only for desktop + if (!window.env.IS_MOBILE) { + // Add This + //$.getScript("//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4"); //FIXME why isn't this working when here? instead it's now in + var addthisServices = 'facebook,twitter,googleplus,tumblr,'+window.env.BASE_URL.replace('https://','').replace('http://',''); + window.addthis_config = { + ui_click: true, + services_custom:{ + name: "Download", + url: window.env.BASE_URL+"/export/avatar-"+User.user._id+".png", + icon: window.env.BASE_URL+"/favicon.ico" + }, + services_expanded:addthisServices, + services_compact:addthisServices + }; + + // Google Charts + $.getScript("//www.google.com/jsapi", function() { + google.load("visualization", "1", { + packages: ["corechart"], + callback: function() {} + }); + }); + } + } + + /** + * Debug functions. Note that the server route for gems is only available if process.env.DEBUG=true + */ + if (_.contains(['development','test'],window.env.NODE_ENV)) { + $scope.setHealthLow = function(){ + User.set({ + 'stats.hp': 1 + }); + } + $scope.addMissedDay = function(){ + if (!confirm("Are you sure you want to reset the day?")) return; + var dayBefore = moment(User.user.lastCron).subtract(1, 'days').toDate(); + User.set({'lastCron': dayBefore}); + Notification.text('-1 day, remember to refresh'); + } + $scope.addTenGems = function(){ + $http.post(ApiUrl.get() + '/api/v2/user/addTenGems').success(function(){ + User.log({}); + }) + } + $scope.addGold = function(){ + User.set({ + 'stats.gp': User.user.stats.gp + 500, + }); + } + $scope.addLevelsAndGold = function(){ + User.set({ + 'stats.exp': User.user.stats.exp + 10000, + 'stats.gp': User.user.stats.gp + 10000, + 'stats.mp': User.user.stats.mp + 10000 + }); + } + $scope.addOneLevel = function(){ + User.set({ + 'stats.exp': User.user.stats.exp + (Math.round(((Math.pow(User.user.stats.lvl, 2) * 0.25) + (10 * User.user.stats.lvl) + 139.75) / 10) * 10) + }); + } + $scope.addBossQuestProgressUp = function(){ + User.set({ + 'party.quest.progress.up': User.user.party.quest.progress.up + 1000 + }); + } + } +}]) diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js new file mode 100644 index 0000000000..3f27ecd82e --- /dev/null +++ b/website/public/js/controllers/groupsCtrl.js @@ -0,0 +1,512 @@ +"use strict"; + +habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', + function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { + + $scope.isMemberOfPendingQuest = function(userid, group) { + if (!group.quest || !group.quest.members) return false; + if (group.quest.active) return false; // quest is started, not pending + return userid in group.quest.members && group.quest.members[userid] != false; + } + + $scope.isMemberOfRunningQuest = function(userid, group) { + if (!group.quest || !group.quest.members) return false; + if (!group.quest.active) return false; // quest is pending, not started + return group.quest.members[userid]; + } + + $scope.isMemberOfGroup = function(userid, group){ + if (!group.members) return false; + var memberIds = _.map(group.members, function(x){return x._id}); + return ~(memberIds.indexOf(userid)); + } + + $scope.isMember = function(user, group){ + return ~(group.members.indexOf(user._id)); + } + + $scope.Members = Members; + $scope._editing = {group:false}; + + $scope.save = function(group){ + if(group._newLeader && group._newLeader._id) group.leader = group._newLeader._id; + group.$save(); + group._editing = false; + } + + // ------ Modals ------ + + $scope.clickMember = function(uid, forceShow) { + if (User.user._id == uid && !forceShow) { + if ($state.is('tasks')) { + $state.go('options.profile.avatar'); + } else { + $state.go('tasks'); + } + } else { + // We need the member information up top here, but then we pass it down to the modal controller + // down below. Better way of handling this? + Members.selectMember(uid, function(){ + $rootScope.openModal('member', {controller:'MemberModalCtrl', windowClass:'profile-modal', size:'lg'}); + }); + } + } + + $scope.removeMember = function(group, member, isMember){ + var yes = confirm(window.env.t('sureKick')) + if(yes){ + Groups.Group.removeMember({gid: group._id, uuid: member._id }, undefined, function(){ + if(isMember){ + _.pull(group.members, member); + }else{ + _.pull(group.invites, member); + } + }); + } + } + + $scope.openInviteModal = function(group){ + $rootScope.openModal('invite-friends', {controller:'InviteToGroupCtrl', resolve: + {injectedGroup: function(){ + return group; + }}}); + }; + + //var serializeQs = function(obj, prefix){ + // var str = []; + // for(var p in obj) { + // if (obj.hasOwnProperty(p)) { + // var k = prefix ? prefix + "[" + p + "]" : p, v = obj[p]; + // str.push(typeof v == "object" ? + // serializeQs(v, k) : + // encodeURIComponent(k) + "=" + encodeURIComponent(v)); + // } + // } + // return str.join("&"); + //} + // + //$scope.inviteLink = function(obj){ + // return window.env.BASE_URL + '?' + serializeQs({partyInvite: obj}); + //} + + $scope.quickReply = function(uid) { + Members.selectMember(uid, function(){ + $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); + }); + } + } + ]) + + .controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', function($scope, User, Groups, injectedGroup, $http, Notification){ + $scope.group = injectedGroup; + + $scope.inviter = User.user.profile.name; + $scope.emails = [{name:"",email:""},{name:"",email:""}]; + + $scope.inviteEmails = function(){ + Groups.Group.invite({gid: $scope.group._id}, {inviter: $scope.inviter, emails: $scope.emails}, function(){ + Notification.text("Invitation(s) sent!"); + $scope.emails = [{name:'',email:''},{name:'',email:''}]; + }, function(){ + $scope.emails = [{name:'',email:''},{name:'',email:''}]; + }); + }; + + $scope.invite = function(){ + Groups.Group.invite({gid: $scope.group._id}, {uuids: [$scope.invitee]}, function(){ + Notification.text("Invitation(s) sent!"); + $scope.invitee = ''; + }, function(){ + $scope.invitee = ''; + }); + }; + }]) + + .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', '$http', 'Notification', 'Groups', + function($scope, $rootScope, Members, Shared, $http, Notification, Groups) { + $scope.timestamp = function(timestamp){ + return moment(timestamp).format($rootScope.User.user.preferences.dateFormat.toUpperCase()); + } + // We watch Members.selectedMember because it's asynchronously set, so would be a hassle to handle updates here + $scope.$watch( function() { return Members.selectedMember; }, function (member) { + if(member) + member.petCount = Shared.countPets($rootScope.countExists(member.items.pets), member.items.pets); + member.mountCount = Shared.countMounts($rootScope.countExists(member.items.mounts), member.items.mounts); + $scope.profile = member; + }); + $scope.sendPrivateMessage = function(uuid, message){ + $http.post('/api/v2/members/'+uuid+'/message',{message:message}).success(function(){ + Notification.text(window.env.t('messageSentAlert')); + $rootScope.User.sync(); + $scope.$close(); + }); + } + $scope.gift = { + type: 'gems', + gems: {amount:0, fromBalance:true}, + subscription: {key:''}, + message:'' + }; + $scope.sendGift = function(uuid, gift){ + $http.post('/api/v2/members/'+uuid+'/gift', gift).success(function(){ + Notification.text('Gift sent!') + $rootScope.User.sync(); + $scope.$close(); + }) + } + $scope.reportAbuse = function(reporter, message, groupId) { + message.flags[reporter._id] = true; + Groups.Group.flagChatMessage({gid: groupId, messageId: message.id}, undefined, function(data){ + Notification.text(window.env.t('abuseReported')); + $scope.$close(); + }); + } + $scope.clearFlagCount = function(message, groupId) { + Groups.Group.clearFlagCount({gid: groupId, messageId: message.id}, undefined, function(data){ + message.flagCount = 0; + Notification.text("Flags cleared"); + $scope.$close(); + }); + } + } + ]) + + .controller('AutocompleteCtrl', ['$scope', '$timeout', 'Groups', 'User', 'InputCaret', function ($scope,$timeout,Groups,User,InputCaret) { + $scope.clearUserlist = function() { + $scope.response = []; + $scope.usernames = []; + } + + $scope.addNewUser = function(user) { + if($.inArray(user.user,$scope.usernames) == -1) { + user.username = user.user; + $scope.usernames.push(user.user); + $scope.response.push(user); + } + } + + $scope.clearUserlist(); + + $scope.chatChanged = function(newvalue,oldvalue){ + if($scope.group.chat && $scope.group.chat.length > 0){ + for(var i = 0; i < $scope.group.chat.length; i++) { + $scope.addNewUser($scope.group.chat[i]); + } + } + } + + $scope.$watch('group.chat',$scope.chatChanged); + + $scope.caretChanged = function(newCaretPos) { + var relativeelement = $('.chat-form div:first'); + var textarea = $('.chat-form textarea'); + var userlist = $('.list-at-user'); + var offset = { + x: textarea.offset().left - relativeelement.offset().left, + y: textarea.offset().top - relativeelement.offset().top, + }; + if(relativeelement) { + var caretOffset = InputCaret.getPosition(textarea); + userlist.css({ + left: caretOffset.left + offset.x, + top: caretOffset.top + offset.y + 16 + }); + } + } + + $scope.updateTimer = false; + + $scope.$watch(function () { return $scope.caretPos; },function(newCaretPos) { + if($scope.updateTimer){ + $timeout.cancel($scope.updateTimer) + } + $scope.updateTimer = $timeout(function(){ + $scope.caretChanged(newCaretPos); + },$scope.watchDelay) + }); + }]) + + .controller('ChatCtrl', ['$scope', 'Groups', 'User', '$http', 'ApiUrl', 'Notification', 'Members', '$rootScope', function($scope, Groups, User, $http, ApiUrl, Notification, Members, $rootScope){ + $scope.message = {content:''}; + $scope._sending = false; + + $scope.isUserMentioned = function(user, message) { + if(message.hasOwnProperty("highlight")) + return message.highlight; + message.highlight = false; + var messagetext = message.text.toLowerCase(); + var username = user.profile.name; + var mentioned = messagetext.indexOf(username.toLowerCase()); + var pattern = username+"([^\w]|$){1}"; + if(mentioned > -1) { + var preceedingchar = messagetext.substring(mentioned-1,mentioned); + if(mentioned == 0 || preceedingchar.trim() == '' || preceedingchar == '@'){ + var regex = new RegExp(pattern,'i'); + message.highlight = regex.test(messagetext); + } + } + return message.highlight; + } + + $scope.postChat = function(group, message){ + if (_.isEmpty(message) || $scope._sending) return; + $scope._sending = true; + var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; + Groups.Group.postChat({gid: group._id, message:message, previousMsg: previousMsg}, undefined, function(data){ + if(data.chat){ + group.chat = data.chat; + }else if(data.message){ + group.chat.unshift(data.message); + } + $scope.message.content = ''; + $scope._sending = false; + }, function(err){ + $scope._sending = false; + }); + } + + $scope.deleteChatMessage = function(group, message){ + if(message.uuid === User.user.id || (User.user.backer && User.user.contributor.admin)){ + var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; + if(confirm('Are you sure you want to delete this message?')){ + Groups.Group.deleteChatMessage({gid:group._id, messageId:message.id, previousMsg:previousMsg}, undefined, function(data){ + if(data.chat) group.chat = data.chat; + + var i = _.findIndex(group.chat, {id: message.id}); + if(i !== -1) group.chat.splice(i, 1); + }); + } + } + } + + $scope.likeChatMessage = function(group,message) { + if (message.uuid == User.user._id) + return Notification.text(window.env.t('foreverAlone')); + if (!message.likes) message.likes = {}; + if (message.likes[User.user._id]) { + delete message.likes[User.user._id]; + } else { + message.likes[User.user._id] = true; + } + //Chat.Chat.like({gid:group._id,mid:message.id}); + + $http.post(ApiUrl.get() + '/api/v2/groups/' + group._id + '/chat/' + message.id + '/like'); + } + + $scope.flagChatMessage = function(groupId,message) { + if(!message.flags) message.flags = {}; + if(message.flags[User.user._id]) + Notification.text(window.env.t('abuseAlreadyReported')); + else { + $scope.abuseObject = message; + $scope.groupId = groupId; + Members.selectMember(message.uuid, function(){ + $rootScope.openModal('abuse-flag',{ + controller:'MemberModalCtrl', + scope: $scope + }); + }); + } + } + + $scope.sync = function(group){ + group.$get(); + } + + // List of Ordering options for the party members list + $scope.partyOrderChoices = { + 'level': window.env.t('sortLevel'), + 'random': window.env.t('sortRandom'), + 'pets': window.env.t('sortPets'), + 'habitrpg_date_joined' : window.env.t('sortHabitrpgJoined'), + 'party_date_joined': window.env.t('sortJoined'), + 'habitrpg_last_logged_in': window.env.t('sortHabitrpgLastLoggedIn'), + 'name': window.env.t('sortName'), + 'backgrounds': window.env.t('sortBackgrounds'), + }; + + $scope.partyOrderAscendingChoices = { + 'ascending': window.env.t('ascendingSort'), + 'descending': window.env.t('descendingSort') + } + + }]) + + .controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', + function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile) { + $scope.groups = { + guilds: Groups.myGuilds(), + "public": Groups.publicGuilds() + } + + $scope.type = 'guild'; + $scope.text = window.env.t('guild'); + var newGroup = function(){ + return new Groups.Group({type:'guild', privacy:'private'}); + } + $scope.newGroup = newGroup() + $scope.create = function(group){ + if (User.user.balance < 1) + return $rootScope.openModal('buyGems', {track:"Gems > Create Group"}); + + if (confirm(window.env.t('confirmGuild'))) { + group.$save(function(saved){ + $rootScope.hardRedirect('/#/options/groups/guilds/' + saved._id); + }); + } + } + + $scope.join = function(group){ + // If we're accepting an invitation, we don't have the actual group object, but a faux group object (for performance + // purposes) {id, name}. Let's trick ngResource into thinking we have a group, so we can call the same $join + // function (server calls .attachGroup(), which finds group by _id and handles this properly) + if (group.id && !group._id) { + group = new Groups.Group({_id:group.id}); + } + + group.$join(function(joined){ + $rootScope.hardRedirect('/#/options/groups/guilds/' + joined._id); + }) + } + + $scope.leave = function(keep) { + if (keep == 'cancel') { + $scope.selectedGroup = undefined; + $scope.popoverEl.popover('destroy'); + } else { + Groups.Group.leave({gid: $scope.selectedGroup._id, keep:keep}, undefined, function(){ + $rootScope.hardRedirect('/#/options/groups/guilds'); + }); + } + } + + $scope.clickLeave = function(group, $event){ + $scope.selectedGroup = group; + $scope.popoverEl = $($event.target); + var html, title; + Challenges.Challenge.query(function(challenges) { + challenges = _.pluck(_.filter(challenges, function(c) { + return c.group._id == group._id; + }), '_id'); + if (_.intersection(challenges, User.user.challenges).length > 0) { + html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveGroupCha'); + } else { + html = $compile( + '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveGroup') + } + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: title, + content: html + }).popover('show'); + }); + } + + $scope.reject = function(guild){ + var i = _.findIndex(User.user.invitations.guilds, {id:guild.id}); + if (~i){ + User.user.invitations.guilds.splice(i, 1); + User.set({'invitations.guilds':User.user.invitations.guilds}); + } + } + } + ]) + + .controller("PartyCtrl", ['$rootScope','$scope', 'Groups', 'User', 'Challenges', '$state', '$compile', + function($rootScope,$scope, Groups, User, Challenges, $state, $compile) { + $scope.type = 'party'; + $scope.text = window.env.t('party'); + $scope.group = $rootScope.party = Groups.party(); + $scope.newGroup = new Groups.Group({type:'party'}); + + Groups.seenMessage($scope.group._id); + + $scope.create = function(group){ + group.$save(function(){ + $rootScope.hardRedirect('/#/options/groups/party'); + }); + } + + $scope.join = function(party){ + var group = new Groups.Group({_id: party.id, name: party.name}); + group.$join(function(){ + $rootScope.hardRedirect('/#/options/groups/party'); + }); + } + + // TODO: refactor guild and party leave into one function + $scope.leave = function(keep) { + if (keep == 'cancel') { + $scope.selectedGroup = undefined; + $scope.popoverEl.popover('destroy'); + } else { + Groups.Group.leave({gid: $scope.selectedGroup._id, keep:keep}, undefined, function(){ + $rootScope.hardRedirect('/#/options/groups/party'); + }); + } + } + + // TODO: refactor guild and party clickLeave into one function + $scope.clickLeave = function(group, $event){ + $scope.selectedGroup = group; + $scope.popoverEl = $($event.target); + var html, title; + Challenges.Challenge.query(function(challenges) { + challenges = _.pluck(_.filter(challenges, function(c) { + return c.group._id == group._id; + }), '_id'); + if (_.intersection(challenges, User.user.challenges).length > 0) { + html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leavePartyCha'); + } else { + html = $compile( + '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveParty'); + } + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: title, + content: html + }).popover('show'); + }); + } + + $scope.reject = function(){ + //User.user.invitations.party = undefined; + User.set({'invitations.party':{}}); + } + + $scope.questCancel = function(){ + if (!confirm(window.env.t('sureCancel'))) return; + $rootScope.party.$questCancel(); + } + + $scope.questAbort = function(){ + if (!confirm(window.env.t('sureAbort'))) return; + if (!confirm(window.env.t('doubleSureAbort'))) return; + $rootScope.party.$questAbort(); + } + + } + ]) + + .controller("TavernCtrl", ['$scope', 'Groups', 'User', + function($scope, Groups, User) { + $scope.group = Groups.tavern(); + $scope.toggleUserTier = function($event) { + $($event.target).next().toggle(); + } + } + ]) diff --git a/website/public/js/controllers/hallCtrl.js b/website/public/js/controllers/hallCtrl.js new file mode 100644 index 0000000000..6956bfedd5 --- /dev/null +++ b/website/public/js/controllers/hallCtrl.js @@ -0,0 +1,36 @@ +"use strict"; + +habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'ApiUrl', '$resource', + function($scope, $rootScope, User, Notification, ApiUrl, $resource) { + var Hero = $resource(ApiUrl.get() + '/api/v2/hall/heroes/:uid', {uid:'@_id'}); + $scope.hero = undefined; + $scope.loadHero = function(uuid){ + $scope.hero = Hero.get({uid:uuid}); + } + $scope.saveHero = function(hero) { + $scope.hero.contributor.admin = ($scope.hero.contributor.level > 7) ? true : false; + hero.$save(function(){ + Notification.text("User updated"); + $scope.hero = undefined; + $scope._heroID = undefined; + $scope.heroes = Hero.query(); + }) + } + $scope.heroes = Hero.query(); + }]); + +habitrpg.controller("HallPatronsCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'ApiUrl', '$resource', + function($scope, $rootScope, User, Notification, ApiUrl, $resource) { + var Patron = $resource(ApiUrl.get() + '/api/v2/hall/patrons/:uid', {uid:'@_id'}); + + var page = 0; + $scope.patrons = []; + + $scope.loadMore = function(){ + Patron.query({page: page++}, function(patrons){ + $scope.patrons = $scope.patrons.concat(patrons); + }) + } + $scope.loadMore(); + + }]); diff --git a/website/public/js/controllers/headerCtrl.js b/website/public/js/controllers/headerCtrl.js new file mode 100644 index 0000000000..ada83f24c2 --- /dev/null +++ b/website/public/js/controllers/headerCtrl.js @@ -0,0 +1,48 @@ +"use strict"; + +habitrpg.controller("HeaderCtrl", ['$scope', 'Groups', 'User', + function($scope, Groups, User) { + + $scope.Math = window.Math; + + $scope.party = Groups.party(function(){ + $scope.partyMinusSelf = _.sortBy( + _.filter($scope.party.members, function(member){ + return member._id !== User.user._id; + }), + function (member) { + switch(User.user.party.order) + { + case 'level': + return member.stats.lvl; + break; + case 'random': + return Math.random(); + break; + case 'pets': + return member.items.pets.length; + break; + case 'name': + return member.profile.name; + break; + case 'backgrounds': + return member.preferences.background; + break; + case 'habitrpg_date_joined': + return member.auth.timestamps.created; + break + case 'habitrpg_last_logged_in': + return member.auth.timestamps.loggedin; + break + default: + // party date joined + return true; + } + } + ) + if (User.user.party.orderAscending == "descending") { + $scope.partyMinusSelf = $scope.partyMinusSelf.reverse() + } + }); + } +]); diff --git a/website/public/js/controllers/inventoryCtrl.js b/website/public/js/controllers/inventoryCtrl.js new file mode 100644 index 0000000000..219c8618e0 --- /dev/null +++ b/website/public/js/controllers/inventoryCtrl.js @@ -0,0 +1,237 @@ +habitrpg.controller("InventoryCtrl", + ['$rootScope', '$scope', 'Shared', '$window', 'User', 'Content', + function($rootScope, $scope, Shared, $window, User, Content) { + + var user = User.user; + + // convenience vars since these are accessed frequently + + $scope.selectedEgg = null; // {index: 1, name: "Tiger", value: 5} + $scope.selectedPotion = null; // {index: 5, name: "Red", value: 3} + $scope.totalPets = _.size(Content.dropEggs) * _.size(Content.hatchingPotions); + $scope.totalMounts = _.size(Content.dropEggs) * _.size(Content.hatchingPotions); + + // count egg, food, hatchingPotion stack totals + var countStacks = function(items) { return _.reduce(items,function(m,v){return m+v;},0);} + + $scope.$watch('user.items.eggs', function(eggs){ $scope.eggCount = countStacks(eggs); }, true); + $scope.$watch('user.items.hatchingPotions', function(pots){ $scope.potCount = countStacks(pots); }, true); + $scope.$watch('user.items.food', function(food){ $scope.foodCount = countStacks(food); }, true); + $scope.$watch('user.items.quests', function(quest){ $scope.questCount = countStacks(quest); }, true); + + $scope.$watch('user.items.gear', function(gear){ + $scope.gear = {}; + _.each(gear.owned, function(v,key){ + if (v === false) return; + var item = Content.gear.flat[key]; + if (!$scope.gear[item.klass]) $scope.gear[item.klass] = []; + $scope.gear[item.klass].push(item); + }) + }, true); + + $scope.chooseEgg = function(egg){ + if ($scope.selectedEgg && $scope.selectedEgg.key == egg) { + return $scope.selectedEgg = null; // clicked same egg, unselect + } + var eggData = _.findWhere(Content.eggs, {key:egg}); + if (!$scope.selectedPotion) { + $scope.selectedEgg = eggData; + } else { + $scope.hatch(eggData, $scope.selectedPotion); + } + $scope.selectedFood = null; + } + + $scope.choosePotion = function(potion){ + if ($scope.selectedPotion && $scope.selectedPotion.key == potion) { + return $scope.selectedPotion = null; // clicked same egg, unselect + } + // we really didn't think through the way these things are stored and getting passed around... + var potionData = _.findWhere(Content.hatchingPotions, {key:potion}); + if (!$scope.selectedEgg) { + $scope.selectedPotion = potionData; + } else { + $scope.hatch($scope.selectedEgg, potionData); + } + $scope.selectedFood = null; + } + + $scope.chooseFood = function(food){ + if ($scope.selectedFood && $scope.selectedFood.key == food) return $scope.selectedFood = null; + $scope.selectedFood = Content.food[food]; + $scope.selectedEgg = $scope.selectedPotion = null; + } + + $scope.sellInventory = function() { + var selected = $scope.selectedEgg ? 'selectedEgg' : $scope.selectedPotion ? 'selectedPotion' : $scope.selectedFood ? 'selectedFood' : undefined; + if (selected) { + var type = $scope.selectedEgg ? 'eggs' : $scope.selectedPotion ? 'hatchingPotions' : $scope.selectedFood ? 'food' : undefined; + user.ops.sell({params:{type:type, key: $scope[selected].key}}); + if (user.items[type][$scope[selected].key] < 1) { + $scope[selected] = null; + } + } + } + + $scope.ownedItems = function(inventory){ + return _.pick(inventory, function(v,k){return v>0;}); + } + + $scope.hatch = function(egg, potion){ + var eggName = Content.eggs[egg.key].text(); + var potName = Content.hatchingPotions[potion.key].text(); + if (!$window.confirm(window.env.t('hatchAPot', {potion: potName, egg: eggName}))) return; + user.ops.hatch({params:{egg:egg.key, hatchingPotion:potion.key}}); + $scope.selectedEgg = null; + $scope.selectedPotion = null; + + $rootScope.petCount = Shared.countPets($rootScope.countExists(User.user.items.pets), User.user.items.pets); + + // Checks if beastmaster has been reached for the first time + if(!User.user.achievements.beastMaster + && $rootScope.petCount >= 90) { + User.user.achievements.beastMaster = true; + $rootScope.openModal('achievements/beastMaster'); + } + + // Checks if Triad Bingo has been reached for the first time + if(!User.user.achievements.triadBingo + && $rootScope.mountCount >= 90 + && Shared.countTriad(User.user.items.pets) >= 90) { + User.user.achievements.triadBingo = true; + $rootScope.openModal('achievements/triadBingo'); + } + } + + $scope.purchase = function(type, item){ + if (type == 'special') return User.user.ops.buySpecialSpell({params:{key:item.key}}); + + var gems = User.user.balance * 4; + + var string = (type == 'weapon') ? window.env.t('weapon') : (type == 'armor') ? window.env.t('armor') : (type == 'head') ? window.env.t('headgear') : (type == 'shield') ? window.env.t('offhand') : (type == 'hatchingPotions') ? window.env.t('hatchingPotion') : (type == 'eggs') ? window.env.t('eggSingular') : (type == 'quests') ? window.env.t('quest') : (item.key == 'Saddle') ? window.env.t('foodSaddleText').toLowerCase() : type; // this is ugly but temporary, once the purchase modal is done this will be removed + if (type == 'weapon' || type == 'armor' || type == 'head' || type == 'shield') { + if (gems < ((item.specialClass == "wizard") && (item.type == "weapon")) + 1) return $rootScope.openModal('buyGems'); + var message = window.env.t('buyThis', {text: string, price: ((item.specialClass == "wizard") && (item.type == "weapon")) + 1, gems: gems}) + if($window.confirm(message)) + User.user.ops.purchase({params:{type:"gear",key:item.key}}); + } else { + if(gems < item.value) return $rootScope.openModal('buyGems'); + var message = window.env.t('buyThis', {text: string, price: item.value, gems: gems}) + if($window.confirm(message)) + User.user.ops.purchase({params:{type:type,key:item.key}}); + } + + } + + $scope.choosePet = function(egg, potion){ + var petDisplayName = env.t('petName', { + potion: Content.hatchingPotions[potion] ? Content.hatchingPotions[potion].text() : potion, + egg: Content.eggs[egg] ? Content.eggs[egg].text() : egg + }), + pet = egg + '-' + potion; + + // Feeding Pet + if ($scope.selectedFood) { + var food = $scope.selectedFood + if (food.key == 'Saddle') { + if (!$window.confirm(window.env.t('useSaddle', {pet: petDisplayName}))) return; + } else if (!$window.confirm(window.env.t('feedPet', {name: petDisplayName, article: food.article, text: food.text()}))) { + return; + } + User.user.ops.feed({params:{pet: pet, food: food.key}}); + $scope.selectedFood = null; + $rootScope.mountCount = Shared.countMounts($rootScope.countExists(User.user.items.mounts), User.user.items.mounts); + + // Checks if mountmaster has been reached for the first time + if(!User.user.achievements.mountMaster + && $rootScope.mountCount >= 90) { + User.user.achievements.mountMaster = true; + $rootScope.openModal('achievements/mountMaster'); + } + + // Selecting Pet + } else { + User.user.ops.equip({params:{type: 'pet', key: pet}}); + } + } + + $scope.chooseMount = function(egg, potion) { + User.user.ops.equip({params:{type: 'mount', key: egg + '-' + potion}}); + } + + $scope.questPopover = function(quest) { + // The popover gets parsed as markdown (hence the double \n for line breaks + var text = ''; + if(quest.boss) { + text += '**' + window.env.t('bossHP') + ':** ' + quest.boss.hp + '\n\n'; + text += '**' + window.env.t('bossStrength') + ':** ' + quest.boss.str + '\n\n'; + } else if(quest.collect) { + var count = 0; + for (var key in quest.collect) { + text += '**' + window.env.t('collect') + ':** ' + quest.collect[key].count + ' ' + quest.collect[key].text() + '\n\n'; + } + } + text += '---\n\n'; + text += '**' + window.env.t('rewards') + ':**\n\n'; + if(quest.drop.items) { + for (var item in quest.drop.items) { + text += quest.drop.items[item].text() + '\n\n'; + } + } + if(quest.drop.exp) + text += quest.drop.exp + ' ' + window.env.t('experience') + '\n\n'; + if(quest.drop.gp) + text += quest.drop.gp + ' ' + window.env.t('gold') + '\n\n'; + + return text; + } + + $scope.showQuest = function(quest) { + var item = Content.quests[quest]; + var completedPrevious = !item.previous || (User.user.achievements.quests && User.user.achievements.quests[item.previous]); + if (!completedPrevious) + return alert(window.env.t('mustComplete', {quest: $rootScope.Content.quests[item.previous].text()})); + if (item.lvl && item.lvl > user.stats.lvl) + return alert(window.env.t('mustLevel', {level: item.lvl})); + $rootScope.selectedQuest = item; + $rootScope.openModal('showQuest', {controller:'InventoryCtrl'}); + } + $scope.closeQuest = function(){ + $rootScope.selectedQuest = undefined; + } + $scope.questInit = function(){ + $rootScope.party.$questAccept({key:$scope.selectedQuest.key}, function(){ + $rootScope.party.$get(); + }); + $scope.closeQuest(); + } + $scope.buyQuest = function(quest) { + var item = Content.quests[quest]; + if (item.lvl && item.lvl > user.stats.lvl) + return alert(window.env.t('mustLvlQuest', {level: item.lvl})); + var completedPrevious = !item.previous || (User.user.achievements.quests && User.user.achievements.quests[item.previous]); + if (!completedPrevious) + return $scope.purchase("quests", item); + $rootScope.selectedQuest = item; + $rootScope.openModal('buyQuest', {controller:'InventoryCtrl'}); + } + + $scope.getSeasonalShopArray = function(set){ + var flatGearArray = _.toArray(Content.gear.flat); + + var filteredArray = _.where(flatGearArray, {index: set}); + + return filteredArray; + }; + + $scope.getSeasonalShopQuests = function(set){ + var questArray = _.toArray(Content.quests); + + var filteredArray = _.filter(questArray, function(q){ + return q.key == "evilsanta" || q.key == "evilsanta2"; + }); + + return filteredArray; + }; + } +]); diff --git a/website/public/js/controllers/notificationCtrl.js b/website/public/js/controllers/notificationCtrl.js new file mode 100644 index 0000000000..6c30b511c2 --- /dev/null +++ b/website/public/js/controllers/notificationCtrl.js @@ -0,0 +1,167 @@ +'use strict'; + +habitrpg.controller('NotificationCtrl', + ['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', + function ($scope, $rootScope, Shared, Content, User, Guide, Notification) { + + $rootScope.$watch('user.stats.hp', function (after, before) { + if (after <= 0){ + $rootScope.playSound('Death'); + $rootScope.openModal('death', {keyboard:false, backdrop:'static'}); + } + if (after == before) return; + if (User.user.stats.lvl == 0) return; + Notification.hp(after - before, 'hp'); + if (after < 0) $rootScope.playSound('Minus_Habit'); + }); + + $rootScope.$watch('user.stats.exp', function(after, before) { + if (after == before) return; + if (User.user.stats.lvl == 0) return; + Notification.exp(after - before); + }); + + $rootScope.$watch('user.achievements', function(){ + $rootScope.playSound('Achievement_Unlocked'); + }, true); + + $rootScope.$watch('user.stats.gp', function(after, before) { + if (after == before) return; + if (User.user.stats.lvl == 0) return; + var money = after - before; + var bonus = User.user._tmp.streakBonus; + Notification.gp(money, bonus || 0); + + //Append Bonus + + if ((money > 0) && !!bonus) { + if (bonus < 0.01) bonus = 0.01; + Notification.text("+ " + Notification.coins(bonus) + ' ' + window.env.t('streakCoins')); + delete User.user._tmp.streakBonus; + } + }); + + $rootScope.$watch('user.stats.mp', function(after,before) { + if (after == before) return; + if (!User.user.flags.classSelected || User.user.preferences.disableClasses) return; + var mana = after - before; + Notification.mp(mana); + }); + + $rootScope.$watch('user._tmp.crit', function(after, before){ + if (after == before || !after) return; + var amount = User.user._tmp.crit * 100 - 100; + // reset the crit counter + User.user._tmp.crit = undefined; + Notification.crit(amount); + }); + + $rootScope.$watch('user._tmp.drop', function(after, before){ + // won't work when getting the same item twice? + if (after == before || !after) return; + $rootScope.playSound('Achievement_Unlocked'); + if (after.type !== 'gear') { + var type = (after.type == 'Food') ? 'food' : + (after.type == 'HatchingPotion') ? 'hatchingPotions' : // can we use camelcase and remove this line? + (after.type.toLowerCase() + 's'); + if(!User.user.items[type][after.key]){ + User.user.items[type][after.key] = 0; + } + User.user.items[type][after.key]++; + } + + if(after.type === 'HatchingPotion'){ + var text = Content.hatchingPotions[after.key].text(); + var notes = Content.hatchingPotions[after.key].notes(); + Notification.drop(env.t('messageDropPotion', {dropText: text, dropNotes: notes})); + }else if(after.type === 'Egg'){ + var text = Content.eggs[after.key].text(); + var notes = Content.eggs[after.key].notes(); + Notification.drop(env.t('messageDropEgg', {dropText: text, dropNotes: notes})); + }else if(after.type === 'Food'){ + var text = Content.food[after.key].text(); + var notes = Content.food[after.key].notes(); + Notification.drop(env.t('messageDropFood', {dropArticle: after.article, dropText: text, dropNotes: notes})); + }else{ + // Keep support for another type of drops that might be added + Notification.drop(User.user._tmp.drop.dialog); + } + $rootScope.playSound('Item_Drop'); + }); + + $rootScope.$watch('user.achievements.streak', function(after, before){ + if(before == undefined || after == before || after < before) return; + if (User.user.achievements.streak > 1) { + Notification.streak(User.user.achievements.streak); + $rootScope.playSound('Achievement_Unlocked'); + } + else { + $rootScope.openModal('achievements/streak'); + } + }); + + $rootScope.$watch('user.achievements.ultimateGear', function(after, before){ + if (after === before || after !== true) return; + $rootScope.openModal('achievements/ultimateGear'); + }); + + $rootScope.$watch('user.achievements.rebirths', function(after, before){ + if(after === before) return; + $rootScope.openModal('achievements/rebirth'); + }); + + $rootScope.$watch('user.flags.contributor', function(after, before){ + if (after === before || after !== true) return; + $rootScope.openModal('achievements/contributor'); + }); + + /*_.each(['weapon', 'head', 'chest', 'shield'], function(watched){ + $rootScope.$watch('user.items.' + watched, function(before, after){ + if (after == before) return; + if (+after < +before) { + //don't want to day "lost a head" + if (watched === 'head') watched = 'helm'; + Notification.text('Lost GP, 1 LVL, ' + watched); + } + }) + });*/ + + // Classes modal + $rootScope.$watch('!user.flags.classSelected && user.stats.lvl >= 10', function(after, before){ + if(after){ + $rootScope.openModal('chooseClass', {controller:'UserCtrl', keyboard:false, backdrop:'static'}); + } + }); + + $rootScope.$watch('user.stats.lvl', function(after, before) { + if (after == before) return; + if (after > before) { + Notification.lvl(); + $rootScope.playSound('Level_Up'); + } + }); + + // Completed quest modal + $rootScope.$watch('user.party.quest.completed', function(after, before){ + if (!after) return; + $rootScope.openModal('questCompleted', {controller:'InventoryCtrl'}); + }); + + // Quest invitation modal + $rootScope.$watch('party.quest.key && !party.quest.active && party.quest.members[user._id] == undefined', function(after, before){ + if (after == before || after != true) return; + $rootScope.openModal('questInvitation'); + }); + + $rootScope.$on('responseError', function(ev, error){ + Notification.error(error); + }); + $rootScope.$on('responseText', function(ev, error){ + Notification.text(error); + }); + + // Show new-stuff modal on load + if (User.user.flags.newStuff) + $rootScope.openModal('newStuff', {size:'lg'}); + } +]); diff --git a/website/public/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js new file mode 100644 index 0000000000..ebf38b9515 --- /dev/null +++ b/website/public/js/controllers/rootCtrl.js @@ -0,0 +1,256 @@ +"use strict"; + +/* Make user and settings available for everyone through root scope. + */ + +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments) { + var user = User.user; + + var initSticky = _.once(function(){ + if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return; + $('.header-wrap').sticky({topSpacing:0}); + }) + $rootScope.$on('userUpdated',initSticky); + + $rootScope.$on('$stateChangeSuccess', + function(event, toState, toParams, fromState, fromParams){ + if (!!fromState.name) window.ga && ga('send', 'pageview', {page: '/#/'+toState.name}); + // clear inbox when entering or exiting inbox tab + if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { + User.user.ops.update && User.set({'inbox.newMessages':0}); + } + }); + + $rootScope.User = User; + $rootScope.user = user; + $rootScope.moment = window.moment; + $rootScope._ = window._; + $rootScope.settings = User.settings; + $rootScope.Shared = Shared; + $rootScope.Content = Content; + $rootScope.env = window.env; + $rootScope.Math = Math; + $rootScope.Groups = Groups; + $rootScope.toJson = angular.toJson; + $rootScope.Payments = Payments; + + // Angular UI Router + $rootScope.$state = $state; + $rootScope.$stateParams = $stateParams; + + // indexOf helper + $scope.indexOf = function(haystack, needle){ + return haystack && ~haystack.indexOf(needle); + } + + // styling helpers + $scope.userLevelStyle = function(user,style){ + style = style || ''; + var npc = (user && user.backer && user.backer.npc) ? user.backer.npc : ''; + var level = (user && user.contributor && user.contributor.level) ? user.contributor.level : ''; + style += $scope.userLevelStyleFromLevel(level,npc,style) + return style; + } + $scope.userAdminGlyphiconStyle = function(user,style){ + style = style || ''; + if(user && user.contributor && user.contributor.level) + style += $scope.userAdminGlyphiconStyleFromLevel(user.contributor.level,style) + return style; + } + $scope.userLevelStyleFromLevel = function(level,npc,style){ + style = style || ''; + if(npc) + style += ' label-npc'; + if(level) + style += ' label-contributor-'+level; + return style; + } + $scope.userAdminGlyphiconStyleFromLevel = function(level,style){ + style = style || ''; + if(level) + if(level==8) + style += ' glyphicon glyphicon-star'; // moderator + if(level==9) + style += ' glyphicon icon-crown'; // staff + return style; + } + + $rootScope.playSound = function(id){ + if (!user.preferences.sound || user.preferences.sound == 'off') return; + var theme = user.preferences.sound; + var file = 'common/audio/' + theme + '/' + id; + document.getElementById('oggSource').src = file + '.ogg'; + document.getElementById('mp3Source').src = file + '.mp3'; + document.getElementById('sound').load(); + } + + // count pets, mounts collected totals, etc + $rootScope.countExists = function(items) {return _.reduce(items,function(m,v){return m+(v?1:0)},0)} + + $rootScope.petCount = Shared.countPets($rootScope.countExists(User.user.items.pets), User.user.items.pets); + $rootScope.mountCount = Shared.countMounts($rootScope.countExists(User.user.items.mounts), User.user.items.mounts); + + $scope.safeApply = function(fn) { + var phase = this.$root.$$phase; + if(phase == '$apply' || phase == '$digest') { + if(fn && (typeof(fn) === 'function')) { + fn(); + } + } else { + this.$apply(fn); + } + }; + + $rootScope.set = User.set; + $rootScope.authenticated = User.authenticated; + + // Open a modal from a template expression (like ng-click,...) + // Otherwise use the proper $modal.open + $rootScope.openModal = function(template, options){//controller, scope, keyboard, backdrop){ + if (!options) options = {}; + if (options.track) window.ga && ga('send', 'event', 'button', 'click', options.track); + return $modal.open({ + templateUrl: 'modals/' + template + '.html', + controller: options.controller, // optional + scope: options.scope, // optional + resolve: options.resolve, // optional + keyboard: (options.keyboard === undefined ? true : options.keyboard), // optional + backdrop: (options.backdrop === undefined ? true : options.backdrop), // optional + size: options.size, // optional, 'sm' or 'lg' + windowClass: options.windowClass // optional + }); + } + + $rootScope.dismissAlert = function() { + $rootScope.set({'flags.newStuff':false}); + } + + $rootScope.acceptCommunityGuidelines = function() { + $rootScope.set({'flags.communityGuidelinesAccepted':true}); + } + + $rootScope.notPorted = function(){ + alert(window.env.t('notPorted')); + } + + $rootScope.dismissErrorOrWarning = function(type, $index){ + $rootScope.flash[type].splice($index, 1); + } + + $scope.contribText = function(contrib, backer){ + if (!contrib && !backer) return; + if (backer && backer.npc) return backer.npc; + var l = contrib && contrib.level; + if (l && l > 0) { + var level = (l < 3) ? window.env.t('friend') : (l < 5) ? window.env.t('elite') : (l < 7) ? window.env.t('champion') : (l < 8) ? window.env.t('legendary') : (l < 9) ? window.env.t('guardian') : window.env.t('heroic'); + return level + ' ' + contrib.text; + } + } + + $rootScope.charts = {}; + $rootScope.toggleChart = function(id, task) { + var history = [], matrix, data, chart, options; + switch (id) { + case 'exp': + history = User.user.history.exp; + $rootScope.charts.exp = (history.length == 0) ? false : !$rootScope.charts.exp; + break; + case 'todos': + history = User.user.history.todos; + $rootScope.charts.todos = (history.length == 0) ? false : !$rootScope.charts.todos; + break; + default: + history = task.history; + $rootScope.charts[id] = (history.length == 0) ? false : !$rootScope.charts[id]; + if (task && task._editing) task._editing = false; + } + matrix = [[env.t('date'), env.t('score')]]; + _.each(history, function(obj) { + matrix.push([moment(obj.date).format(User.user.preferences.dateFormat.toUpperCase().replace('YYYY','YY') ), obj.value]); + }); + data = google.visualization.arrayToDataTable(matrix); + options = { + title: window.env.t('history'), + backgroundColor: { + fill: 'transparent' + }, + hAxis: {slantedText:true, slantedTextAngle: 90}, + height:270, + width:300 + }; + chart = new google.visualization.LineChart($("." + id + "-chart")[0]); + chart.draw(data, options); + }; + + + + /* + ------------------------ + Spells + ------------------------ + */ + $scope.castStart = function(spell) { + if (User.user.stats.mp < spell.mana) return Notification.text(window.env.t('notEnoughMana')); + + if (spell.immediateUse && User.user.stats.gp < spell.value) + return Notification.text('Not enough gold.'); + + $rootScope.applyingAction = true; + $scope.spell = spell; + if (spell.target == 'self') { + $scope.castEnd(null, 'self'); + } else if (spell.target == 'party') { + var party = Groups.party(); + party = (_.isArray(party) ? party : []).concat(User.user); + $scope.castEnd(party, 'party'); + } + } + + $scope.castEnd = function(target, type, $event){ + if (!$rootScope.applyingAction) return; + $event && ($event.stopPropagation(),$event.preventDefault()); + if ($scope.spell.target != type) return Notification.text(window.env.t('invalidTarget')); + $scope.spell.cast(User.user, target); + User.save(); + + var spell = $scope.spell; + var targetId = (type == 'party' || type == 'self') ? '' : type == 'task' ? target.id : target._id; + $scope.spell = null; + $rootScope.applyingAction = false; + + $http.post(ApiUrl.get() + '/api/v2/user/class/cast/'+spell.key+'?targetType='+type+'&targetId='+targetId) + .success(function(){ + var msg = window.env.t('youCast', {spell: spell.text()}); + switch (type) { + case 'task': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.text});break; + case 'user': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.profile.name});break; + case 'party': msg = window.env.t('youCastParty', {spell: spell.text()});break; + } + Notification.text(msg); + }); + + } + + $rootScope.castCancel = function(){ + $rootScope.applyingAction = false; + $scope.spell = null; + } + + // Because our angular-ui-router uses anchors for urls (/#/options/groups/party), window.location.href=... won't + // reload the page. Perform manually. + $rootScope.hardRedirect = function(url){ + window.location.href = url; + window.location.reload(false); + } + + // Universal method for sending HTTP methods + $rootScope.http = function(method, route, data, alertMsg){ + $http[method](ApiUrl.get() + route, data).success(function(){ + if (alertMsg) Notification.text(window.env.t(alertMsg)); + User.sync(); + }); + // error will be handled via $http interceptor + } + } +]); diff --git a/website/public/js/controllers/settingsCtrl.js b/website/public/js/controllers/settingsCtrl.js new file mode 100644 index 0000000000..df960db39c --- /dev/null +++ b/website/public/js/controllers/settingsCtrl.js @@ -0,0 +1,203 @@ +'use strict'; + +// Make user and settings available for everyone through root scope. +habitrpg.controller('SettingsCtrl', + ['$scope', 'User', '$rootScope', '$http', 'ApiUrl', 'Guide', '$location', '$timeout', 'Notification', 'Shared', + function($scope, User, $rootScope, $http, ApiUrl, Guide, $location, $timeout, Notification, Shared) { + + // FIXME we have this re-declared everywhere, figure which is the canonical version and delete the rest +// $scope.auth = function (id, token) { +// User.authenticate(id, token, function (err) { +// if (!err) { +// alert('Login successful!'); +// $location.path("/habit"); +// } +// }); +// } + + // A simple object to map the key stored in the db (user.preferences.emailNotification[key]) + // to its string but ONLY when the preferences' key and the string key don't match + var mapPrefToEmailString = { + 'importantAnnouncements': 'inactivityEmails' + }; + + // If ?unsubFrom param is passed with valid email type, + // automatically unsubscribe users from that email and + // show an alert + $timeout(function(){ + var unsubFrom = $location.search().unsubFrom; + if(unsubFrom){ + var emailPrefKey = 'preferences.emailNotifications.' + unsubFrom; + var emailTypeString = env.t(mapPrefToEmailString[unsubFrom] || unsubFrom); + User.set({emailPrefKey: false}); + User.user.preferences.emailNotifications[unsubFrom] = false; + Notification.text(env.t('correctlyUnsubscribedEmailType', {emailType: emailTypeString})); + $location.search({}); + } + }, 500); + + $scope.hideHeader = function(){ + User.set({"preferences.hideHeader":!User.user.preferences.hideHeader}) + if (User.user.preferences.hideHeader && User.user.preferences.stickyHeader){ + User.set({"preferences.stickyHeader":false}); + $rootScope.$on('userSynced', function(){ + window.location.reload(); + }); + } + } + + $scope.toggleStickyHeader = function(){ + $rootScope.$on('userSynced', function(){ + window.location.reload(); + }); + User.set({"preferences.stickyHeader":!User.user.preferences.stickyHeader}); + } + + $scope.showTour = function(){ + User.set({'flags.showTour':true}); + $location.path('/tasks'); + $timeout(Guide.initTour); + } + + $scope.showClassesTour = function(){ + Guide.classesTour(); + } + + $scope.showBailey = function(){ + User.set({'flags.newStuff':true}); + } + + $scope.saveDayStart = function(){ + var dayStart = +User.user.preferences.dayStart; + if (_.isNaN(dayStart) || dayStart < 0 || dayStart > 24) { + dayStart = 0; + return alert(window.env.t('enterNumber')); + } + User.set({'preferences.dayStart': dayStart}); + } + + $scope.language = window.env.language; + $scope.avalaibleLanguages = window.env.avalaibleLanguages; + + $scope.changeLanguage = function(){ + $rootScope.$on('userSynced', function(){ + window.location.reload(); + }); + User.set({'preferences.language': $scope.language.code}); + } + + $scope.availableFormats = ['MM/dd/yyyy','dd/MM/yyyy', 'yyyy/MM/dd']; + + $scope.reroll = function(){ + User.user.ops.reroll({}); + $rootScope.$state.go('tasks'); + } + + $scope.rebirth = function(){ + User.user.ops.rebirth({}); + $rootScope.$state.go('tasks'); + } + + $scope.changeUser = function(attr, updates){ + $http.post(ApiUrl.get() + '/api/v2/user/change-'+attr, updates) + .success(function(){ + alert(window.env.t(attr+'Success')); + _.each(updates, function(v,k){updates[k]=null;}); + User.sync(); + }); + } + + $scope.restoreValues = {}; + $rootScope.openRestoreModal = function(){ + $scope.restoreValues.stats = angular.copy(User.user.stats); + $scope.restoreValues.achievements = {streak: User.user.achievements.streak || 0}; + $rootScope.openModal('restore', {scope:$scope}); + }; + + $scope.restore = function(){ + var stats = $scope.restoreValues.stats, + achievements = $scope.restoreValues.achievements; + User.set({ + "stats.hp": stats.hp, + "stats.exp": stats.exp, + "stats.gp": stats.gp, + "stats.lvl": stats.lvl, + "stats.mp": stats.mp, + "achievements.streak": achievements.streak + }); + } + + $scope.reset = function(){ + User.user.ops.reset({}); + $rootScope.$state.go('tasks'); + } + + $scope['delete'] = function(){ + $http['delete'](ApiUrl.get() + '/api/v2/user') + .success(function(res, code){ + if (res.err) return alert(res.err); + localStorage.clear(); + window.location.href = '/logout'; + }); + } + + $scope.enterCoupon = function(code) { + $http.post(ApiUrl.get() + '/api/v2/user/coupon/' + code).success(function(res,code){ + if (code!==200) return; + User.sync(); + Notification.text('Coupon applied! Check your inventory'); + }); + } + $scope.generateCodes = function(codes){ + $http.post(ApiUrl.get() + '/api/v2/coupons/generate/'+codes.event+'?count='+(codes.count || 1)) + .success(function(res,code){ + $scope._codes = {}; + if (code!==200) return; + window.location.href = '/api/v2/coupons?limit='+codes.count+'&_id='+User.user._id+'&apiToken='+User.user.apiToken; + }) + } + $scope.releasePets = function() { + User.user.ops.releasePets({}); + $rootScope.$state.go('tasks'); + } + + $scope.releaseMounts = function() { + User.user.ops.releaseMounts({}); + $rootScope.mountCount = 0; + $rootScope.$state.go('tasks'); + } + + $scope.releaseBoth = function() { + User.user.ops.releaseBoth({}); + $rootScope.mountCount = 0; + $rootScope.$state.go('tasks'); + } + + // ---- Webhooks ------ + $scope._newWebhook = {url:''}; + $scope.$watch('user.preferences.webhooks',function(webhooks){ + $scope.hasWebhooks = _.size(webhooks); + }) + $scope.addWebhook = function(url) { + User.user.ops.addWebhook({body:{url:url, id:Shared.uuid()}}); + $scope._newWebhook.url = ''; + } + $scope.saveWebhook = function(id,webhook) { + delete webhook._editing; + User.user.ops.updateWebhook({params:{id:id}, body:webhook}); + } + $scope.deleteWebhook = function(id) { + User.user.ops.deleteWebhook({params:{id:id}}); + } + + $scope.applyCoupon = function(coupon){ + $http.get(ApiUrl.get() + '/api/v2/coupons/valid-discount/'+coupon) + .success(function(){ + Notification.text("Coupon applied!"); + var subs = $scope.Content.subscriptionBlocks; + subs["basic_6mo"].discount = true; + subs["google_6mo"].discount = false; + }); + } + } +]); diff --git a/website/public/js/controllers/tasksCtrl.js b/website/public/js/controllers/tasksCtrl.js new file mode 100644 index 0000000000..7294d5c2f7 --- /dev/null +++ b/website/public/js/controllers/tasksCtrl.js @@ -0,0 +1,244 @@ +"use strict"; + +habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','Notification', '$http', 'ApiUrl', '$timeout', 'Shared', + function($scope, $rootScope, $location, User, Notification, $http, ApiUrl, $timeout, Shared) { + $scope.obj = User.user; // used for task-lists + $scope.user = User.user; + + $scope.score = function(task, direction) { + switch (task.type) { + case 'reward': + $rootScope.playSound('Reward'); + break; + case 'daily': + $rootScope.playSound('Daily'); + break; + case 'todo': + $rootScope.playSound('ToDo'); + break; + default: + if (direction === 'down') $rootScope.playSound('Minus_Habit'); + else if (direction === 'up') $rootScope.playSound('Plus_Habit'); + } + User.user.ops.score({params:{id: task.id, direction:direction}}) + }; + + function addTask(addTo, listDef, task) { + var newTask = { + text: task, + type: listDef.type, + tags: _.transform(User.user.filters, function(m,v,k){ + if (v) m[k]=v; + }) + }; + User.user.ops.addTask({body:newTask}); + } + + $scope.addTask = function(addTo, listDef) { + if (listDef.bulk) { + var tasks = listDef.newTask.split(/[\n\r]+/); + _.each(tasks, function(t) { + addTask(addTo, listDef, t); + }); + listDef.bulk = false; + } else { + addTask(addTo, listDef, listDef.newTask); + } + delete listDef.newTask; + delete listDef.focus; + }; + + $scope.toggleBulk = function(list) { + if (typeof list.bulk === 'undefined') { + list.bulk = false; + } + list.bulk = !list.bulk; + list.focus = true; + }; + + /** + * Add the new task to the actions log + */ + $scope.clearDoneTodos = function() {}; + + /** + * Pushes task to top or bottom of list + */ + $scope.pushTask = function(task, index, location) { + var to = (location === 'bottom') ? -1 : 0; + User.user.ops.sortTask({params:{id:task.id},query:{from:index, to:to}}) + }; + + /** + * This is calculated post-change, so task.completed=true if they just checked it + */ + $scope.changeCheck = function(task) { + if (task.completed) { + $scope.score(task, "up"); + } else { + $scope.score(task, "down"); + } + }; + + $scope.removeTask = function(list, $index) { + if (!confirm(window.env.t('sureDelete'))) return; + User.user.ops.deleteTask({params:{id:list[$index].id}}) + }; + + $scope.saveTask = function(task, stayOpen, isSaveAndClose) { + if (task.checklist) + task.checklist = _.filter(task.checklist,function(i){return !!i.text}); + User.user.ops.updateTask({params:{id:task.id},body:task}); + if (!stayOpen) task._editing = false; + if (isSaveAndClose) + $("#task-" + task.id).parent().children('.popover').removeClass('in'); + }; + + /** + * Reset $scope.task to $scope.originalTask + */ + $scope.cancel = function() { + var key; + for (key in $scope.task) { + $scope.task[key] = $scope.originalTask[key]; + } + $scope.originalTask = null; + $scope.editedTask = null; + $scope.editing = false; + }; + + $scope.unlink = function(task, keep) { + // TODO move this to userServices, turn userSerivces.user into ng-resource + $http.post(ApiUrl.get() + '/api/v2/user/tasks/' + task.id + '/unlink?keep=' + keep) + .success(function(){ + User.log({}); + }); + }; + + /* + ------------------------ + To-Dos + ------------------------ + */ + $scope._today = moment().add({days: 1}); + + /* + ------------------------ + Checklists + ------------------------ + */ + function focusChecklist(task,index) { + window.setTimeout(function(){ + $('#task-'+task.id+' .checklist-form input[type="text"]')[index].focus(); + }); + } + $scope.addChecklist = function(task) { + task.checklist = [{completed:false,text:""}]; + focusChecklist(task,0); + } + $scope.addChecklistItem = function(task,$event,$index) { + if (!task.checklist[$index].text) { + // Don't allow creation of an empty checklist item + // TODO Provide UI feedback that this item is still blank + } else if ($index == task.checklist.length-1){ + User.user.ops.updateTask({params:{id:task.id},body:task}); // don't preen the new empty item + task.checklist.push({completed:false,text:''}); + focusChecklist(task,task.checklist.length-1); + } else { + $scope.saveTask(task,true); + focusChecklist(task,$index+1); + } + } + $scope.removeChecklistItem = function(task,$event,$index,force){ + // Remove item if clicked on trash icon + if (force) { + task.checklist.splice($index,1); + $scope.saveTask(task,true); + } else if (!task.checklist[$index].text) { + // User deleted all the text and is now wishing to delete the item + // saveTask will prune the empty item + $scope.saveTask(task,true); + // Move focus if the list is still non-empty + if ($index > 0) + focusChecklist(task,$index-1); + // Don't allow the backspace key to navigate back now that the field is gone + $event.preventDefault(); + } + } + $scope.swapChecklistItems = function(task, oldIndex, newIndex) { + var toSwap = task.checklist.splice(oldIndex, 1)[0]; + task.checklist.splice(newIndex, 0, toSwap); + $scope.saveTask(task, true); + } + $scope.navigateChecklist = function(task,$index,$event){ + focusChecklist(task, $event.keyCode == '40' ? $index+1 : $index-1); + } + $scope.checklistCompletion = function(checklist){ + return _.reduce(checklist,function(m,i){return m+(i.completed ? 1 : 0);},0) + } + $scope.collapseChecklist = function(task) { + task.collapseChecklist = !task.collapseChecklist; + $scope.saveTask(task,true); + } + + /* + ------------------------ + Items + ------------------------ + */ + + $scope.$watch('user.items.gear.equipped', function(){ + $scope.itemStore = Shared.updateStore(User.user); + },true); + + $scope.buy = function(item) { + User.user.ops.buy({params:{key:item.key}}); + $rootScope.playSound('Reward'); + }; + + + /* + ------------------------ + Ads + ------------------------ + */ + + /** + * See conversation on http://productforums.google.com/forum/#!topic/adsense/WYkC_VzKwbA, + * Adsense is very sensitive. It must be called once-and-only-once for every , else things break. + * Additionally, angular won't run javascript embedded into a script template, so we can't copy/paste + * the html provided by adsense - we need to run this function post-link + */ + $scope.initAds = function(){ + $.getScript('//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'); + (window.adsbygoogle = window.adsbygoogle || []).push({}); + } + + /* + ------------------------ + Hiding Tasks + ------------------------ + */ + + $scope.shouldShow = function(task, list, prefs){ + if (task._editing) // never hide a task while being edited + return true; + var shouldDo = task.type == 'daily' ? habitrpgShared.shouldDo(new Date, task.repeat, prefs) : true; + switch (list.view) { + case "yellowred": // Habits + return task.value < 1; + case "greenblue": // Habits + return task.value >= 1; + case "remaining": // Dailies and To-Dos + return !task.completed && shouldDo; + case "complete": // Dailies and To-Dos + return task.completed || !shouldDo; + case "dated": // To-Dos + return !task.completed && task.date; + case "ingamerewards": // All skills/rewards except the user's own + return false; // Because "rewards" list includes only the user's own + case "all": + return true; + } + } + }]); diff --git a/website/public/js/controllers/userCtrl.js b/website/public/js/controllers/userCtrl.js new file mode 100644 index 0000000000..cf5336f4c7 --- /dev/null +++ b/website/public/js/controllers/userCtrl.js @@ -0,0 +1,81 @@ +"use strict"; + +habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$http', '$state', 'Guide', 'Shared', + function($rootScope, $scope, $location, User, $http, $state, Guide, Shared) { + $scope.profile = User.user; + $scope.profile.petCount = Shared.countPets($rootScope.countExists($scope.profile.items.pets), $scope.profile.items.pets); + $scope.profile.mountCount = Shared.countMounts($rootScope.countExists($scope.profile.items.mounts), $scope.profile.items.mounts); + $scope.hideUserAvatar = function() { + $(".userAvatar").hide(); + }; + + $scope.$watch('_editing.profile', function(value){ + if(value === true) $scope.editingProfile = angular.copy(User.user.profile); + }); + + $scope.allocate = function(stat){ + User.user.ops.allocate({query:{stat:stat}}); + } + + $scope.changeClass = function(klass){ + if (!klass) { + if (!confirm(window.env.t('sureReset'))) + return; + return User.user.ops.changeClass({}); + } + + User.user.ops.changeClass({query:{class:klass}}); + $scope.selectedClass = undefined; + Shared.updateStore(User.user); + $state.go('options.profile.stats'); + window.setTimeout(Guide.classesTour, 10); + } + + $scope.save = function(){ + var values = {}; + _.each($scope.editingProfile, function(value, key){ + // Using toString because we need to compare two arrays (websites) + var curVal = $scope.profile.profile[key]; + if(!curVal || $scope.editingProfile[key].toString() !== curVal.toString()) + values['profile.' + key] = value; + }); + User.set(values); + $scope._editing.profile = false; + } + + /** + * For gem-unlockable preferences, (a) if owned, select preference (b) else, purchase + * @param path: User.preferences <-> User.purchased maps like User.preferences.skin=abc <-> User.purchased.skin.abc. + * Pass in this paramater as "skin.abc". Alternatively, pass as an array ["skin.abc", "skin.xyz"] to unlock sets + */ + $scope.unlock = function(path){ + var fullSet = ~path.indexOf(','); + var cost = + ~path.indexOf('background.') ? + (fullSet ? 3.75 : 1.75) : // (Backgrounds) 15G per set, 7G per individual + (fullSet ? 1.25 : 0.5); // (Hair, skin, etc) 5G per set, 2G per individual + + + if (fullSet) { + if (confirm(window.env.t('purchaseFor',{cost:cost*4})) !== true) return; + if (User.user.balance < cost) return $rootScope.openModal('buyGems'); + } else if (!User.user.fns.dotGet('purchased.' + path)) { + if (confirm(window.env.t('purchaseFor',{cost:cost*4})) !== true) return; + if (User.user.balance < cost) return $rootScope.openModal('buyGems'); + } + User.user.ops.unlock({query:{path:path}}) + } + + $scope.ownsSet = function(type,_set) { + return !_.find(_set,function(v,k){ + return !User.user.purchased[type][k]; + }); + } + $scope.setKeys = function(type,_set){ + return _.map(_set, function(v,k){ + return type+'.'+k; + }).join(','); + } + + } +]); diff --git a/website/public/js/directives/directives.js b/website/public/js/directives/directives.js new file mode 100644 index 0000000000..d1543e348f --- /dev/null +++ b/website/public/js/directives/directives.js @@ -0,0 +1,195 @@ +'use strict'; + +/** + * Directive that places focus on the element it is applied to when the expression it binds to evaluates to true. + */ +habitrpg.directive('taskFocus', + ['$timeout', + function($timeout) { + return function(scope, elem, attrs) { + scope.$watch(attrs.taskFocus, function(newval) { + if ( newval ) { + $timeout(function() { + elem[0].focus(); + }, 0, false); + } + }); + }; + } +]); + +habitrpg.directive('habitrpgAdsense', function() { + return { + restrict: 'A', + transclude: true, + replace: true, + template: '
', + link: function ($scope, element, attrs) {} + } +}); + +habitrpg.directive('whenScrolled', function() { + return function(scope, elm, attr) { + var raw = elm[0]; + + elm.bind('scroll', function() { + if (raw.scrollTop + raw.offsetHeight >= raw.scrollHeight) { + scope.$apply(attr.whenScrolled); + } + }); + }; +}); + +habitrpg + .directive('habitrpgTasks', ['$rootScope', 'User', function($rootScope, User) { + return { + restrict: 'EA', + templateUrl: 'templates/habitrpg-tasks.html', + //transclude: true, + //scope: { + // main: '@', // true if it's the user's main list + // obj: '=' + //}, + controller: ['$scope', '$rootScope', function($scope, $rootScope){ + $scope.editTask = function(task){ + task._editing = !task._editing; + task._tags = User.user.preferences.tagsCollapsed; + task._advanced = User.user.preferences.advancedCollapsed; + if($rootScope.charts[task.id]) $rootScope.charts[task.id] = false; + }; + }], + link: function(scope, element, attrs) { + // $scope.obj needs to come from controllers, so we can pass by ref + scope.main = attrs.main; + scope.modal = attrs.modal; + var dailiesView; + if(User.user.preferences.dailyDueDefaultView) { + dailiesView = "remaining"; + } else { + dailiesView = "all"; + } + $rootScope.lists = [ + { + header: window.env.t('habits'), + type: 'habit', + placeHolder: window.env.t('newHabit'), + placeHolderBulk: window.env.t('newHabitBulk'), + view: "all" + }, { + header: window.env.t('dailies'), + type: 'daily', + placeHolder: window.env.t('newDaily'), + placeHolderBulk: window.env.t('newDailyBulk'), + view: dailiesView + }, { + header: window.env.t('todos'), + type: 'todo', + placeHolder: window.env.t('newTodo'), + placeHolderBulk: window.env.t('newTodoBulk'), + view: "remaining" + }, { + header: window.env.t('rewards'), + type: 'reward', + placeHolder: window.env.t('newReward'), + placeHolderBulk: window.env.t('newRewardBulk'), + view: "all" + } + ]; + + } + } + }]); + +habitrpg.directive('fromNow', ['$interval', function($interval){ + return function(scope, element, attr){ + var updateText = function(){ element.text(moment(scope.message.timestamp).fromNow()) }; + updateText(); + // Update the counter every 60secs if was sent less than one hour ago otherwise every hour + // OPTIMIZATION, every time the interval is run, update the interval time + var intervalTime = moment().diff(scope.message.timestamp, 'minute') < 60 ? 60000 : 3600000; + var interval = $interval(function(){ updateText() }, intervalTime, false); + scope.$on('$destroy', function() { + $interval.cancel(interval); + }); + } +}]); + +habitrpg.directive('hrpgSortTasks', ['User', function(User) { + return function($scope, element, attrs, ngModel) { + $(element).sortable({ + axis: "y", + distance: 5, + start: function (event, ui) { + ui.item.data('startIndex', ui.item.index()); + }, + stop: function (event, ui) { + var task = angular.element(ui.item[0]).scope().task, + startIndex = ui.item.data('startIndex'); + User.user.ops.sortTask({ params: {id: task.id}, query: {from: startIndex, to: ui.item.index()} }); + } + }); + } +}]); + +habitrpg.directive('hrpgSortChecklist', ['User', function(User) { + return function($scope, element, attrs, ngModel) { + $(element).sortable({ + axis: "y", + distance: 5, + start: function (event, ui) { + ui.item.data('startIndex', ui.item.index()); + }, + stop: function (event, ui) { + var task = angular.element(ui.item[0]).scope().task, + startIndex = ui.item.data('startIndex'); + //$scope.saveTask(task, true); + $scope.swapChecklistItems(task, startIndex, ui.item.index()); + } + }); + } +}]); + +habitrpg.directive('hrpgSortTags', ['User', function(User) { + return function($scope, element, attrs, ngModel) { + $(element).sortable({ + axis: "x", + start: function (event, ui) { + ui.item.data('startIndex', ui.item.index()); + }, + stop: function (event, ui) { + User.user.ops.sortTag({query:{ from: ui.item.data('startIndex'), to:ui.item.index() }}); + } + }); + } +}]); + +habitrpg + .directive( 'popoverHtmlPopup', ['$sce', function($sce) { + return { + restrict: 'EA', + replace: true, + scope: { title: '@', content: '@', placement: '@', animation: '&', isOpen: '&' }, + link: function(scope, element, attrs) { + scope.$watch('content', function(value, oldValue) { + scope.unsafeContent = $sce.trustAsHtml(scope.content); + }); + }, + templateUrl: 'template/popover/popover-html.html' + }; + }]) + .directive( 'popoverHtml', [ '$compile', '$timeout', '$parse', '$window', '$tooltip', + function ( $compile, $timeout, $parse, $window, $tooltip ) { + return $tooltip( 'popoverHtml', 'popover', 'click' ); + } + ]) + .run(["$templateCache", function($templateCache) { + $templateCache.put("template/popover/popover-html.html", + "
\n" + + "
\n" + + "\n" + + "
\n" + + "

\n" + + "
\n" + + "
\n" + + "
\n"); + }]); diff --git a/website/public/js/env.js b/website/public/js/env.js new file mode 100644 index 0000000000..cdb40e695e --- /dev/null +++ b/website/public/js/env.js @@ -0,0 +1,16 @@ +"use strict"; + +window.env = window.env || {}; //FIX tests + +// If Moment.js is loaded, +if(window.moment && window.env.language && window.env.language.momentLang && window.env.language.momentLangCode){ + var head = document.getElementsByTagName('head')[0]; + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.text = window.env.language.momentLang; + head.appendChild(script); + window.moment.locale(window.env.language.momentLangCode); +} + +window.habitrpgShared.i18n.strings = window.env.translations; +window.env.t = window.habitrpgShared.i18n.t; \ No newline at end of file diff --git a/website/public/js/filters/filters.js b/website/public/js/filters/filters.js new file mode 100644 index 0000000000..5064566b1e --- /dev/null +++ b/website/public/js/filters/filters.js @@ -0,0 +1,23 @@ +angular.module('habitrpg') + .filter('gold', function () { + return function (gp) { + return Math.floor(gp); + } + }) + .filter('silver', function () { + return function (gp) { + return Math.floor((gp - Math.floor(gp))*100); + } + }) + .filter('htmlDecode',function(){ + return function(html){ + return $('
').html(html).text(); + } + }) + .filter('goldRoundThousandsToK', function(){ + return function (gp) { + return (gp > 999999999) ? (gp / Math.pow(10, 9)).toFixed(1) + "b" : + (gp > 999999) ? (gp / Math.pow(10, 6)).toFixed(1) + "m" : + (gp > 999) ? (gp / Math.pow(10, 3)).toFixed(1) + "k" : gp; + } + }) diff --git a/website/public/js/services/challengeServices.js b/website/public/js/services/challengeServices.js new file mode 100644 index 0000000000..51b91de8c2 --- /dev/null +++ b/website/public/js/services/challengeServices.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Services that persists and retrieves user from localStorage. + */ + +angular.module('habitrpg').factory('Challenges', +['ApiUrl', '$resource', +function(ApiUrl, $resource) { + var Challenge = $resource(ApiUrl.get() + '/api/v2/challenges/:cid', + {cid:'@_id'}, + { + //'query': {method: "GET", isArray:false} + join: {method: "POST", url: ApiUrl.get() + '/api/v2/challenges/:cid/join'}, + leave: {method: "POST", url: ApiUrl.get() + '/api/v2/challenges/:cid/leave'}, + close: {method: "POST", params: {uid:''}, url: ApiUrl.get() + '/api/v2/challenges/:cid/close'}, + getMember: {method: "GET", url: ApiUrl.get() + '/api/v2/challenges/:cid/member/:uid'} + }); + + //var challenges = []; + + return { + Challenge: Challenge + //challenges: challenges + } +}]); diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js new file mode 100644 index 0000000000..03fe1a708e --- /dev/null +++ b/website/public/js/services/groupServices.js @@ -0,0 +1,90 @@ +'use strict'; + +/** + * Services that persists and retrieves user from localStorage. + */ + +angular.module('habitrpg').factory('Groups', +['ApiUrl', '$resource', '$q', '$http', 'User', 'Challenges', +function(ApiUrl, $resource, $q, $http, User, Challenges) { + var Group = $resource(ApiUrl.get() + '/api/v2/groups/:gid', + {gid:'@_id', messageId: '@_messageId'}, + { + get: { + method: "GET", + isArray:false, + // Wrap challenges as ngResource so they have functions like $leave or $join + transformResponse: function(data, headers) { + data = angular.fromJson(data); + _.each(data && data.challenges, function(c) { + angular.extend(c, Challenges.Challenge.prototype); + }); + return data; + } + }, + + postChat: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat'}, + deleteChatMessage: {method: "DELETE", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId'}, + flagChatMessage: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/flag'}, + clearFlagCount: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/clearflags'}, + join: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/join'}, + leave: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/leave'}, + invite: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/invite'}, + removeMember: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/removeMember'}, + questAccept: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/questAccept'}, + questReject: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/questReject'}, + questCancel: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/questCancel'}, + questAbort: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/questAbort'} + }); + + // Defer loading everything until they're requested + var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined}; + + return { + party: function(cb){ + if (!data.party) return (data.party = Group.get({gid: 'party'}, cb)); + return (cb) ? cb(party) : data.party; + }, + publicGuilds: function(){ + //TODO combine these as {type:'guilds,public'} and create a $filter() to separate them + if (!data.publicGuilds) data.publicGuilds = Group.query({type:'public'}); + return data.publicGuilds; + }, + myGuilds: function(){ + if (!data.myGuilds) data.myGuilds = Group.query({type:'guilds'}); + return data.myGuilds; + }, + tavern: function(){ + if (!data.tavern) data.tavern = Group.get({gid:'habitrpg'}); + return data.tavern; + }, + + // On enter, set chat message to "seen" + seenMessage: function(gid){ + $http.post(ApiUrl.get() + '/api/v2/groups/'+gid+'/chat/seen'); + if (User.user.newMessages) delete User.user.newMessages[gid]; + }, + + // Pass reference to party, myGuilds, publicGuilds, tavern; inside data in order to + // be able to modify them directly (otherwise will be stick with cached version) + data: data, + + Group: Group + } +}]) +/** + * TODO Get this working. Make ChatService it's own ngResource, so we can update chat without having to sync the whole + * group object (expensive). Also so we can add chat-specific routes + */ +// .factory('Chat', ['API_URL', '$resource', +// function(API_URL, $resource) { +// var Chat = $resource(API_URL + '/api/v2/groups/:gid/chat/:mid', +// //{gid:'@_id', mid: '@_messageId'}, +// { +// like: {method: 'POST', url: API_URL + '/api/v2/groups/:gid/chat/:mid'} +// //postChat: {method: "POST", url: API_URL + '/api/v2/groups/:gid/chat'}, +// //deleteChatMessage: {method: "DELETE", url: API_URL + '/api/v2/groups/:gid/chat/:messageId'}, +// }); +// return {Chat:Chat}; +// } +// ]); diff --git a/website/public/js/services/guideServices.js b/website/public/js/services/guideServices.js new file mode 100644 index 0000000000..45f02161e0 --- /dev/null +++ b/website/public/js/services/guideServices.js @@ -0,0 +1,220 @@ +'use strict'; + +/** + * Services for each tour step when you unlock features + */ + +angular.module('habitrpg').factory('Guide', +['$rootScope', 'User', '$timeout', '$state', +function($rootScope, User, $timeout, $state) { + /** + * Init and show the welcome tour. Note we do it listening to a $rootScope broadcasted 'userLoaded' message, + * this because we need to determine whether to show the tour *after* the user has been pulled from the server, + * otherwise it's always start off as true, and then get set to false later + */ + var tourRunning = false; + $rootScope.$on('userUpdated', initTour); + function initTour(){ + if (User.user.flags.showTour === false || tourRunning) return; + tourRunning = true; + var tourSteps = [ + { + orphan:true, + title: window.env.t('welcomeHabit'), + content: window.env.t('welcomeHabitT1') + " Justin, " + window.env.t('welcomeHabitT2'), + }, { + element: ".main-herobox", + title: window.env.t('yourAvatar'), + content: window.env.t('yourAvatarText'), + }, { + element: ".main-herobox", + title: window.env.t('avatarCustom'), + content: window.env.t('avatarCustomText'), + }, { + element: ".hero-stats", + title: window.env.t('hitPoints'), + content: window.env.t('hitPointsText'), + }, { + element: ".hero-stats", + title: window.env.t('expPoints'), + content: window.env.t('expPointsText'), + }, { + element: "ul.habits", + title: window.env.t('typeGoals'), + content: window.env.t('typeGoalsText'), + placement: "top" + }, { + element: "ul.habits", + title: window.env.t('habits'), + content: window.env.t('tourHabits'), + placement: "top" + }, { + element: "ul.dailys", + title: window.env.t('dailies'), + content: window.env.t('tourDailies'), + placement: "top" + }, { + element: "ul.todos", + title: window.env.t('todos'), + content: window.env.t('tourTodos'), + placement: "top", + }, { + element: "ul.main-list.rewards", + title: window.env.t('rewards'), + content: window.env.t('tourRewards'), + placement: "top" + }, { + element: "ul.habits li:first-child", + title: window.env.t('hoverOver'), + content: window.env.t('hoverOverText'), + placement: "right" + }, { + orphan:true, + title: window.env.t('unlockFeatures'), + content: window.env.t('unlockFeaturesT1') + " " + window.env.t('habitWiki') + " " + window.env.t('unlockFeaturesT2'), + placement: "right" + } + ]; + $('.main-herobox').popover('destroy'); + var tour = new Tour({ + backdrop: true, + //orphan: true, + //keyboard: false, + template: '', + onEnd: function(){ + User.set({'flags.showTour': false}); + } + }); + _.each(tourSteps, function(step) { + step.content = "
" + step.content + "
"; + step.onShow = function(){ + // Since all the steps are currently on the tasks page, ensure we go back there for each step in case they + // clicked elsewhere during the tour. FIXME: $state.go() returns a promise, necessary for async tour steps; + // however, that's not working here - have to use timeout instead :/ + if (!$state.is('tasks')) return $timeout(function(){$state.go('tasks');}, 0) + } + step.html = true; + tour.addStep(step); + }); + tour.restart(); // Tour doesn't quite mesh with our handling of flags.showTour, just restart it on page load + //tour.start(true); + }; + + var alreadyShown = function(before, after) { + return !(!before && after === true); + }; + + var showPopover = function(selector, title, html, placement) { + if (!placement) placement = 'bottom'; + $(selector).popover('destroy'); + var button = ""; + if (env.worldDmg.guide) { + html = "
" + html + '
' + button + '
'; + } else { + html = "
" + html + '
' + button + '
'; + } + $(selector).popover({ + title: title, + placement: placement, + trigger: 'manual', + html: true, + content: html + }).popover('show'); + }; + + $rootScope.$watch('user.flags.customizationsNotification', function(after, before) { + if (alreadyShown(before, after)) return; + showPopover('.main-herobox', window.env.t('customAvatar'), window.env.t('customAvatarText'), 'bottom'); + }); + + $rootScope.$watch('user.flags.itemsEnabled', function(after, before) { + if (alreadyShown(before, after)) return; + var html = window.env.t('storeUnlockedText'); + showPopover('div.rewards', window.env.t('storeUnlocked'), html, 'left'); + }); + + $rootScope.$watch('user.flags.partyEnabled', function(after, before) { + if (alreadyShown(before, after)) return; + var html = window.env.t('partySysText'); + showPopover('.user-menu', window.env.t('partySys'), html, 'bottom'); + }); + + $rootScope.$watch('user.flags.dropsEnabled', function(after, before) { + if (alreadyShown(before, after)) return; + var eggs = User.user.items.eggs || {}; + if (!eggs) { + eggs['Wolf'] = 1; // This is also set on the server + } + $rootScope.openModal('dropsEnabled'); + }); + + $rootScope.$watch('user.flags.rebirthEnabled', function(after, before) { + if (alreadyShown(before, after)) return; + $rootScope.openModal('rebirthEnabled'); + }); + + + /** + * Classes Tour + */ + function classesTour(){ + + // TODO notice my hack-job `onShow: _.once()` functions. Without these, the syncronous path redirects won't properly handle showing tour + var tourSteps = [ + { + path: '/#/options/inventory/equipment', + onShow: _.once(function(tour){ + $timeout(function(){tour.goTo(0)}); + }), + element: '.equipment-tab', + title: window.env.t('classGear'), + content: window.env.t('classGearText', {klass: User.user.stats.class}) + }, + { + path: '/#/options/profile/stats', + onShow: _.once(function(tour){ + $timeout(function(){tour.goTo(1)}); + }), + element: ".allocate-stats", + title: window.env.t('stats'), + content: window.env.t('classStats'), + }, { + element: ".auto-allocate", + title: window.env.t('autoAllocate'), + placement: 'left', + content: window.env.t('autoAllocateText'), + }, { + element: ".meter.mana", + title: window.env.t('spells'), + content: window.env.t('spellsText') + " " + window.env.t('toDo') + "." + }, { + orphan: true, + title: window.env.t('readMore'), + content: window.env.t('moreClass') + " Wikia." + } + ]; + _.each(tourSteps, function(step){ + if (env.worldDmg.guide) { + step.content = "
" + step.content + "
"; + } else { + step.content = "
" + step.content + "
"; + } + }); + $('.allocate-stats').popover('destroy'); + var tour = new Tour({ +// onEnd: function(){ +// User.set({'flags.showTour': false}); +// } + }); + tourSteps.forEach(function(step) { + tour.addStep(_.defaults(step, {html: true})); + }); + tour.restart(); // Tour doesn't quite mesh with our handling of flags.showTour, just restart it on page load + //tour.start(true); + }; + + return { + initTour: initTour, + classesTour: classesTour + }; +}]); diff --git a/website/public/js/services/memberServices.js b/website/public/js/services/memberServices.js new file mode 100644 index 0000000000..53a1c1e998 --- /dev/null +++ b/website/public/js/services/memberServices.js @@ -0,0 +1,87 @@ +'use strict'; + +/** + * Services that persists and retrieves user from localStorage. + */ + +angular.module('habitrpg').factory('Members', +['$rootScope', 'Shared', 'ApiUrl', '$resource', +function($rootScope, Shared, ApiUrl, $resource) { + var members = {}; + var Member = $resource(ApiUrl.get() + '/api/v2/members/:uid', {uid:'@_id'}); + var memberServices = { + + Member: Member, + + members: members, + + /** + * Allows us to lazy-load party / group / public members throughout the application. + * @param obj - either a group or an individual member. If it's a group, we lazy-load all of its members. + */ + populate: function(obj){ + + function populateGroup(group){ + _.each(group.members, function(member){ + // meaning `populate('members')` wasn't run on the server, so we're getting the "in-database" form of + // the members array, which is just a list of IDs - not the populated objects + if (_.isString(member)) return; + + // lazy-load + members[member._id] = member; + }) + } + + // Array of groups + if (_.isArray(obj)) { + if (obj[0] && obj[0].members) { + _.each(obj, function(group){ + populateGroup(group); + }) + } + + // Individual Group + } else if (obj.members) + populateGroup(obj); + + // individual Member + if (obj._id) { + members[obj._id] = obj; + } + }, + + selectedMember: undefined, + + /** + * Once users are populated, we fetch them throughout the application (eg, modals). This + * either gets them or fetches if not available + * @param uid + */ + selectMember: function(uid, cb) { + var self = this; + // Fetch from cache if we can. For guild members, only their uname will have been fetched on initial load, + // check if they have full fields (eg, check profile.items and an item inside + // because sometimes profile.items exists but it's empty like when user is fetched for party + // and then for guild) + // and if not, fetch them + if (members[uid] && members[uid].items && members[uid].items.weapon) { + Shared.wrap(members[uid],false); + self.selectedMember = members[uid]; + cb(); + } else { + Member.get({uid: uid}, function(member){ + self.populate(member); // lazy load for later + Shared.wrap(member,false); + self.selectedMember = members[member._id]; + cb(); + }); + } + } + } + + $rootScope.$on('userUpdated', function(event, user){ + memberServices.populate(user); + }) + + return memberServices; +}]); diff --git a/website/public/js/services/notificationServices.js b/website/public/js/services/notificationServices.js new file mode 100644 index 0000000000..a0dde36fbb --- /dev/null +++ b/website/public/js/services/notificationServices.js @@ -0,0 +1,84 @@ +/** + Set up "+1 Exp", "Level Up", etc notifications + */ +angular.module("habitrpg").factory("Notification", +[function() { + var stack_topright = {"dir1": "down", "dir2": "left", "spacing1": 15, "spacing2": 15, "firstpos1": 60}; + function notify(html, type, icon) { + var notice = $.pnotify({ + type: type || 'warning', //('info', 'text', 'warning', 'success', 'gp', 'xp', 'hp', 'lvl', 'death', 'mp', 'crit') + text: html, + opacity: 1, + addclass: 'alert-' + type, + delay: 7000, + hide: (type == 'error') ? false : true, + mouse_reset: false, + width: "250px", + stack: stack_topright, + icon: icon || false + }).click(function() { notice.pnotify_remove() }); + }; + + /** + Show "+ 5 {gold_coin} 3 {silver_coin}" + */ + function coins(money) { + var absolute, gold, silver; + absolute = Math.abs(money); + gold = Math.floor(absolute); + silver = Math.floor((absolute - gold) * 100); + if (gold && silver > 0) { + return "" + gold + " " + silver + " "; + } else if (gold > 0) { + return "" + gold + " "; + } else if (silver > 0) { + return "" + silver + " "; + } + }; + + var sign = function(number){ + return number?number<0?'-':'+':'+'; + } + + var round = function(number){ + return Math.abs(number.toFixed(1)); + } + + return { + coins: coins, + hp: function(val) { + // don't show notifications if user dead + notify(sign(val) + " " + round(val) + " " + window.env.t('hp'), 'hp', 'glyphicon glyphicon-heart'); + }, + exp: function(val) { + if (val < -50) return; // don't show when they level up (resetting their exp) + notify(sign(val) + " " + round(val) + " " + window.env.t('xp'), 'xp', 'glyphicon glyphicon-star'); + }, + gp: function(val, bonus) { + notify(sign(val) + " " + coins(val - bonus), 'gp'); + }, + text: function(val){ + if (val) { + notify(val, 'info'); + } + }, + lvl: function(){ + notify(window.env.t('levelUp'), 'lvl', 'glyphicon glyphicon-chevron-up'); + }, + error: function(error){ + notify(error, "danger", 'glyphicon glyphicon-exclamation-sign'); + }, + mp: function(val) { + notify(sign(val) + " " + round(val) + " " + window.env.t('mp'), 'mp', 'glyphicon glyphicon-fire'); + }, + crit: function(val) { + notify(window.env.t('critBonus') + Math.round(val) + "%", 'crit', 'glyphicon glyphicon-certificate'); + }, + streak: function(val) { + notify(window.env.t('streakName') + ': ' + val, 'streak', 'glyphicon glyphicon-repeat'); + }, + drop: function(val) { + notify(val, 'drop', 'glyphicon glyphicon-gift'); + } + }; +}]); diff --git a/website/public/js/services/paymentServices.js b/website/public/js/services/paymentServices.js new file mode 100644 index 0000000000..bc677b9155 --- /dev/null +++ b/website/public/js/services/paymentServices.js @@ -0,0 +1,69 @@ +'use strict'; + +angular.module('habitrpg').factory('Payments', +['$rootScope', 'User', '$http', 'Content', +function($rootScope, User, $http, Content) { + var Payments = {}; + + Payments.showStripe = function(data) { + var sub = + data.subscription ? data.subscription + : data.gift && data.gift.type=='subscription' ? data.gift.subscription.key + : false; + sub = sub && Content.subscriptionBlocks[sub]; + var amount = // 500 = $5 + sub ? sub.price*100 + : data.gift && data.gift.type=='gems' ? data.gift.gems.amount/4*100 + : 500; + StripeCheckout.open({ + key: window.env.STRIPE_PUB_KEY, + address: false, + amount: amount, + name: 'HabitRPG', + description: sub ? window.env.t('subscribe') : window.env.t('checkout'), + image: "/apple-touch-icon-144-precomposed.png", + panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), + token: function(res) { + var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below + if (data.gift) url += '&gift=' + Payments.encodeGift(data.uuid, data.gift); + if (data.subscription) url += '&sub='+sub.key; + if (data.coupon) url += '&coupon='+data.coupon; + $http.post(url, res).success(function() { + window.location.reload(true); + }).error(function(res) { + alert(res.err); + }); + } + }); + } + + Payments.showStripeEdit = function(){ + StripeCheckout.open({ + key: window.env.STRIPE_PUB_KEY, + address: false, + name: window.env.t('subUpdateTitle'), + description: window.env.t('subUpdateDescription'), + panelLabel: window.env.t('subUpdateCard'), + token: function(data) { + var url = '/stripe/subscribe/edit'; + $http.post(url, data).success(function() { + window.location.reload(true); + }).error(function(data) { + alert(data.err); + }); + } + }); + } + + Payments.cancelSubscription = function(){ + if (!confirm(window.env.t('sureCancelSub'))) return; + window.location.href = '/' + User.user.purchased.plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.user.apiToken; + } + + Payments.encodeGift = function(uuid, gift){ + gift.uuid = uuid; + return JSON.stringify(gift); + } + + return Payments; +}]); diff --git a/website/public/js/services/sharedServices.js b/website/public/js/services/sharedServices.js new file mode 100644 index 0000000000..5694d56fb3 --- /dev/null +++ b/website/public/js/services/sharedServices.js @@ -0,0 +1,13 @@ +'use strict'; + +/** + * Services that expose habitrpg-shared + */ + +angular.module('habitrpg') +.factory('Shared', [function () { + return window.habitrpgShared; +}]) +.factory('Content', ['Shared', function (Shared) { + return Shared.content; +}]); diff --git a/website/public/js/static.js b/website/public/js/static.js new file mode 100644 index 0000000000..3fdd1e6f2d --- /dev/null +++ b/website/public/js/static.js @@ -0,0 +1,41 @@ +"use strict"; + +window.habitrpg = angular.module('habitrpg', ['chieffancypants.loadingBar', 'ui.bootstrap']) + .constant("API_URL", "") + .constant("STORAGE_USER_ID", 'habitrpg-user') + .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') + .constant("MOBILE_APP", false) + +.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', function($scope, $location, $modal, $http){ + var memberId = $location.search()['memberId']; + if (memberId) { + $http.get('/api/v2/members/'+memberId).success(function(data, status, headers, config){ + $scope.profile = data; + $scope.Content = window.habitrpgShared.content; + $modal.open({ + templateUrl: 'modals/member.html', + scope: $scope + }); + }) + } + }]) + +.controller("PlansCtrl", ['$rootScope', + function($rootScope) { + $rootScope.clickContact = function(){ + window.ga && ga('send', 'event', 'button', 'click', 'Contact Us (Plans)'); + } + } +]) + +.controller('AboutCtrl',[function(){ + $(document).ready(function(){ + $('a.gallery').colorbox({ + maxWidth: '90%', + maxHeight: '80%', + transition: 'none', + scalePhotos:true + //maxHeight: '70%' + }); + }); +}]) diff --git a/website/public/logo/HABITRPG logo version 1.psd b/website/public/logo/HABITRPG logo version 1.psd new file mode 100755 index 0000000000..7f6d4878b2 Binary files /dev/null and b/website/public/logo/HABITRPG logo version 1.psd differ diff --git a/website/public/logo/HABITRPG-logo-version-1.gif b/website/public/logo/HABITRPG-logo-version-1.gif new file mode 100755 index 0000000000..b4c0136f3d Binary files /dev/null and b/website/public/logo/HABITRPG-logo-version-1.gif differ diff --git a/website/public/logo/habitrpg.jpg b/website/public/logo/habitrpg.jpg new file mode 100644 index 0000000000..efc9343a4e Binary files /dev/null and b/website/public/logo/habitrpg.jpg differ diff --git a/website/public/logo/habitrpg_bl.eps b/website/public/logo/habitrpg_bl.eps new file mode 100644 index 0000000000..c4e871c2cf --- /dev/null +++ b/website/public/logo/habitrpg_bl.eps @@ -0,0 +1,5802 @@ +%!PS-Adobe-3.1 EPSF-3.0 +%ADO_DSC_Encoding: MacOS Roman +%%Title: habitrpg_bl.eps +%%Creator: Adobe Illustrator(R) 14.0 +%%For: Joanna P +%%CreationDate: 13-02-17 +%%BoundingBox: 0 0 266 79 +%%HiResBoundingBox: 0 0 265.9145 78.2041 +%%CropBox: 0 0 265.9145 78.2041 +%%LanguageLevel: 2 +%%DocumentData: Clean7Bit +%ADOBeginClientInjection: DocumentHeader "AI11EPS" +%%AI8_CreatorVersion: 14.0.0 %AI9_PrintingDataBegin %ADO_BuildNumber: Adobe Illustrator(R) 14.0.0 x367 R agm 4.4890 ct 5.1541 %ADO_ContainsXMP: MainFirst %AI7_Thumbnail: 128 40 8 %%BeginData: 8480 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C455227522752275227FFA85227522752275227FD05FFA85227522752 %275252FD05FF7D522752275227522752527DA8FD05FF5252275227522752 %7DFF275227522752275227522752277DA8522752275227522752527DA8FD %05FF2752275227522752275252A8FD08FFA852FD05F87DFFFFFFFD08F8FF %A8FD08F8FD05FFA8FD07F827FD05FF7DFD0CF852FD04FF27FD07F87D7DFD %0DF852A8FD0CF827FD04FFFD0CF827A8FD05FFA827F8F8F827F8F8F852FF %FFFFA827F8F8F8FD05FFA8F8F8F827FD07FF7DF8F8F82727F8F8F8FD07FF %52F8F8F852A87DA852F8F8F852FD05FF27F8F8F852FFFFA8F8F87DA87DF8 %F8F852A8A827F87DFFFF7DF8F8F852A8A8A852F8F8F827FD05FF27F8F8F8 %A8A87DA827F8F827A8FD04FF27F8F827FFFF7DF8F8F87DFFFFFF27F8F827 %FD06FFF8F8F827FD07FF7DF8F8F8A827F8F8F8FD07FFA8F8F8F87DFD04FF %52F8F8F87DFD04FF7DF8F8F8A8FFFF7DF8F8FFFFA8F8F8F87DFFFF52F852 %FFFFFFF8F8F87DFD04FF52F8F8F87DFD04FF27F8F8F8FD05FFF8F8F827FF %FFFFA8F8F8F87DFFFFFF27F8F8F8FFFFFF52F8F827FD06FFF8F8F827FD07 %FF52F8F8F8A87DF8F8F8FD07FFA8F8F8F852FD05FFF8F8F852FD04FF52F8 %F8F8A8FFFFA8F852FFFFA8F8F8F852FFFFA8F87DFFFFFFF8F8F852FD05FF %27F8F827FD04FF27F8F8F8FD05FF7DF8F8F8A8FFFF52F8F8F8FD04FF7DF8 %F8F8A8FFFF27F8F827FD06FFF8F8F827FD07FF52F8F8F8FF52F8F8F87DFD %06FFA8F8F8F87DFD05FF52F8F8F8FD04FF7DF8F8F8A8FFFFA8277DFFFFA8 %F8F8F87DFFFFA8277DFFFFFFF8F8F87DFD05FF52F8F8F8FD04FF27F8F8F8 %FD05FFA8F8F8F852FFFF27F8F827FD04FFA8F8F8F87DFFFF27F8F827FD06 %FFF8F8F827FD07FF27F8F8F8FF7DF8F8F8A8FD06FF7DF8F8F852FD05FF52 %F8F8F8A8FFFFFF52F8F8F8A8FD07FFA8F8F8F852FD08FFF8F8F852FD05FF %7DF8F8F87DFFFFFF27F8F8F8FD06FFF8F8F852FFFFF8F8F852FD05FFF8F8 %F827FFFF27F8F827FD06FFF8F8F827FD07FF27F8F827FF7DF8F8F87DFD06 %FFA8F8F8F87DFD05FFA8F8F8F8A8FFFFFF7DF8F8F8A8FD07FFA8F8F8F87D %FD08FFF8F8F87DFD05FFA8F8F8F87DFFFFFF27F8F8F8FD06FF27F8F827FF %A8F8F8F852FD05FFF8F8F827FFFF52F8F827FD06FFF8F8F827FD07FFF8F8 %F852FFA8F8F8F87DFD06FFA8F8F8F852FD05FFA8F8F8F8A8FFFFFF52F8F8 %F8A8FD07FFA8F8F8F852FD08FFF8F8F852FD05FFA8F8F8F852FFFFFF27F8 %F8F8FD06FF27F8F852FFA8F8F8F87DFD05FF52F8F8F8FFFF27F8F827FD06 %FFF8F8F827FD07FFF8F8F827FFA8F8F8F852FD06FFA8F8F8F87DFD05FFA8 %F8F8F8A8FFFFFF7DF8F8F8A8FD07FFA8F8F8F87DFD08FFF8F8F87DFD05FF %A8F8F8F87DFFFFFF27F8F8F8FD06FF27F8F827FF7DF8F8F87DFD05FF27F8 %F8F8FFFF52F8F827FD06FFF8F8F827FD07FFF8F8F87DFFFFF8F8F852FD06 %FFA8F8F8F852FD05FF7DF8F8F87DFFFFFF52F8F8F87DFD07FF7DF8F8F852 %FD08FFF8F8F852FD05FFA8F8F8F852FFFFFF27F8F8F8FD06FF27F8F827FF %A8F8F8F8A8FD05FF7DF8F8F8FFFF27F8F827FD06FFF8F8F827FD06FF7DF8 %F8F852FFFFF8F8F827FD06FFA8F8F8F87DFD05FF7DF8F8F8A8FFFFFF7DF8 %F8F8A8FD07FFA8F8F8F87DFD08FFF8F8F87DFD05FFA8F8F8F87DFFFFFF27 %F8F8F8FD06FF27F8F827FF7DF8F8F87DFD05FF7D7D527DFFFF52F8F827FD %06FFF8F8F827FD06FFA8F8F8F8A8FFFF27F8F852FD06FFA8F8F8F852FD05 %FF52F8F8F8A8FFFFFF52F8F8F8A8FD07FFA8F8F8F852FD08FFF8F8F852FD %05FF7DF8F8F8A8FFFFFF27F8F8F8FD06FFF8F8F852FF7DF8F8F8A8FD0BFF %27F8F827FD06FFF8F8F827FD06FF7DF8F8F87DFFFF27F8F827FD06FFA8F8 %F8F87DFD05FF27F8F8F8FD04FF7DF8F8F8A8FD07FFA8F8F8F87DFD08FFF8 %F8F87DFD05FF52F8F8F8FD04FF27F8F8F8FD05FF7DF8F8F87DFF52F8F8F8 %7DFD0BFF27F8F827FD06FFF8F8F827FD06FF7DF8F8F8A8FFFF27F8F8F8FD %06FF7DF8F8F852FD05FFF8F8F852FD04FF52F8F8F8A8FD07FFA8F8F8F852 %FD08FFF8F8F852FD05FFF8F8F827FD04FF27F8F8F8FD05FF52F8F8F8FFFF %7DF8F8F8A8FD0BFF27F8F827FD06FFF8F8F827FD06FF52F8F8F8A8FFFF7D %F8F8F8FD06FFA8F8F8F87DFFFFFFA8FD04F87DFD04FF7DF8F8F8A8FD07FF %A8F8F8F87DFD08FFF8F8F87DFD04FF27F8F8F87DFD04FF27F8F8F8FD04FF %7DF8F8F827FFFF52F8F8F87DFD0BFF52FD0CF827FD06FF7DFD0AF8A8FD05 %FFA8FD0BF852FD05FF52F8F8F8A8FD07FFA8F8F8F852FD08FFFD0BF852FD %05FF27FD0AF827A8FFFF7DF8F8F8A8FD0BFF27FD0CF827FD06FF27FD0AF8 %A8FD05FFA8FD0BF8A8FD05FF7DF8F8F8A8FD07FFA8F8F8F87DFD08FFFD0A %F852FD06FF27FD09F827A8FFFFFF52F8F8F87DFFFFFF7DA87DA87DA8FFFF %52F8F8F87D527D527D52F8F8F827FD06FF52F8F8F87D527D52F8F8F87DFD %05FFA8F8F8F8277D527D52FD04F8FD05FF52F8F8F87DFD07FF7DF8F8F852 %FD08FFF8F8F8277D5227F8F8F8FD07FF27F8F8F8527D527D7DA8FD05FF7D %F8F8F8A8FFFF52FD06F8FFFF27F8F827FD06FFF8F8F827FD06FFF8F8F827 %FFFFFFA8F8F8F87DFD05FFA8F8F8F87DFD04FF7DF8F8F827FD04FF7DF8F8 %F8A8FD07FFA8F8F8F87DFD08FFF8F8F87DFFFF52F8F8F8A8FD06FF27F8F8 %F8FD0BFF52F8F8F87DFFFF7DFD06F8FFFF52F8F827FD06FFF8F8F827FD06 %FFF8F8F827FFFFFFA8F8F8F852FD05FFA8F8F8F852FD05FF27F8F827FD04 %FF52F8F8F8A8FD07FFA8F8F8F852FD08FFF8F8F852FFFF7DF8F8F87DFD06 %FF27F8F8F8FD0BFF7DF8F8F8A8FFFF52F87D7DF8F8F8FFFF27F8F827FD06 %FFF8F8F827FD06FFF8F8F852FD04FFF8F8F87DFD05FFA8F8F8F87DFD05FF %7DF8F8F8A8FFFFFF7DF8F8F8A8FD07FFA8F8F8F87DFD08FFF8F8F87DFFFF %A8F8F8F852FD06FF27F8F8F8FD0BFF52F8F8F87DFFFF7DF8A852F8F8F8FF %FF27F8F827FD06FFF8F8F827FD05FFA8F8F8F852FD04FFF8F8F827FD05FF %7DF8F8F852FD05FF7DF8F8F8A8FFFFFF52F8F8F8A8FD07FFA8F8F8F852FD %08FFF8F8F852FFFFFFF8F8F852FD06FF27F8F8F8FD0BFFA8F8F8F8A8FFFF %52F8A87DF8F8F8FFFF27F8F827FD06FFF8F8F827FD05FFA8F8F8F87DFD04 %FFF8F8F827FD05FFA8F8F8F87DFD05FFA8F8F8F8A8FFFFFF7DF8F8F8A8FD %07FFA8F8F8F87DFD08FFF8F8F87DFFFFFF27F8F8F8FD06FF27F8F8F8FD0B %FF7DF8F8F87DFFFFFF7DFF52F8F8F8FFFF52F8F827FD06FFF8F8F827FD05 %FFA8F8F8F87DFD04FF52F8F827FD05FFA8F8F8F852FD05FFA8F8F8F8A8FF %FFFF52F8F8F8A8FD07FFA8F8F8F852FD08FFF8F8F852FFFFFF52F8F8F8A8 %FD05FF27F8F8F8FD0BFFA8F8F8F8A8FD05FF52F8F8F8FFFF27F8F827FD06 %FFF8F8F827FD05FF7DF8F8F8A8FD04FF27F8F8F8FD05FFA8F8F8F87DFD05 %FFA8F8F8F8A8FFFFFF7DF8F8F8A8FD07FFA8F8F8F87DFD08FFF8F8F87DFF %FFFF7DF8F8F8A8FD05FF27F8F8F8FD0BFF7DF8F8F852FD05FF27F8F827FF %FF52F8F827FD06FFF8F8F827FD05FF52F8F8F87DFD04FF52F8F8F8FD05FF %A8F8F8F852FD05FF7DF8F8F87DFFFFFF52F8F8F87DFD07FF7DF8F8F852FD %08FFF8F8F852FFFFFFA8F8F8F852FD05FF27F8F8F8FD0CFFF8F8F87DFD05 %FF27F8F827FFFF27F8F827FD06FFF8F8F827FD05FF7DF8F8F8FD05FF52F8 %F8F8FD05FFA8F8F8F87DFD05FF7DF8F8F8A8FFFFFF7DF8F8F8A8FD07FFA8 %F8F8F87DFD08FFF8F8F87DFD04FFF8F8F827FD05FF27F8F8F8FD0CFFF8F8 %F827FD05FFF8F8F852FFFF52F8F827FD06FFF8F8F827FD05FF27F8F8F8FD %05FF7DF8F8F8A8FD04FFA8F8F8F852FD05FF27F8F827FD04FF52F8F8F8A8 %FD07FFA8F8F8F852FD08FFF8F8F852FD04FF52F8F8F8FD05FF27F8F8F8FD %0CFF27F8F827FD04FFA8F8F8F87DFFFF27F8F827FD06FFF8F8F827FD05FF %27F8F8F8FD05FF52F8F8F87DFD04FFA8F8F8F87DFD04FFA8F8F8F852FD04 %FF7DF8F8F8A8FD07FFA8F8F8F87DFD08FFF8F8F87DFD04FF27F8F8F8A8FD %04FF27F8F8F8FD0CFF7DF8F8F8FD04FF7DF8F8F8A8FFFF27F8F827FD06FF %F8F8F827FD05FF27F8F852FD05FFA8F8F8F87DFD04FF7DF8F8F852FD04FF %7DF8F8F8A8FD04FF52F8F8F8A8FD07FFA8F8F8F852FD08FFF8F8F852FD04 %FF7DF8F8F8A8FD04FF27F8F8F8FD0CFFA8F8F8F852FFFFFF27F8F827FF7D %A8FD04F87DA8FFFF7D7DFD04F87DA8A8A87DFD04F8A87DFFA8A827F8F8F8 %27A8A8FF7D52F8F8F8527DA87D52F8F8F852FFFFFFA87D27F8F8F8527DA8 %FFFFFFA87D52F8F8F8277DA8FD04FF7D7DF8F8F8277DA8FFFF7DF8F8F852 %FFFFA87DFD04F87D7DFD0BFF27F8F8F87DFF52F8F8F87DFFFD08F8FFA8FD %08F8A8FD08F8FF7DFD07F85252FD0CF87DFD04FF27FD07F852FFFFFF52FD %07F827FFFFFFA8FD07F827FFFFFFF8F8F852FFFFFD08F8A8FD0BFF27FD07 %F852FFFF527D527D527D527DFFFF527D527D527D527DA87D527D527D527D %52FF7D7D527D527D527D7DA8527D527D527D527D527D7DFD06FF7D527D52 %7D527D52A8FFFFFFA8527D527D527D527DFFFFFFA8527D527D527D527DFF %FFFF27F8F8F8FFFF7D527D527D527D52FD0DFF5227F8F8F8527DFD5EFF52 %F8F8F8FD7CFF52F8F8F87DFD79FF27FD07F852FD77FF52FD07277DFDFCFF %FD21FFFF %%EndData +%ADOEndClientInjection: DocumentHeader "AI11EPS" +%%Pages: 1 +%%DocumentNeededResources: +%%DocumentSuppliedResources: procset Adobe_AGM_Image 1.0 0 +%%+ procset Adobe_CoolType_Utility_T42 1.0 0 +%%+ procset Adobe_CoolType_Utility_MAKEOCF 1.23 0 +%%+ procset Adobe_CoolType_Core 2.31 0 +%%+ procset Adobe_AGM_Core 2.0 0 +%%+ procset Adobe_AGM_Utils 1.0 0 +%%DocumentFonts: +%%DocumentNeededFonts: +%%DocumentNeededFeatures: +%%DocumentSuppliedFeatures: +%%DocumentProcessColors: Cyan Magenta Yellow Black +%%DocumentCustomColors: +%%CMYKCustomColor: +%%RGBCustomColor: +%%EndComments + + + + + + +%%BeginDefaults +%%ViewingOrientation: 1 0 0 1 +%%EndDefaults +%%BeginProlog +%%BeginResource: procset Adobe_AGM_Utils 1.0 0 +%%Version: 1.0 0 +%%Copyright: Copyright(C)2000-2006 Adobe Systems, Inc. All Rights Reserved. +systemdict/setpacking known +{currentpacking true setpacking}if +userdict/Adobe_AGM_Utils 75 dict dup begin put +/bdf +{bind def}bind def +/nd{null def}bdf +/xdf +{exch def}bdf +/ldf +{load def}bdf +/ddf +{put}bdf +/xddf +{3 -1 roll put}bdf +/xpt +{exch put}bdf +/ndf +{ + exch dup where{ + pop pop pop + }{ + xdf + }ifelse +}def +/cdndf +{ + exch dup currentdict exch known{ + pop pop + }{ + exch def + }ifelse +}def +/gx +{get exec}bdf +/ps_level + /languagelevel where{ + pop systemdict/languagelevel gx + }{ + 1 + }ifelse +def +/level2 + ps_level 2 ge +def +/level3 + ps_level 3 ge +def +/ps_version + {version cvr}stopped{-1}if +def +/set_gvm +{currentglobal exch setglobal}bdf +/reset_gvm +{setglobal}bdf +/makereadonlyarray +{ + /packedarray where{pop packedarray + }{ + array astore readonly}ifelse +}bdf +/map_reserved_ink_name +{ + dup type/stringtype eq{ + dup/Red eq{ + pop(_Red_) + }{ + dup/Green eq{ + pop(_Green_) + }{ + dup/Blue eq{ + pop(_Blue_) + }{ + dup()cvn eq{ + pop(Process) + }if + }ifelse + }ifelse + }ifelse + }if +}bdf +/AGMUTIL_GSTATE 22 dict def +/get_gstate +{ + AGMUTIL_GSTATE begin + /AGMUTIL_GSTATE_clr_spc currentcolorspace def + /AGMUTIL_GSTATE_clr_indx 0 def + /AGMUTIL_GSTATE_clr_comps 12 array def + mark currentcolor counttomark + {AGMUTIL_GSTATE_clr_comps AGMUTIL_GSTATE_clr_indx 3 -1 roll put + /AGMUTIL_GSTATE_clr_indx AGMUTIL_GSTATE_clr_indx 1 add def}repeat pop + /AGMUTIL_GSTATE_fnt rootfont def + /AGMUTIL_GSTATE_lw currentlinewidth def + /AGMUTIL_GSTATE_lc currentlinecap def + /AGMUTIL_GSTATE_lj currentlinejoin def + /AGMUTIL_GSTATE_ml currentmiterlimit def + currentdash/AGMUTIL_GSTATE_do xdf/AGMUTIL_GSTATE_da xdf + /AGMUTIL_GSTATE_sa currentstrokeadjust def + /AGMUTIL_GSTATE_clr_rnd currentcolorrendering def + /AGMUTIL_GSTATE_op currentoverprint def + /AGMUTIL_GSTATE_bg currentblackgeneration cvlit def + /AGMUTIL_GSTATE_ucr currentundercolorremoval cvlit def + currentcolortransfer cvlit/AGMUTIL_GSTATE_gy_xfer xdf cvlit/AGMUTIL_GSTATE_b_xfer xdf + cvlit/AGMUTIL_GSTATE_g_xfer xdf cvlit/AGMUTIL_GSTATE_r_xfer xdf + /AGMUTIL_GSTATE_ht currenthalftone def + /AGMUTIL_GSTATE_flt currentflat def + end +}def +/set_gstate +{ + AGMUTIL_GSTATE begin + AGMUTIL_GSTATE_clr_spc setcolorspace + AGMUTIL_GSTATE_clr_indx{AGMUTIL_GSTATE_clr_comps AGMUTIL_GSTATE_clr_indx 1 sub get + /AGMUTIL_GSTATE_clr_indx AGMUTIL_GSTATE_clr_indx 1 sub def}repeat setcolor + AGMUTIL_GSTATE_fnt setfont + AGMUTIL_GSTATE_lw setlinewidth + AGMUTIL_GSTATE_lc setlinecap + AGMUTIL_GSTATE_lj setlinejoin + AGMUTIL_GSTATE_ml setmiterlimit + AGMUTIL_GSTATE_da AGMUTIL_GSTATE_do setdash + AGMUTIL_GSTATE_sa setstrokeadjust + AGMUTIL_GSTATE_clr_rnd setcolorrendering + AGMUTIL_GSTATE_op setoverprint + AGMUTIL_GSTATE_bg cvx setblackgeneration + AGMUTIL_GSTATE_ucr cvx setundercolorremoval + AGMUTIL_GSTATE_r_xfer cvx AGMUTIL_GSTATE_g_xfer cvx AGMUTIL_GSTATE_b_xfer cvx + AGMUTIL_GSTATE_gy_xfer cvx setcolortransfer + AGMUTIL_GSTATE_ht/HalftoneType get dup 9 eq exch 100 eq or + { + currenthalftone/HalftoneType get AGMUTIL_GSTATE_ht/HalftoneType get ne + { + mark AGMUTIL_GSTATE_ht{sethalftone}stopped cleartomark + }if + }{ + AGMUTIL_GSTATE_ht sethalftone + }ifelse + AGMUTIL_GSTATE_flt setflat + end +}def +/get_gstate_and_matrix +{ + AGMUTIL_GSTATE begin + /AGMUTIL_GSTATE_ctm matrix currentmatrix def + end + get_gstate +}def +/set_gstate_and_matrix +{ + set_gstate + AGMUTIL_GSTATE begin + AGMUTIL_GSTATE_ctm setmatrix + end +}def +/AGMUTIL_str256 256 string def +/AGMUTIL_src256 256 string def +/AGMUTIL_dst64 64 string def +/AGMUTIL_srcLen nd +/AGMUTIL_ndx nd +/AGMUTIL_cpd nd +/capture_cpd{ + //Adobe_AGM_Utils/AGMUTIL_cpd currentpagedevice ddf +}def +/thold_halftone +{ + level3 + {sethalftone currenthalftone} + { + dup/HalftoneType get 3 eq + { + sethalftone currenthalftone + }{ + begin + Width Height mul{ + Thresholds read{pop}if + }repeat + end + currenthalftone + }ifelse + }ifelse +}def +/rdcmntline +{ + currentfile AGMUTIL_str256 readline pop + (%)anchorsearch{pop}if +}bdf +/filter_cmyk +{ + dup type/filetype ne{ + exch()/SubFileDecode filter + }{ + exch pop + } + ifelse + [ + exch + { + AGMUTIL_src256 readstring pop + dup length/AGMUTIL_srcLen exch def + /AGMUTIL_ndx 0 def + AGMCORE_plate_ndx 4 AGMUTIL_srcLen 1 sub{ + 1 index exch get + AGMUTIL_dst64 AGMUTIL_ndx 3 -1 roll put + /AGMUTIL_ndx AGMUTIL_ndx 1 add def + }for + pop + AGMUTIL_dst64 0 AGMUTIL_ndx getinterval + } + bind + /exec cvx + ]cvx +}bdf +/filter_indexed_devn +{ + cvi Names length mul names_index add Lookup exch get +}bdf +/filter_devn +{ + 4 dict begin + /srcStr xdf + /dstStr xdf + dup type/filetype ne{ + 0()/SubFileDecode filter + }if + [ + exch + [ + /devicen_colorspace_dict/AGMCORE_gget cvx/begin cvx + currentdict/srcStr get/readstring cvx/pop cvx + /dup cvx/length cvx 0/gt cvx[ + Adobe_AGM_Utils/AGMUTIL_ndx 0/ddf cvx + names_index Names length currentdict/srcStr get length 1 sub{ + 1/index cvx/exch cvx/get cvx + currentdict/dstStr get/AGMUTIL_ndx/load cvx 3 -1/roll cvx/put cvx + Adobe_AGM_Utils/AGMUTIL_ndx/AGMUTIL_ndx/load cvx 1/add cvx/ddf cvx + }for + currentdict/dstStr get 0/AGMUTIL_ndx/load cvx/getinterval cvx + ]cvx/if cvx + /end cvx + ]cvx + bind + /exec cvx + ]cvx + end +}bdf +/AGMUTIL_imagefile nd +/read_image_file +{ + AGMUTIL_imagefile 0 setfileposition + 10 dict begin + /imageDict xdf + /imbufLen Width BitsPerComponent mul 7 add 8 idiv def + /imbufIdx 0 def + /origDataSource imageDict/DataSource get def + /origMultipleDataSources imageDict/MultipleDataSources get def + /origDecode imageDict/Decode get def + /dstDataStr imageDict/Width get colorSpaceElemCnt mul string def + imageDict/MultipleDataSources known{MultipleDataSources}{false}ifelse + { + /imbufCnt imageDict/DataSource get length def + /imbufs imbufCnt array def + 0 1 imbufCnt 1 sub{ + /imbufIdx xdf + imbufs imbufIdx imbufLen string put + imageDict/DataSource get imbufIdx[AGMUTIL_imagefile imbufs imbufIdx get/readstring cvx/pop cvx]cvx put + }for + DeviceN_PS2{ + imageDict begin + /DataSource[DataSource/devn_sep_datasource cvx]cvx def + /MultipleDataSources false def + /Decode[0 1]def + end + }if + }{ + /imbuf imbufLen string def + Indexed_DeviceN level3 not and DeviceN_NoneName or{ + /srcDataStrs[imageDict begin + currentdict/MultipleDataSources known{MultipleDataSources{DataSource length}{1}ifelse}{1}ifelse + { + Width Decode length 2 div mul cvi string + }repeat + end]def + imageDict begin + /DataSource[AGMUTIL_imagefile Decode BitsPerComponent false 1/filter_indexed_devn load dstDataStr srcDataStrs devn_alt_datasource/exec cvx]cvx def + /Decode[0 1]def + end + }{ + imageDict/DataSource[1 string dup 0 AGMUTIL_imagefile Decode length 2 idiv string/readstring cvx/pop cvx names_index/get cvx/put cvx]cvx put + imageDict/Decode[0 1]put + }ifelse + }ifelse + imageDict exch + load exec + imageDict/DataSource origDataSource put + imageDict/MultipleDataSources origMultipleDataSources put + imageDict/Decode origDecode put + end +}bdf +/write_image_file +{ + begin + {(AGMUTIL_imagefile)(w+)file}stopped{ + false + }{ + Adobe_AGM_Utils/AGMUTIL_imagefile xddf + 2 dict begin + /imbufLen Width BitsPerComponent mul 7 add 8 idiv def + MultipleDataSources{DataSource 0 get}{DataSource}ifelse type/filetype eq{ + /imbuf imbufLen string def + }if + 1 1 Height MultipleDataSources not{Decode length 2 idiv mul}if{ + pop + MultipleDataSources{ + 0 1 DataSource length 1 sub{ + DataSource type dup + /arraytype eq{ + pop DataSource exch gx + }{ + /filetype eq{ + DataSource exch get imbuf readstring pop + }{ + DataSource exch get + }ifelse + }ifelse + AGMUTIL_imagefile exch writestring + }for + }{ + DataSource type dup + /arraytype eq{ + pop DataSource exec + }{ + /filetype eq{ + DataSource imbuf readstring pop + }{ + DataSource + }ifelse + }ifelse + AGMUTIL_imagefile exch writestring + }ifelse + }for + end + true + }ifelse + end +}bdf +/close_image_file +{ + AGMUTIL_imagefile closefile(AGMUTIL_imagefile)deletefile +}def +statusdict/product known userdict/AGMP_current_show known not and{ + /pstr statusdict/product get def + pstr(HP LaserJet 2200)eq + pstr(HP LaserJet 4000 Series)eq or + pstr(HP LaserJet 4050 Series )eq or + pstr(HP LaserJet 8000 Series)eq or + pstr(HP LaserJet 8100 Series)eq or + pstr(HP LaserJet 8150 Series)eq or + pstr(HP LaserJet 5000 Series)eq or + pstr(HP LaserJet 5100 Series)eq or + pstr(HP Color LaserJet 4500)eq or + pstr(HP Color LaserJet 4600)eq or + pstr(HP LaserJet 5Si)eq or + pstr(HP LaserJet 1200 Series)eq or + pstr(HP LaserJet 1300 Series)eq or + pstr(HP LaserJet 4100 Series)eq or + { + userdict/AGMP_current_show/show load put + userdict/show{ + currentcolorspace 0 get + /Pattern eq + {false charpath f} + {AGMP_current_show}ifelse + }put + }if + currentdict/pstr undef +}if +/consumeimagedata +{ + begin + AGMIMG_init_common + currentdict/MultipleDataSources known not + {/MultipleDataSources false def}if + MultipleDataSources + { + DataSource 0 get type + dup/filetype eq + { + 1 dict begin + /flushbuffer Width cvi string def + 1 1 Height cvi + { + pop + 0 1 DataSource length 1 sub + { + DataSource exch get + flushbuffer readstring pop pop + }for + }for + end + }if + dup/arraytype eq exch/packedarraytype eq or DataSource 0 get xcheck and + { + Width Height mul cvi + { + 0 1 DataSource length 1 sub + {dup DataSource exch gx length exch 0 ne{pop}if}for + dup 0 eq + {pop exit}if + sub dup 0 le + {exit}if + }loop + pop + }if + } + { + /DataSource load type + dup/filetype eq + { + 1 dict begin + /flushbuffer Width Decode length 2 idiv mul cvi string def + 1 1 Height{pop DataSource flushbuffer readstring pop pop}for + end + }if + dup/arraytype eq exch/packedarraytype eq or/DataSource load xcheck and + { + Height Width BitsPerComponent mul 8 BitsPerComponent sub add 8 idiv Decode length 2 idiv mul mul + { + DataSource length dup 0 eq + {pop exit}if + sub dup 0 le + {exit}if + }loop + pop + }if + }ifelse + end +}bdf +/addprocs +{ + 2{/exec load}repeat + 3 1 roll + [5 1 roll]bind cvx +}def +/modify_halftone_xfer +{ + currenthalftone dup length dict copy begin + currentdict 2 index known{ + 1 index load dup length dict copy begin + currentdict/TransferFunction known{ + /TransferFunction load + }{ + currenttransfer + }ifelse + addprocs/TransferFunction xdf + currentdict end def + currentdict end sethalftone + }{ + currentdict/TransferFunction known{ + /TransferFunction load + }{ + currenttransfer + }ifelse + addprocs/TransferFunction xdf + currentdict end sethalftone + pop + }ifelse +}def +/clonearray +{ + dup xcheck exch + dup length array exch + Adobe_AGM_Core/AGMCORE_tmp -1 ddf + { + Adobe_AGM_Core/AGMCORE_tmp 2 copy get 1 add ddf + dup type/dicttype eq + { + Adobe_AGM_Core/AGMCORE_tmp get + exch + clonedict + Adobe_AGM_Core/AGMCORE_tmp 4 -1 roll ddf + }if + dup type/arraytype eq + { + Adobe_AGM_Core/AGMCORE_tmp get exch + clonearray + Adobe_AGM_Core/AGMCORE_tmp 4 -1 roll ddf + }if + exch dup + Adobe_AGM_Core/AGMCORE_tmp get 4 -1 roll put + }forall + exch{cvx}if +}bdf +/clonedict +{ + dup length dict + begin + { + dup type/dicttype eq + {clonedict}if + dup type/arraytype eq + {clonearray}if + def + }forall + currentdict + end +}bdf +/DeviceN_PS2 +{ + /currentcolorspace AGMCORE_gget 0 get/DeviceN eq level3 not and +}bdf +/Indexed_DeviceN +{ + /indexed_colorspace_dict AGMCORE_gget dup null ne{ + dup/CSDBase known{ + /CSDBase get/CSD get_res/Names known + }{ + pop false + }ifelse + }{ + pop false + }ifelse +}bdf +/DeviceN_NoneName +{ + /Names where{ + pop + false Names + { + (None)eq or + }forall + }{ + false + }ifelse +}bdf +/DeviceN_PS2_inRip_seps +{ + /AGMCORE_in_rip_sep where + { + pop dup type dup/arraytype eq exch/packedarraytype eq or + { + dup 0 get/DeviceN eq level3 not and AGMCORE_in_rip_sep and + { + /currentcolorspace exch AGMCORE_gput + false + }{ + true + }ifelse + }{ + true + }ifelse + }{ + true + }ifelse +}bdf +/base_colorspace_type +{ + dup type/arraytype eq{0 get}if +}bdf +/currentdistillerparams where{pop currentdistillerparams/CoreDistVersion get 5000 lt}{true}ifelse +{ + /pdfmark_5{cleartomark}bind def +}{ + /pdfmark_5{pdfmark}bind def +}ifelse +/ReadBypdfmark_5 +{ + currentfile exch 0 exch/SubFileDecode filter + /currentdistillerparams where + {pop currentdistillerparams/CoreDistVersion get 5000 lt}{true}ifelse + {flushfile cleartomark} + {/PUT pdfmark}ifelse +}bdf +/ReadBypdfmark_5_string +{ + 2 dict begin + /makerString exch def string/tmpString exch def + { + currentfile tmpString readline not{pop exit}if + makerString anchorsearch + { + pop pop cleartomark exit + }{ + 3 copy/PUT pdfmark_5 pop 2 copy(\n)/PUT pdfmark_5 + }ifelse + }loop + end +}bdf +/xpdfm +{ + { + dup 0 get/Label eq + { + aload length[exch 1 add 1 roll/PAGELABEL + }{ + aload pop + [{ThisPage}<<5 -2 roll>>/PUT + }ifelse + pdfmark_5 + }forall +}bdf +/lmt{ + dup 2 index le{exch}if pop dup 2 index ge{exch}if pop +}bdf +/int{ + dup 2 index sub 3 index 5 index sub div 6 -2 roll sub mul exch pop add exch pop +}bdf +/ds{ + Adobe_AGM_Utils begin +}bdf +/dt{ + currentdict Adobe_AGM_Utils eq{ + end + }if +}bdf +systemdict/setpacking known +{setpacking}if +%%EndResource +%%BeginResource: procset Adobe_AGM_Core 2.0 0 +%%Version: 2.0 0 +%%Copyright: Copyright(C)1997-2007 Adobe Systems, Inc. All Rights Reserved. +systemdict/setpacking known +{ + currentpacking + true setpacking +}if +userdict/Adobe_AGM_Core 209 dict dup begin put +/Adobe_AGM_Core_Id/Adobe_AGM_Core_2.0_0 def +/AGMCORE_str256 256 string def +/AGMCORE_save nd +/AGMCORE_graphicsave nd +/AGMCORE_c 0 def +/AGMCORE_m 0 def +/AGMCORE_y 0 def +/AGMCORE_k 0 def +/AGMCORE_cmykbuf 4 array def +/AGMCORE_screen[currentscreen]cvx def +/AGMCORE_tmp 0 def +/AGMCORE_&setgray nd +/AGMCORE_&setcolor nd +/AGMCORE_&setcolorspace nd +/AGMCORE_&setcmykcolor nd +/AGMCORE_cyan_plate nd +/AGMCORE_magenta_plate nd +/AGMCORE_yellow_plate nd +/AGMCORE_black_plate nd +/AGMCORE_plate_ndx nd +/AGMCORE_get_ink_data nd +/AGMCORE_is_cmyk_sep nd +/AGMCORE_host_sep nd +/AGMCORE_avoid_L2_sep_space nd +/AGMCORE_distilling nd +/AGMCORE_composite_job nd +/AGMCORE_producing_seps nd +/AGMCORE_ps_level -1 def +/AGMCORE_ps_version -1 def +/AGMCORE_environ_ok nd +/AGMCORE_CSD_cache 0 dict def +/AGMCORE_currentoverprint false def +/AGMCORE_deltaX nd +/AGMCORE_deltaY nd +/AGMCORE_name nd +/AGMCORE_sep_special nd +/AGMCORE_err_strings 4 dict def +/AGMCORE_cur_err nd +/AGMCORE_current_spot_alias false def +/AGMCORE_inverting false def +/AGMCORE_feature_dictCount nd +/AGMCORE_feature_opCount nd +/AGMCORE_feature_ctm nd +/AGMCORE_ConvertToProcess false def +/AGMCORE_Default_CTM matrix def +/AGMCORE_Default_PageSize nd +/AGMCORE_Default_flatness nd +/AGMCORE_currentbg nd +/AGMCORE_currentucr nd +/AGMCORE_pattern_paint_type 0 def +/knockout_unitsq nd +currentglobal true setglobal +[/CSA/Gradient/Procedure] +{ + /Generic/Category findresource dup length dict copy/Category defineresource pop +}forall +setglobal +/AGMCORE_key_known +{ + where{ + /Adobe_AGM_Core_Id known + }{ + false + }ifelse +}ndf +/flushinput +{ + save + 2 dict begin + /CompareBuffer 3 -1 roll def + /readbuffer 256 string def + mark + { + currentfile readbuffer{readline}stopped + {cleartomark mark} + { + not + {pop exit} + if + CompareBuffer eq + {exit} + if + }ifelse + }loop + cleartomark + end + restore +}bdf +/getspotfunction +{ + AGMCORE_screen exch pop exch pop + dup type/dicttype eq{ + dup/HalftoneType get 1 eq{ + /SpotFunction get + }{ + dup/HalftoneType get 2 eq{ + /GraySpotFunction get + }{ + pop + { + abs exch abs 2 copy add 1 gt{ + 1 sub dup mul exch 1 sub dup mul add 1 sub + }{ + dup mul exch dup mul add 1 exch sub + }ifelse + }bind + }ifelse + }ifelse + }if +}def +/np +{newpath}bdf +/clp_npth +{clip np}def +/eoclp_npth +{eoclip np}def +/npth_clp +{np clip}def +/graphic_setup +{ + /AGMCORE_graphicsave save store + concat + 0 setgray + 0 setlinecap + 0 setlinejoin + 1 setlinewidth + []0 setdash + 10 setmiterlimit + np + false setoverprint + false setstrokeadjust + //Adobe_AGM_Core/spot_alias gx + /Adobe_AGM_Image where{ + pop + Adobe_AGM_Image/spot_alias 2 copy known{ + gx + }{ + pop pop + }ifelse + }if + /sep_colorspace_dict null AGMCORE_gput + 100 dict begin + /dictstackcount countdictstack def + /showpage{}def + mark +}def +/graphic_cleanup +{ + cleartomark + dictstackcount 1 countdictstack 1 sub{end}for + end + AGMCORE_graphicsave restore +}def +/compose_error_msg +{ + grestoreall initgraphics + /Helvetica findfont 10 scalefont setfont + /AGMCORE_deltaY 100 def + /AGMCORE_deltaX 310 def + clippath pathbbox np pop pop 36 add exch 36 add exch moveto + 0 AGMCORE_deltaY rlineto AGMCORE_deltaX 0 rlineto + 0 AGMCORE_deltaY neg rlineto AGMCORE_deltaX neg 0 rlineto closepath + 0 AGMCORE_&setgray + gsave 1 AGMCORE_&setgray fill grestore + 1 setlinewidth gsave stroke grestore + currentpoint AGMCORE_deltaY 15 sub add exch 8 add exch moveto + /AGMCORE_deltaY 12 def + /AGMCORE_tmp 0 def + AGMCORE_err_strings exch get + { + dup 32 eq + { + pop + AGMCORE_str256 0 AGMCORE_tmp getinterval + stringwidth pop currentpoint pop add AGMCORE_deltaX 28 add gt + { + currentpoint AGMCORE_deltaY sub exch pop + clippath pathbbox pop pop pop 44 add exch moveto + }if + AGMCORE_str256 0 AGMCORE_tmp getinterval show( )show + 0 1 AGMCORE_str256 length 1 sub + { + AGMCORE_str256 exch 0 put + }for + /AGMCORE_tmp 0 def + }{ + AGMCORE_str256 exch AGMCORE_tmp xpt + /AGMCORE_tmp AGMCORE_tmp 1 add def + }ifelse + }forall +}bdf +/AGMCORE_CMYKDeviceNColorspaces[ + [/Separation/None/DeviceCMYK{0 0 0}] + [/Separation(Black)/DeviceCMYK{0 0 0 4 -1 roll}bind] + [/Separation(Yellow)/DeviceCMYK{0 0 3 -1 roll 0}bind] + [/DeviceN[(Yellow)(Black)]/DeviceCMYK{0 0 4 2 roll}bind] + [/Separation(Magenta)/DeviceCMYK{0 exch 0 0}bind] + [/DeviceN[(Magenta)(Black)]/DeviceCMYK{0 3 1 roll 0 exch}bind] + [/DeviceN[(Magenta)(Yellow)]/DeviceCMYK{0 3 1 roll 0}bind] + [/DeviceN[(Magenta)(Yellow)(Black)]/DeviceCMYK{0 4 1 roll}bind] + [/Separation(Cyan)/DeviceCMYK{0 0 0}] + [/DeviceN[(Cyan)(Black)]/DeviceCMYK{0 0 3 -1 roll}bind] + [/DeviceN[(Cyan)(Yellow)]/DeviceCMYK{0 exch 0}bind] + [/DeviceN[(Cyan)(Yellow)(Black)]/DeviceCMYK{0 3 1 roll}bind] + [/DeviceN[(Cyan)(Magenta)]/DeviceCMYK{0 0}] + [/DeviceN[(Cyan)(Magenta)(Black)]/DeviceCMYK{0 exch}bind] + [/DeviceN[(Cyan)(Magenta)(Yellow)]/DeviceCMYK{0}] + [/DeviceCMYK] +]def +/ds{ + Adobe_AGM_Core begin + /currentdistillerparams where + { + pop currentdistillerparams/CoreDistVersion get 5000 lt + {<>setdistillerparams}if + }if + /AGMCORE_ps_version xdf + /AGMCORE_ps_level xdf + errordict/AGM_handleerror known not{ + errordict/AGM_handleerror errordict/handleerror get put + errordict/handleerror{ + Adobe_AGM_Core begin + $error/newerror get AGMCORE_cur_err null ne and{ + $error/newerror false put + AGMCORE_cur_err compose_error_msg + }if + $error/newerror true put + end + errordict/AGM_handleerror get exec + }bind put + }if + /AGMCORE_environ_ok + ps_level AGMCORE_ps_level ge + ps_version AGMCORE_ps_version ge and + AGMCORE_ps_level -1 eq or + def + AGMCORE_environ_ok not + {/AGMCORE_cur_err/AGMCORE_bad_environ def}if + /AGMCORE_&setgray systemdict/setgray get def + level2{ + /AGMCORE_&setcolor systemdict/setcolor get def + /AGMCORE_&setcolorspace systemdict/setcolorspace get def + }if + /AGMCORE_currentbg currentblackgeneration def + /AGMCORE_currentucr currentundercolorremoval def + /AGMCORE_Default_flatness currentflat def + /AGMCORE_distilling + /product where{ + pop systemdict/setdistillerparams known product(Adobe PostScript Parser)ne and + }{ + false + }ifelse + def + /AGMCORE_GSTATE AGMCORE_key_known not{ + /AGMCORE_GSTATE 21 dict def + /AGMCORE_tmpmatrix matrix def + /AGMCORE_gstack 32 array def + /AGMCORE_gstackptr 0 def + /AGMCORE_gstacksaveptr 0 def + /AGMCORE_gstackframekeys 14 def + /AGMCORE_&gsave/gsave ldf + /AGMCORE_&grestore/grestore ldf + /AGMCORE_&grestoreall/grestoreall ldf + /AGMCORE_&save/save ldf + /AGMCORE_&setoverprint/setoverprint ldf + /AGMCORE_gdictcopy{ + begin + {def}forall + end + }def + /AGMCORE_gput{ + AGMCORE_gstack AGMCORE_gstackptr get + 3 1 roll + put + }def + /AGMCORE_gget{ + AGMCORE_gstack AGMCORE_gstackptr get + exch + get + }def + /gsave{ + AGMCORE_&gsave + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gstackptr 1 add + dup 32 ge{limitcheck}if + /AGMCORE_gstackptr exch store + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gdictcopy + }def + /grestore{ + AGMCORE_&grestore + AGMCORE_gstackptr 1 sub + dup AGMCORE_gstacksaveptr lt{1 add}if + dup AGMCORE_gstack exch get dup/AGMCORE_currentoverprint known + {/AGMCORE_currentoverprint get setoverprint}{pop}ifelse + /AGMCORE_gstackptr exch store + }def + /grestoreall{ + AGMCORE_&grestoreall + /AGMCORE_gstackptr AGMCORE_gstacksaveptr store + }def + /save{ + AGMCORE_&save + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gstackptr 1 add + dup 32 ge{limitcheck}if + /AGMCORE_gstackptr exch store + /AGMCORE_gstacksaveptr AGMCORE_gstackptr store + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gdictcopy + }def + /setoverprint{ + dup/AGMCORE_currentoverprint exch AGMCORE_gput AGMCORE_&setoverprint + }def + 0 1 AGMCORE_gstack length 1 sub{ + AGMCORE_gstack exch AGMCORE_gstackframekeys dict put + }for + }if + level3/AGMCORE_&sysshfill AGMCORE_key_known not and + { + /AGMCORE_&sysshfill systemdict/shfill get def + /AGMCORE_&sysmakepattern systemdict/makepattern get def + /AGMCORE_&usrmakepattern/makepattern load def + }if + /currentcmykcolor[0 0 0 0]AGMCORE_gput + /currentstrokeadjust false AGMCORE_gput + /currentcolorspace[/DeviceGray]AGMCORE_gput + /sep_tint 0 AGMCORE_gput + /devicen_tints[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]AGMCORE_gput + /sep_colorspace_dict null AGMCORE_gput + /devicen_colorspace_dict null AGMCORE_gput + /indexed_colorspace_dict null AGMCORE_gput + /currentcolor_intent()AGMCORE_gput + /customcolor_tint 1 AGMCORE_gput + /absolute_colorimetric_crd null AGMCORE_gput + /relative_colorimetric_crd null AGMCORE_gput + /saturation_crd null AGMCORE_gput + /perceptual_crd null AGMCORE_gput + currentcolortransfer cvlit/AGMCore_gray_xfer xdf cvlit/AGMCore_b_xfer xdf + cvlit/AGMCore_g_xfer xdf cvlit/AGMCore_r_xfer xdf + << + /MaxPatternItem currentsystemparams/MaxPatternCache get + >> + setuserparams + end +}def +/ps +{ + /setcmykcolor where{ + pop + Adobe_AGM_Core/AGMCORE_&setcmykcolor/setcmykcolor load put + }if + Adobe_AGM_Core begin + /setcmykcolor + { + 4 copy AGMCORE_cmykbuf astore/currentcmykcolor exch AGMCORE_gput + 1 sub 4 1 roll + 3{ + 3 index add neg dup 0 lt{ + pop 0 + }if + 3 1 roll + }repeat + setrgbcolor pop + }ndf + /currentcmykcolor + { + /currentcmykcolor AGMCORE_gget aload pop + }ndf + /setoverprint + {pop}ndf + /currentoverprint + {false}ndf + /AGMCORE_cyan_plate 1 0 0 0 test_cmyk_color_plate def + /AGMCORE_magenta_plate 0 1 0 0 test_cmyk_color_plate def + /AGMCORE_yellow_plate 0 0 1 0 test_cmyk_color_plate def + /AGMCORE_black_plate 0 0 0 1 test_cmyk_color_plate def + /AGMCORE_plate_ndx + AGMCORE_cyan_plate{ + 0 + }{ + AGMCORE_magenta_plate{ + 1 + }{ + AGMCORE_yellow_plate{ + 2 + }{ + AGMCORE_black_plate{ + 3 + }{ + 4 + }ifelse + }ifelse + }ifelse + }ifelse + def + /AGMCORE_have_reported_unsupported_color_space false def + /AGMCORE_report_unsupported_color_space + { + AGMCORE_have_reported_unsupported_color_space false eq + { + (Warning: Job contains content that cannot be separated with on-host methods. This content appears on the black plate, and knocks out all other plates.)== + Adobe_AGM_Core/AGMCORE_have_reported_unsupported_color_space true ddf + }if + }def + /AGMCORE_composite_job + AGMCORE_cyan_plate AGMCORE_magenta_plate and AGMCORE_yellow_plate and AGMCORE_black_plate and def + /AGMCORE_in_rip_sep + /AGMCORE_in_rip_sep where{ + pop AGMCORE_in_rip_sep + }{ + AGMCORE_distilling + { + false + }{ + userdict/Adobe_AGM_OnHost_Seps known{ + false + }{ + level2{ + currentpagedevice/Separations 2 copy known{ + get + }{ + pop pop false + }ifelse + }{ + false + }ifelse + }ifelse + }ifelse + }ifelse + def + /AGMCORE_producing_seps AGMCORE_composite_job not AGMCORE_in_rip_sep or def + /AGMCORE_host_sep AGMCORE_producing_seps AGMCORE_in_rip_sep not and def + /AGM_preserve_spots + /AGM_preserve_spots where{ + pop AGM_preserve_spots + }{ + AGMCORE_distilling AGMCORE_producing_seps or + }ifelse + def + /AGM_is_distiller_preserving_spotimages + { + currentdistillerparams/PreserveOverprintSettings known + { + currentdistillerparams/PreserveOverprintSettings get + { + currentdistillerparams/ColorConversionStrategy known + { + currentdistillerparams/ColorConversionStrategy get + /sRGB ne + }{ + true + }ifelse + }{ + false + }ifelse + }{ + false + }ifelse + }def + /convert_spot_to_process where{pop}{ + /convert_spot_to_process + { + //Adobe_AGM_Core begin + dup map_alias{ + /Name get exch pop + }if + dup dup(None)eq exch(All)eq or + { + pop false + }{ + AGMCORE_host_sep + { + gsave + 1 0 0 0 setcmykcolor currentgray 1 exch sub + 0 1 0 0 setcmykcolor currentgray 1 exch sub + 0 0 1 0 setcmykcolor currentgray 1 exch sub + 0 0 0 1 setcmykcolor currentgray 1 exch sub + add add add 0 eq + { + pop false + }{ + false setoverprint + current_spot_alias false set_spot_alias + 1 1 1 1 6 -1 roll findcmykcustomcolor 1 setcustomcolor + set_spot_alias + currentgray 1 ne + }ifelse + grestore + }{ + AGMCORE_distilling + { + pop AGM_is_distiller_preserving_spotimages not + }{ + //Adobe_AGM_Core/AGMCORE_name xddf + false + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 0 eq + AGMUTIL_cpd/OverrideSeparations known and + { + AGMUTIL_cpd/OverrideSeparations get + { + /HqnSpots/ProcSet resourcestatus + { + pop pop pop true + }if + }if + }if + { + AGMCORE_name/HqnSpots/ProcSet findresource/TestSpot gx not + }{ + gsave + [/Separation AGMCORE_name/DeviceGray{}]AGMCORE_&setcolorspace + false + AGMUTIL_cpd/SeparationColorNames 2 copy known + { + get + {AGMCORE_name eq or}forall + not + }{ + pop pop pop true + }ifelse + grestore + }ifelse + }ifelse + }ifelse + }ifelse + end + }def + }ifelse + /convert_to_process where{pop}{ + /convert_to_process + { + dup length 0 eq + { + pop false + }{ + AGMCORE_host_sep + { + dup true exch + { + dup(Cyan)eq exch + dup(Magenta)eq 3 -1 roll or exch + dup(Yellow)eq 3 -1 roll or exch + dup(Black)eq 3 -1 roll or + {pop} + {convert_spot_to_process and}ifelse + } + forall + { + true exch + { + dup(Cyan)eq exch + dup(Magenta)eq 3 -1 roll or exch + dup(Yellow)eq 3 -1 roll or exch + (Black)eq or and + }forall + not + }{pop false}ifelse + }{ + false exch + { + /PhotoshopDuotoneList where{pop false}{true}ifelse + { + dup(Cyan)eq exch + dup(Magenta)eq 3 -1 roll or exch + dup(Yellow)eq 3 -1 roll or exch + dup(Black)eq 3 -1 roll or + {pop} + {convert_spot_to_process or}ifelse + } + { + convert_spot_to_process or + } + ifelse + } + forall + }ifelse + }ifelse + }def + }ifelse + /AGMCORE_avoid_L2_sep_space + version cvr 2012 lt + level2 and + AGMCORE_producing_seps not and + def + /AGMCORE_is_cmyk_sep + AGMCORE_cyan_plate AGMCORE_magenta_plate or AGMCORE_yellow_plate or AGMCORE_black_plate or + def + /AGM_avoid_0_cmyk where{ + pop AGM_avoid_0_cmyk + }{ + AGM_preserve_spots + userdict/Adobe_AGM_OnHost_Seps known + userdict/Adobe_AGM_InRip_Seps known or + not and + }ifelse + { + /setcmykcolor[ + { + 4 copy add add add 0 eq currentoverprint and{ + pop 0.0005 + }if + }/exec cvx + /AGMCORE_&setcmykcolor load dup type/operatortype ne{ + /exec cvx + }if + ]cvx def + }if + /AGMCORE_IsSeparationAProcessColor + { + dup(Cyan)eq exch dup(Magenta)eq exch dup(Yellow)eq exch(Black)eq or or or + }def + AGMCORE_host_sep{ + /setcolortransfer + { + AGMCORE_cyan_plate{ + pop pop pop + }{ + AGMCORE_magenta_plate{ + 4 3 roll pop pop pop + }{ + AGMCORE_yellow_plate{ + 4 2 roll pop pop pop + }{ + 4 1 roll pop pop pop + }ifelse + }ifelse + }ifelse + settransfer + } + def + /AGMCORE_get_ink_data + AGMCORE_cyan_plate{ + {pop pop pop} + }{ + AGMCORE_magenta_plate{ + {4 3 roll pop pop pop} + }{ + AGMCORE_yellow_plate{ + {4 2 roll pop pop pop} + }{ + {4 1 roll pop pop pop} + }ifelse + }ifelse + }ifelse + def + /AGMCORE_RemoveProcessColorNames + { + 1 dict begin + /filtername + { + dup/Cyan eq 1 index(Cyan)eq or + {pop(_cyan_)}if + dup/Magenta eq 1 index(Magenta)eq or + {pop(_magenta_)}if + dup/Yellow eq 1 index(Yellow)eq or + {pop(_yellow_)}if + dup/Black eq 1 index(Black)eq or + {pop(_black_)}if + }def + dup type/arraytype eq + {[exch{filtername}forall]} + {filtername}ifelse + end + }def + level3{ + /AGMCORE_IsCurrentColor + { + dup AGMCORE_IsSeparationAProcessColor + { + AGMCORE_plate_ndx 0 eq + {dup(Cyan)eq exch/Cyan eq or}if + AGMCORE_plate_ndx 1 eq + {dup(Magenta)eq exch/Magenta eq or}if + AGMCORE_plate_ndx 2 eq + {dup(Yellow)eq exch/Yellow eq or}if + AGMCORE_plate_ndx 3 eq + {dup(Black)eq exch/Black eq or}if + AGMCORE_plate_ndx 4 eq + {pop false}if + }{ + gsave + false setoverprint + current_spot_alias false set_spot_alias + 1 1 1 1 6 -1 roll findcmykcustomcolor 1 setcustomcolor + set_spot_alias + currentgray 1 ne + grestore + }ifelse + }def + /AGMCORE_filter_functiondatasource + { + 5 dict begin + /data_in xdf + data_in type/stringtype eq + { + /ncomp xdf + /comp xdf + /string_out data_in length ncomp idiv string def + 0 ncomp data_in length 1 sub + { + string_out exch dup ncomp idiv exch data_in exch ncomp getinterval comp get 255 exch sub put + }for + string_out + }{ + string/string_in xdf + /string_out 1 string def + /component xdf + [ + data_in string_in/readstring cvx + [component/get cvx 255/exch cvx/sub cvx string_out/exch cvx 0/exch cvx/put cvx string_out]cvx + [/pop cvx()]cvx/ifelse cvx + ]cvx/ReusableStreamDecode filter + }ifelse + end + }def + /AGMCORE_separateShadingFunction + { + 2 dict begin + /paint? xdf + /channel xdf + dup type/dicttype eq + { + begin + FunctionType 0 eq + { + /DataSource channel Range length 2 idiv DataSource AGMCORE_filter_functiondatasource def + currentdict/Decode known + {/Decode Decode channel 2 mul 2 getinterval def}if + paint? not + {/Decode[1 1]def}if + }if + FunctionType 2 eq + { + paint? + { + /C0[C0 channel get 1 exch sub]def + /C1[C1 channel get 1 exch sub]def + }{ + /C0[1]def + /C1[1]def + }ifelse + }if + FunctionType 3 eq + { + /Functions[Functions{channel paint? AGMCORE_separateShadingFunction}forall]def + }if + currentdict/Range known + {/Range[0 1]def}if + currentdict + end}{ + channel get 0 paint? AGMCORE_separateShadingFunction + }ifelse + end + }def + /AGMCORE_separateShading + { + 3 -1 roll begin + currentdict/Function known + { + currentdict/Background known + {[1 index{Background 3 index get 1 exch sub}{1}ifelse]/Background xdf}if + Function 3 1 roll AGMCORE_separateShadingFunction/Function xdf + /ColorSpace[/DeviceGray]def + }{ + ColorSpace dup type/arraytype eq{0 get}if/DeviceCMYK eq + { + /ColorSpace[/DeviceN[/_cyan_/_magenta_/_yellow_/_black_]/DeviceCMYK{}]def + }{ + ColorSpace dup 1 get AGMCORE_RemoveProcessColorNames 1 exch put + }ifelse + ColorSpace 0 get/Separation eq + { + { + [1/exch cvx/sub cvx]cvx + }{ + [/pop cvx 1]cvx + }ifelse + ColorSpace 3 3 -1 roll put + pop + }{ + { + [exch ColorSpace 1 get length 1 sub exch sub/index cvx 1/exch cvx/sub cvx ColorSpace 1 get length 1 add 1/roll cvx ColorSpace 1 get length{/pop cvx}repeat]cvx + }{ + pop[ColorSpace 1 get length{/pop cvx}repeat cvx 1]cvx + }ifelse + ColorSpace 3 3 -1 roll bind put + }ifelse + ColorSpace 2/DeviceGray put + }ifelse + end + }def + /AGMCORE_separateShadingDict + { + dup/ColorSpace get + dup type/arraytype ne + {[exch]}if + dup 0 get/DeviceCMYK eq + { + exch begin + currentdict + AGMCORE_cyan_plate + {0 true}if + AGMCORE_magenta_plate + {1 true}if + AGMCORE_yellow_plate + {2 true}if + AGMCORE_black_plate + {3 true}if + AGMCORE_plate_ndx 4 eq + {0 false}if + dup not currentoverprint and + {/AGMCORE_ignoreshade true def}if + AGMCORE_separateShading + currentdict + end exch + }if + dup 0 get/Separation eq + { + exch begin + ColorSpace 1 get dup/None ne exch/All ne and + { + ColorSpace 1 get AGMCORE_IsCurrentColor AGMCORE_plate_ndx 4 lt and ColorSpace 1 get AGMCORE_IsSeparationAProcessColor not and + { + ColorSpace 2 get dup type/arraytype eq{0 get}if/DeviceCMYK eq + { + /ColorSpace + [ + /Separation + ColorSpace 1 get + /DeviceGray + [ + ColorSpace 3 get/exec cvx + 4 AGMCORE_plate_ndx sub -1/roll cvx + 4 1/roll cvx + 3[/pop cvx]cvx/repeat cvx + 1/exch cvx/sub cvx + ]cvx + ]def + }{ + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate not + { + currentdict 0 false AGMCORE_separateShading + }if + }ifelse + }{ + currentdict ColorSpace 1 get AGMCORE_IsCurrentColor + 0 exch + dup not currentoverprint and + {/AGMCORE_ignoreshade true def}if + AGMCORE_separateShading + }ifelse + }if + currentdict + end exch + }if + dup 0 get/DeviceN eq + { + exch begin + ColorSpace 1 get convert_to_process + { + ColorSpace 2 get dup type/arraytype eq{0 get}if/DeviceCMYK eq + { + /ColorSpace + [ + /DeviceN + ColorSpace 1 get + /DeviceGray + [ + ColorSpace 3 get/exec cvx + 4 AGMCORE_plate_ndx sub -1/roll cvx + 4 1/roll cvx + 3[/pop cvx]cvx/repeat cvx + 1/exch cvx/sub cvx + ]cvx + ]def + }{ + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate not + { + currentdict 0 false AGMCORE_separateShading + /ColorSpace[/DeviceGray]def + }if + }ifelse + }{ + currentdict + false -1 ColorSpace 1 get + { + AGMCORE_IsCurrentColor + { + 1 add + exch pop true exch exit + }if + 1 add + }forall + exch + dup not currentoverprint and + {/AGMCORE_ignoreshade true def}if + AGMCORE_separateShading + }ifelse + currentdict + end exch + }if + dup 0 get dup/DeviceCMYK eq exch dup/Separation eq exch/DeviceN eq or or not + { + exch begin + ColorSpace dup type/arraytype eq + {0 get}if + /DeviceGray ne + { + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate not + { + ColorSpace 0 get/CIEBasedA eq + { + /ColorSpace[/Separation/_ciebaseda_/DeviceGray{}]def + }if + ColorSpace 0 get dup/CIEBasedABC eq exch dup/CIEBasedDEF eq exch/DeviceRGB eq or or + { + /ColorSpace[/DeviceN[/_red_/_green_/_blue_]/DeviceRGB{}]def + }if + ColorSpace 0 get/CIEBasedDEFG eq + { + /ColorSpace[/DeviceN[/_cyan_/_magenta_/_yellow_/_black_]/DeviceCMYK{}]def + }if + currentdict 0 false AGMCORE_separateShading + }if + }if + currentdict + end exch + }if + pop + dup/AGMCORE_ignoreshade known + { + begin + /ColorSpace[/Separation(None)/DeviceGray{}]def + currentdict end + }if + }def + /shfill + { + AGMCORE_separateShadingDict + dup/AGMCORE_ignoreshade known + {pop} + {AGMCORE_&sysshfill}ifelse + }def + /makepattern + { + exch + dup/PatternType get 2 eq + { + clonedict + begin + /Shading Shading AGMCORE_separateShadingDict def + Shading/AGMCORE_ignoreshade known + currentdict end exch + {pop<>}if + exch AGMCORE_&sysmakepattern + }{ + exch AGMCORE_&usrmakepattern + }ifelse + }def + }if + }if + AGMCORE_in_rip_sep{ + /setcustomcolor + { + exch aload pop + dup 7 1 roll inRip_spot_has_ink not { + 4{4 index mul 4 1 roll} + repeat + /DeviceCMYK setcolorspace + 6 -2 roll pop pop + }{ + //Adobe_AGM_Core begin + /AGMCORE_k xdf/AGMCORE_y xdf/AGMCORE_m xdf/AGMCORE_c xdf + end + [/Separation 4 -1 roll/DeviceCMYK + {dup AGMCORE_c mul exch dup AGMCORE_m mul exch dup AGMCORE_y mul exch AGMCORE_k mul} + ] + setcolorspace + }ifelse + setcolor + }ndf + /setseparationgray + { + [/Separation(All)/DeviceGray{}]setcolorspace_opt + 1 exch sub setcolor + }ndf + }{ + /setseparationgray + { + AGMCORE_&setgray + }ndf + }ifelse + /findcmykcustomcolor + { + 5 makereadonlyarray + }ndf + /setcustomcolor + { + exch aload pop pop + 4{4 index mul 4 1 roll}repeat + setcmykcolor pop + }ndf + /has_color + /colorimage where{ + AGMCORE_producing_seps{ + pop true + }{ + systemdict eq + }ifelse + }{ + false + }ifelse + def + /map_index + { + 1 index mul exch getinterval{255 div}forall + }bdf + /map_indexed_devn + { + Lookup Names length 3 -1 roll cvi map_index + }bdf + /n_color_components + { + base_colorspace_type + dup/DeviceGray eq{ + pop 1 + }{ + /DeviceCMYK eq{ + 4 + }{ + 3 + }ifelse + }ifelse + }bdf + level2{ + /mo/moveto ldf + /li/lineto ldf + /cv/curveto ldf + /knockout_unitsq + { + 1 setgray + 0 0 1 1 rectfill + }def + level2/setcolorspace AGMCORE_key_known not and{ + /AGMCORE_&&&setcolorspace/setcolorspace ldf + /AGMCORE_ReplaceMappedColor + { + dup type dup/arraytype eq exch/packedarraytype eq or + { + /AGMCORE_SpotAliasAry2 where{ + begin + dup 0 get dup/Separation eq + { + pop + dup length array copy + dup dup 1 get + current_spot_alias + { + dup map_alias + { + false set_spot_alias + dup 1 exch setsepcolorspace + true set_spot_alias + begin + /sep_colorspace_dict currentdict AGMCORE_gput + pop pop pop + [ + /Separation Name + CSA map_csa + MappedCSA + /sep_colorspace_proc load + ] + dup Name + end + }if + }if + map_reserved_ink_name 1 xpt + }{ + /DeviceN eq + { + dup length array copy + dup dup 1 get[ + exch{ + current_spot_alias{ + dup map_alias{ + /Name get exch pop + }if + }if + map_reserved_ink_name + }forall + ]1 xpt + }if + }ifelse + end + }if + }if + }def + /setcolorspace + { + dup type dup/arraytype eq exch/packedarraytype eq or + { + dup 0 get/Indexed eq + { + AGMCORE_distilling + { + /PhotoshopDuotoneList where + { + pop false + }{ + true + }ifelse + }{ + true + }ifelse + { + aload pop 3 -1 roll + AGMCORE_ReplaceMappedColor + 3 1 roll 4 array astore + }if + }{ + AGMCORE_ReplaceMappedColor + }ifelse + }if + DeviceN_PS2_inRip_seps{AGMCORE_&&&setcolorspace}if + }def + }if + }{ + /adj + { + currentstrokeadjust{ + transform + 0.25 sub round 0.25 add exch + 0.25 sub round 0.25 add exch + itransform + }if + }def + /mo{ + adj moveto + }def + /li{ + adj lineto + }def + /cv{ + 6 2 roll adj + 6 2 roll adj + 6 2 roll adj curveto + }def + /knockout_unitsq + { + 1 setgray + 8 8 1[8 0 0 8 0 0]{}image + }def + /currentstrokeadjust{ + /currentstrokeadjust AGMCORE_gget + }def + /setstrokeadjust{ + /currentstrokeadjust exch AGMCORE_gput + }def + /setcolorspace + { + /currentcolorspace exch AGMCORE_gput + }def + /currentcolorspace + { + /currentcolorspace AGMCORE_gget + }def + /setcolor_devicecolor + { + base_colorspace_type + dup/DeviceGray eq{ + pop setgray + }{ + /DeviceCMYK eq{ + setcmykcolor + }{ + setrgbcolor + }ifelse + }ifelse + }def + /setcolor + { + currentcolorspace 0 get + dup/DeviceGray ne{ + dup/DeviceCMYK ne{ + dup/DeviceRGB ne{ + dup/Separation eq{ + pop + currentcolorspace 3 gx + currentcolorspace 2 get + }{ + dup/Indexed eq{ + pop + currentcolorspace 3 get dup type/stringtype eq{ + currentcolorspace 1 get n_color_components + 3 -1 roll map_index + }{ + exec + }ifelse + currentcolorspace 1 get + }{ + /AGMCORE_cur_err/AGMCORE_invalid_color_space def + AGMCORE_invalid_color_space + }ifelse + }ifelse + }if + }if + }if + setcolor_devicecolor + }def + }ifelse + /sop/setoverprint ldf + /lw/setlinewidth ldf + /lc/setlinecap ldf + /lj/setlinejoin ldf + /ml/setmiterlimit ldf + /dsh/setdash ldf + /sadj/setstrokeadjust ldf + /gry/setgray ldf + /rgb/setrgbcolor ldf + /cmyk[ + /currentcolorspace[/DeviceCMYK]/AGMCORE_gput cvx + /setcmykcolor load dup type/operatortype ne{/exec cvx}if + ]cvx bdf + level3 AGMCORE_host_sep not and{ + /nzopmsc{ + 6 dict begin + /kk exch def + /yy exch def + /mm exch def + /cc exch def + /sum 0 def + cc 0 ne{/sum sum 2#1000 or def cc}if + mm 0 ne{/sum sum 2#0100 or def mm}if + yy 0 ne{/sum sum 2#0010 or def yy}if + kk 0 ne{/sum sum 2#0001 or def kk}if + AGMCORE_CMYKDeviceNColorspaces sum get setcolorspace + sum 0 eq{0}if + end + setcolor + }bdf + }{ + /nzopmsc/cmyk ldf + }ifelse + /sep/setsepcolor ldf + /devn/setdevicencolor ldf + /idx/setindexedcolor ldf + /colr/setcolor ldf + /csacrd/set_csa_crd ldf + /sepcs/setsepcolorspace ldf + /devncs/setdevicencolorspace ldf + /idxcs/setindexedcolorspace ldf + /cp/closepath ldf + /clp/clp_npth ldf + /eclp/eoclp_npth ldf + /f/fill ldf + /ef/eofill ldf + /@/stroke ldf + /nclp/npth_clp ldf + /gset/graphic_setup ldf + /gcln/graphic_cleanup ldf + /ct/concat ldf + /cf/currentfile ldf + /fl/filter ldf + /rs/readstring ldf + /AGMCORE_def_ht currenthalftone def + /clonedict Adobe_AGM_Utils begin/clonedict load end def + /clonearray Adobe_AGM_Utils begin/clonearray load end def + currentdict{ + dup xcheck 1 index type dup/arraytype eq exch/packedarraytype eq or and{ + bind + }if + def + }forall + /getrampcolor + { + /indx exch def + 0 1 NumComp 1 sub + { + dup + Samples exch get + dup type/stringtype eq{indx get}if + exch + Scaling exch get aload pop + 3 1 roll + mul add + }for + ColorSpaceFamily/Separation eq + {sep} + { + ColorSpaceFamily/DeviceN eq + {devn}{setcolor}ifelse + }ifelse + }bdf + /sssetbackground{ + aload pop + ColorSpaceFamily/Separation eq + {sep} + { + ColorSpaceFamily/DeviceN eq + {devn}{setcolor}ifelse + }ifelse + }bdf + /RadialShade + { + 40 dict begin + /ColorSpaceFamily xdf + /background xdf + /ext1 xdf + /ext0 xdf + /BBox xdf + /r2 xdf + /c2y xdf + /c2x xdf + /r1 xdf + /c1y xdf + /c1x xdf + /rampdict xdf + /setinkoverprint where{pop/setinkoverprint{pop}def}if + gsave + BBox length 0 gt + { + np + BBox 0 get BBox 1 get moveto + BBox 2 get BBox 0 get sub 0 rlineto + 0 BBox 3 get BBox 1 get sub rlineto + BBox 2 get BBox 0 get sub neg 0 rlineto + closepath + clip + np + }if + c1x c2x eq + { + c1y c2y lt{/theta 90 def}{/theta 270 def}ifelse + }{ + /slope c2y c1y sub c2x c1x sub div def + /theta slope 1 atan def + c2x c1x lt c2y c1y ge and{/theta theta 180 sub def}if + c2x c1x lt c2y c1y lt and{/theta theta 180 add def}if + }ifelse + gsave + clippath + c1x c1y translate + theta rotate + -90 rotate + {pathbbox}stopped + {0 0 0 0}if + /yMax xdf + /xMax xdf + /yMin xdf + /xMin xdf + grestore + xMax xMin eq yMax yMin eq or + { + grestore + end + }{ + /max{2 copy gt{pop}{exch pop}ifelse}bdf + /min{2 copy lt{pop}{exch pop}ifelse}bdf + rampdict begin + 40 dict begin + background length 0 gt{background sssetbackground gsave clippath fill grestore}if + gsave + c1x c1y translate + theta rotate + -90 rotate + /c2y c1x c2x sub dup mul c1y c2y sub dup mul add sqrt def + /c1y 0 def + /c1x 0 def + /c2x 0 def + ext0 + { + 0 getrampcolor + c2y r2 add r1 sub 0.0001 lt + { + c1x c1y r1 360 0 arcn + pathbbox + /aymax exch def + /axmax exch def + /aymin exch def + /axmin exch def + /bxMin xMin axmin min def + /byMin yMin aymin min def + /bxMax xMax axmax max def + /byMax yMax aymax max def + bxMin byMin moveto + bxMax byMin lineto + bxMax byMax lineto + bxMin byMax lineto + bxMin byMin lineto + eofill + }{ + c2y r1 add r2 le + { + c1x c1y r1 0 360 arc + fill + } + { + c2x c2y r2 0 360 arc fill + r1 r2 eq + { + /p1x r1 neg def + /p1y c1y def + /p2x r1 def + /p2y c1y def + p1x p1y moveto p2x p2y lineto p2x yMin lineto p1x yMin lineto + fill + }{ + /AA r2 r1 sub c2y div def + AA -1 eq + {/theta 89.99 def} + {/theta AA 1 AA dup mul sub sqrt div 1 atan def} + ifelse + /SS1 90 theta add dup sin exch cos div def + /p1x r1 SS1 SS1 mul SS1 SS1 mul 1 add div sqrt mul neg def + /p1y p1x SS1 div neg def + /SS2 90 theta sub dup sin exch cos div def + /p2x r1 SS2 SS2 mul SS2 SS2 mul 1 add div sqrt mul def + /p2y p2x SS2 div neg def + r1 r2 gt + { + /L1maxX p1x yMin p1y sub SS1 div add def + /L2maxX p2x yMin p2y sub SS2 div add def + }{ + /L1maxX 0 def + /L2maxX 0 def + }ifelse + p1x p1y moveto p2x p2y lineto L2maxX L2maxX p2x sub SS2 mul p2y add lineto + L1maxX L1maxX p1x sub SS1 mul p1y add lineto + fill + }ifelse + }ifelse + }ifelse + }if + c1x c2x sub dup mul + c1y c2y sub dup mul + add 0.5 exp + 0 dtransform + dup mul exch dup mul add 0.5 exp 72 div + 0 72 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 72 0 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 1 index 1 index lt{exch}if pop + /hires xdf + hires mul + /numpix xdf + /numsteps NumSamples def + /rampIndxInc 1 def + /subsampling false def + numpix 0 ne + { + NumSamples numpix div 0.5 gt + { + /numsteps numpix 2 div round cvi dup 1 le{pop 2}if def + /rampIndxInc NumSamples 1 sub numsteps div def + /subsampling true def + }if + }if + /xInc c2x c1x sub numsteps div def + /yInc c2y c1y sub numsteps div def + /rInc r2 r1 sub numsteps div def + /cx c1x def + /cy c1y def + /radius r1 def + np + xInc 0 eq yInc 0 eq rInc 0 eq and and + { + 0 getrampcolor + cx cy radius 0 360 arc + stroke + NumSamples 1 sub getrampcolor + cx cy radius 72 hires div add 0 360 arc + 0 setlinewidth + stroke + }{ + 0 + numsteps + { + dup + subsampling{round cvi}if + getrampcolor + cx cy radius 0 360 arc + /cx cx xInc add def + /cy cy yInc add def + /radius radius rInc add def + cx cy radius 360 0 arcn + eofill + rampIndxInc add + }repeat + pop + }ifelse + ext1 + { + c2y r2 add r1 lt + { + c2x c2y r2 0 360 arc + fill + }{ + c2y r1 add r2 sub 0.0001 le + { + c2x c2y r2 360 0 arcn + pathbbox + /aymax exch def + /axmax exch def + /aymin exch def + /axmin exch def + /bxMin xMin axmin min def + /byMin yMin aymin min def + /bxMax xMax axmax max def + /byMax yMax aymax max def + bxMin byMin moveto + bxMax byMin lineto + bxMax byMax lineto + bxMin byMax lineto + bxMin byMin lineto + eofill + }{ + c2x c2y r2 0 360 arc fill + r1 r2 eq + { + /p1x r2 neg def + /p1y c2y def + /p2x r2 def + /p2y c2y def + p1x p1y moveto p2x p2y lineto p2x yMax lineto p1x yMax lineto + fill + }{ + /AA r2 r1 sub c2y div def + AA -1 eq + {/theta 89.99 def} + {/theta AA 1 AA dup mul sub sqrt div 1 atan def} + ifelse + /SS1 90 theta add dup sin exch cos div def + /p1x r2 SS1 SS1 mul SS1 SS1 mul 1 add div sqrt mul neg def + /p1y c2y p1x SS1 div sub def + /SS2 90 theta sub dup sin exch cos div def + /p2x r2 SS2 SS2 mul SS2 SS2 mul 1 add div sqrt mul def + /p2y c2y p2x SS2 div sub def + r1 r2 lt + { + /L1maxX p1x yMax p1y sub SS1 div add def + /L2maxX p2x yMax p2y sub SS2 div add def + }{ + /L1maxX 0 def + /L2maxX 0 def + }ifelse + p1x p1y moveto p2x p2y lineto L2maxX L2maxX p2x sub SS2 mul p2y add lineto + L1maxX L1maxX p1x sub SS1 mul p1y add lineto + fill + }ifelse + }ifelse + }ifelse + }if + grestore + grestore + end + end + end + }ifelse + }bdf + /GenStrips + { + 40 dict begin + /ColorSpaceFamily xdf + /background xdf + /ext1 xdf + /ext0 xdf + /BBox xdf + /y2 xdf + /x2 xdf + /y1 xdf + /x1 xdf + /rampdict xdf + /setinkoverprint where{pop/setinkoverprint{pop}def}if + gsave + BBox length 0 gt + { + np + BBox 0 get BBox 1 get moveto + BBox 2 get BBox 0 get sub 0 rlineto + 0 BBox 3 get BBox 1 get sub rlineto + BBox 2 get BBox 0 get sub neg 0 rlineto + closepath + clip + np + }if + x1 x2 eq + { + y1 y2 lt{/theta 90 def}{/theta 270 def}ifelse + }{ + /slope y2 y1 sub x2 x1 sub div def + /theta slope 1 atan def + x2 x1 lt y2 y1 ge and{/theta theta 180 sub def}if + x2 x1 lt y2 y1 lt and{/theta theta 180 add def}if + } + ifelse + gsave + clippath + x1 y1 translate + theta rotate + {pathbbox}stopped + {0 0 0 0}if + /yMax exch def + /xMax exch def + /yMin exch def + /xMin exch def + grestore + xMax xMin eq yMax yMin eq or + { + grestore + end + }{ + rampdict begin + 20 dict begin + background length 0 gt{background sssetbackground gsave clippath fill grestore}if + gsave + x1 y1 translate + theta rotate + /xStart 0 def + /xEnd x2 x1 sub dup mul y2 y1 sub dup mul add 0.5 exp def + /ySpan yMax yMin sub def + /numsteps NumSamples def + /rampIndxInc 1 def + /subsampling false def + xStart 0 transform + xEnd 0 transform + 3 -1 roll + sub dup mul + 3 1 roll + sub dup mul + add 0.5 exp 72 div + 0 72 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 72 0 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 1 index 1 index lt{exch}if pop + mul + /numpix xdf + numpix 0 ne + { + NumSamples numpix div 0.5 gt + { + /numsteps numpix 2 div round cvi dup 1 le{pop 2}if def + /rampIndxInc NumSamples 1 sub numsteps div def + /subsampling true def + }if + }if + ext0 + { + 0 getrampcolor + xMin xStart lt + { + xMin yMin xMin neg ySpan rectfill + }if + }if + /xInc xEnd xStart sub numsteps div def + /x xStart def + 0 + numsteps + { + dup + subsampling{round cvi}if + getrampcolor + x yMin xInc ySpan rectfill + /x x xInc add def + rampIndxInc add + }repeat + pop + ext1{ + xMax xEnd gt + { + xEnd yMin xMax xEnd sub ySpan rectfill + }if + }if + grestore + grestore + end + end + end + }ifelse + }bdf +}def +/pt +{ + end +}def +/dt{ +}def +/pgsv{ + //Adobe_AGM_Core/AGMCORE_save save put +}def +/pgrs{ + //Adobe_AGM_Core/AGMCORE_save get restore +}def +systemdict/findcolorrendering known{ + /findcolorrendering systemdict/findcolorrendering get def +}if +systemdict/setcolorrendering known{ + /setcolorrendering systemdict/setcolorrendering get def +}if +/test_cmyk_color_plate +{ + gsave + setcmykcolor currentgray 1 ne + grestore +}def +/inRip_spot_has_ink +{ + dup//Adobe_AGM_Core/AGMCORE_name xddf + convert_spot_to_process not +}def +/map255_to_range +{ + 1 index sub + 3 -1 roll 255 div mul add +}def +/set_csa_crd +{ + /sep_colorspace_dict null AGMCORE_gput + begin + CSA get_csa_by_name setcolorspace_opt + set_crd + end +} +def +/map_csa +{ + currentdict/MappedCSA known{MappedCSA null ne}{false}ifelse + {pop}{get_csa_by_name/MappedCSA xdf}ifelse +}def +/setsepcolor +{ + /sep_colorspace_dict AGMCORE_gget begin + dup/sep_tint exch AGMCORE_gput + TintProc + end +}def +/setdevicencolor +{ + /devicen_colorspace_dict AGMCORE_gget begin + Names length copy + Names length 1 sub -1 0 + { + /devicen_tints AGMCORE_gget 3 1 roll xpt + }for + TintProc + end +}def +/sep_colorspace_proc +{ + /AGMCORE_tmp exch store + /sep_colorspace_dict AGMCORE_gget begin + currentdict/Components known{ + Components aload pop + TintMethod/Lab eq{ + 2{AGMCORE_tmp mul NComponents 1 roll}repeat + LMax sub AGMCORE_tmp mul LMax add NComponents 1 roll + }{ + TintMethod/Subtractive eq{ + NComponents{ + AGMCORE_tmp mul NComponents 1 roll + }repeat + }{ + NComponents{ + 1 sub AGMCORE_tmp mul 1 add NComponents 1 roll + }repeat + }ifelse + }ifelse + }{ + ColorLookup AGMCORE_tmp ColorLookup length 1 sub mul round cvi get + aload pop + }ifelse + end +}def +/sep_colorspace_gray_proc +{ + /AGMCORE_tmp exch store + /sep_colorspace_dict AGMCORE_gget begin + GrayLookup AGMCORE_tmp GrayLookup length 1 sub mul round cvi get + end +}def +/sep_proc_name +{ + dup 0 get + dup/DeviceRGB eq exch/DeviceCMYK eq or level2 not and has_color not and{ + pop[/DeviceGray] + /sep_colorspace_gray_proc + }{ + /sep_colorspace_proc + }ifelse +}def +/setsepcolorspace +{ + current_spot_alias{ + dup begin + Name map_alias{ + exch pop + }if + end + }if + dup/sep_colorspace_dict exch AGMCORE_gput + begin + CSA map_csa + /AGMCORE_sep_special Name dup()eq exch(All)eq or store + AGMCORE_avoid_L2_sep_space{ + [/Indexed MappedCSA sep_proc_name 255 exch + {255 div}/exec cvx 3 -1 roll[4 1 roll load/exec cvx]cvx + ]setcolorspace_opt + /TintProc{ + 255 mul round cvi setcolor + }bdf + }{ + MappedCSA 0 get/DeviceCMYK eq + currentdict/Components known and + AGMCORE_sep_special not and{ + /TintProc[ + Components aload pop Name findcmykcustomcolor + /exch cvx/setcustomcolor cvx + ]cvx bdf + }{ + AGMCORE_host_sep Name(All)eq and{ + /TintProc{ + 1 exch sub setseparationgray + }bdf + }{ + AGMCORE_in_rip_sep MappedCSA 0 get/DeviceCMYK eq and + AGMCORE_host_sep or + Name()eq and{ + /TintProc[ + MappedCSA sep_proc_name exch 0 get/DeviceCMYK eq{ + cvx/setcmykcolor cvx + }{ + cvx/setgray cvx + }ifelse + ]cvx bdf + }{ + AGMCORE_producing_seps MappedCSA 0 get dup/DeviceCMYK eq exch/DeviceGray eq or and AGMCORE_sep_special not and{ + /TintProc[ + /dup cvx + MappedCSA sep_proc_name cvx exch + 0 get/DeviceGray eq{ + 1/exch cvx/sub cvx 0 0 0 4 -1/roll cvx + }if + /Name cvx/findcmykcustomcolor cvx/exch cvx + AGMCORE_host_sep{ + AGMCORE_is_cmyk_sep + /Name cvx + /AGMCORE_IsSeparationAProcessColor load/exec cvx + /not cvx/and cvx + }{ + Name inRip_spot_has_ink not + }ifelse + [ + /pop cvx 1 + ]cvx/if cvx + /setcustomcolor cvx + ]cvx bdf + }{ + /TintProc{setcolor}bdf + [/Separation Name MappedCSA sep_proc_name load]setcolorspace_opt + }ifelse + }ifelse + }ifelse + }ifelse + }ifelse + set_crd + setsepcolor + end +}def +/additive_blend +{ + 3 dict begin + /numarrays xdf + /numcolors xdf + 0 1 numcolors 1 sub + { + /c1 xdf + 1 + 0 1 numarrays 1 sub + { + 1 exch add/index cvx + c1/get cvx/mul cvx + }for + numarrays 1 add 1/roll cvx + }for + numarrays[/pop cvx]cvx/repeat cvx + end +}def +/subtractive_blend +{ + 3 dict begin + /numarrays xdf + /numcolors xdf + 0 1 numcolors 1 sub + { + /c1 xdf + 1 1 + 0 1 numarrays 1 sub + { + 1 3 3 -1 roll add/index cvx + c1/get cvx/sub cvx/mul cvx + }for + /sub cvx + numarrays 1 add 1/roll cvx + }for + numarrays[/pop cvx]cvx/repeat cvx + end +}def +/exec_tint_transform +{ + /TintProc[ + /TintTransform cvx/setcolor cvx + ]cvx bdf + MappedCSA setcolorspace_opt +}bdf +/devn_makecustomcolor +{ + 2 dict begin + /names_index xdf + /Names xdf + 1 1 1 1 Names names_index get findcmykcustomcolor + /devicen_tints AGMCORE_gget names_index get setcustomcolor + Names length{pop}repeat + end +}bdf +/setdevicencolorspace +{ + dup/AliasedColorants known{false}{true}ifelse + current_spot_alias and{ + 7 dict begin + /names_index 0 def + dup/names_len exch/Names get length def + /new_names names_len array def + /new_LookupTables names_len array def + /alias_cnt 0 def + dup/Names get + { + dup map_alias{ + exch pop + dup/ColorLookup known{ + dup begin + new_LookupTables names_index ColorLookup put + end + }{ + dup/Components known{ + dup begin + new_LookupTables names_index Components put + end + }{ + dup begin + new_LookupTables names_index[null null null null]put + end + }ifelse + }ifelse + new_names names_index 3 -1 roll/Name get put + /alias_cnt alias_cnt 1 add def + }{ + /name xdf + new_names names_index name put + dup/LookupTables known{ + dup begin + new_LookupTables names_index LookupTables names_index get put + end + }{ + dup begin + new_LookupTables names_index[null null null null]put + end + }ifelse + }ifelse + /names_index names_index 1 add def + }forall + alias_cnt 0 gt{ + /AliasedColorants true def + /lut_entry_len new_LookupTables 0 get dup length 256 ge{0 get length}{length}ifelse def + 0 1 names_len 1 sub{ + /names_index xdf + new_LookupTables names_index get dup length 256 ge{0 get length}{length}ifelse lut_entry_len ne{ + /AliasedColorants false def + exit + }{ + new_LookupTables names_index get 0 get null eq{ + dup/Names get names_index get/name xdf + name(Cyan)eq name(Magenta)eq name(Yellow)eq name(Black)eq + or or or not{ + /AliasedColorants false def + exit + }if + }if + }ifelse + }for + lut_entry_len 1 eq{ + /AliasedColorants false def + }if + AliasedColorants{ + dup begin + /Names new_names def + /LookupTables new_LookupTables def + /AliasedColorants true def + /NComponents lut_entry_len def + /TintMethod NComponents 4 eq{/Subtractive}{/Additive}ifelse def + /MappedCSA TintMethod/Additive eq{/DeviceRGB}{/DeviceCMYK}ifelse def + currentdict/TTTablesIdx known not{ + /TTTablesIdx -1 def + }if + end + }if + }if + end + }if + dup/devicen_colorspace_dict exch AGMCORE_gput + begin + currentdict/AliasedColorants known{ + AliasedColorants + }{ + false + }ifelse + dup not{ + CSA map_csa + }if + /TintTransform load type/nulltype eq or{ + /TintTransform[ + 0 1 Names length 1 sub + { + /TTTablesIdx TTTablesIdx 1 add def + dup LookupTables exch get dup 0 get null eq + { + 1 index + Names exch get + dup(Cyan)eq + { + pop exch + LookupTables length exch sub + /index cvx + 0 0 0 + } + { + dup(Magenta)eq + { + pop exch + LookupTables length exch sub + /index cvx + 0/exch cvx 0 0 + }{ + (Yellow)eq + { + exch + LookupTables length exch sub + /index cvx + 0 0 3 -1/roll cvx 0 + }{ + exch + LookupTables length exch sub + /index cvx + 0 0 0 4 -1/roll cvx + }ifelse + }ifelse + }ifelse + 5 -1/roll cvx/astore cvx + }{ + dup length 1 sub + LookupTables length 4 -1 roll sub 1 add + /index cvx/mul cvx/round cvx/cvi cvx/get cvx + }ifelse + Names length TTTablesIdx add 1 add 1/roll cvx + }for + Names length[/pop cvx]cvx/repeat cvx + NComponents Names length + TintMethod/Subtractive eq + { + subtractive_blend + }{ + additive_blend + }ifelse + ]cvx bdf + }if + AGMCORE_host_sep{ + Names convert_to_process{ + exec_tint_transform + } + { + currentdict/AliasedColorants known{ + AliasedColorants not + }{ + false + }ifelse + 5 dict begin + /AvoidAliasedColorants xdf + /painted? false def + /names_index 0 def + /names_len Names length def + AvoidAliasedColorants{ + /currentspotalias current_spot_alias def + false set_spot_alias + }if + Names{ + AGMCORE_is_cmyk_sep{ + dup(Cyan)eq AGMCORE_cyan_plate and exch + dup(Magenta)eq AGMCORE_magenta_plate and exch + dup(Yellow)eq AGMCORE_yellow_plate and exch + (Black)eq AGMCORE_black_plate and or or or{ + /devicen_colorspace_dict AGMCORE_gget/TintProc[ + Names names_index/devn_makecustomcolor cvx + ]cvx ddf + /painted? true def + }if + painted?{exit}if + }{ + 0 0 0 0 5 -1 roll findcmykcustomcolor 1 setcustomcolor currentgray 0 eq{ + /devicen_colorspace_dict AGMCORE_gget/TintProc[ + Names names_index/devn_makecustomcolor cvx + ]cvx ddf + /painted? true def + exit + }if + }ifelse + /names_index names_index 1 add def + }forall + AvoidAliasedColorants{ + currentspotalias set_spot_alias + }if + painted?{ + /devicen_colorspace_dict AGMCORE_gget/names_index names_index put + }{ + /devicen_colorspace_dict AGMCORE_gget/TintProc[ + names_len[/pop cvx]cvx/repeat cvx 1/setseparationgray cvx + 0 0 0 0/setcmykcolor cvx + ]cvx ddf + }ifelse + end + }ifelse + } + { + AGMCORE_in_rip_sep{ + Names convert_to_process not + }{ + level3 + }ifelse + { + [/DeviceN Names MappedCSA/TintTransform load]setcolorspace_opt + /TintProc level3 not AGMCORE_in_rip_sep and{ + [ + Names/length cvx[/pop cvx]cvx/repeat cvx + ]cvx bdf + }{ + {setcolor}bdf + }ifelse + }{ + exec_tint_transform + }ifelse + }ifelse + set_crd + /AliasedColorants false def + end +}def +/setindexedcolorspace +{ + dup/indexed_colorspace_dict exch AGMCORE_gput + begin + currentdict/CSDBase known{ + CSDBase/CSD get_res begin + currentdict/Names known{ + currentdict devncs + }{ + 1 currentdict sepcs + }ifelse + AGMCORE_host_sep{ + 4 dict begin + /compCnt/Names where{pop Names length}{1}ifelse def + /NewLookup HiVal 1 add string def + 0 1 HiVal{ + /tableIndex xdf + Lookup dup type/stringtype eq{ + compCnt tableIndex map_index + }{ + exec + }ifelse + /Names where{ + pop setdevicencolor + }{ + setsepcolor + }ifelse + currentgray + tableIndex exch + 255 mul cvi + NewLookup 3 1 roll put + }for + [/Indexed currentcolorspace HiVal NewLookup]setcolorspace_opt + end + }{ + level3 + { + currentdict/Names known{ + [/Indexed[/DeviceN Names MappedCSA/TintTransform load]HiVal Lookup]setcolorspace_opt + }{ + [/Indexed[/Separation Name MappedCSA sep_proc_name load]HiVal Lookup]setcolorspace_opt + }ifelse + }{ + [/Indexed MappedCSA HiVal + [ + currentdict/Names known{ + Lookup dup type/stringtype eq + {/exch cvx CSDBase/CSD get_res/Names get length dup/mul cvx exch/getinterval cvx{255 div}/forall cvx} + {/exec cvx}ifelse + /TintTransform load/exec cvx + }{ + Lookup dup type/stringtype eq + {/exch cvx/get cvx 255/div cvx} + {/exec cvx}ifelse + CSDBase/CSD get_res/MappedCSA get sep_proc_name exch pop/load cvx/exec cvx + }ifelse + ]cvx + ]setcolorspace_opt + }ifelse + }ifelse + end + set_crd + } + { + CSA map_csa + AGMCORE_host_sep level2 not and{ + 0 0 0 0 setcmykcolor + }{ + [/Indexed MappedCSA + level2 not has_color not and{ + dup 0 get dup/DeviceRGB eq exch/DeviceCMYK eq or{ + pop[/DeviceGray] + }if + HiVal GrayLookup + }{ + HiVal + currentdict/RangeArray known{ + { + /indexed_colorspace_dict AGMCORE_gget begin + Lookup exch + dup HiVal gt{ + pop HiVal + }if + NComponents mul NComponents getinterval{}forall + NComponents 1 sub -1 0{ + RangeArray exch 2 mul 2 getinterval aload pop map255_to_range + NComponents 1 roll + }for + end + }bind + }{ + Lookup + }ifelse + }ifelse + ]setcolorspace_opt + set_crd + }ifelse + }ifelse + end +}def +/setindexedcolor +{ + AGMCORE_host_sep{ + /indexed_colorspace_dict AGMCORE_gget + begin + currentdict/CSDBase known{ + CSDBase/CSD get_res begin + currentdict/Names known{ + map_indexed_devn + devn + } + { + Lookup 1 3 -1 roll map_index + sep + }ifelse + end + }{ + Lookup MappedCSA/DeviceCMYK eq{4}{1}ifelse 3 -1 roll + map_index + MappedCSA/DeviceCMYK eq{setcmykcolor}{setgray}ifelse + }ifelse + end + }{ + level3 not AGMCORE_in_rip_sep and/indexed_colorspace_dict AGMCORE_gget/CSDBase known and{ + /indexed_colorspace_dict AGMCORE_gget/CSDBase get/CSD get_res begin + map_indexed_devn + devn + end + } + { + setcolor + }ifelse + }ifelse +}def +/ignoreimagedata +{ + currentoverprint not{ + gsave + dup clonedict begin + 1 setgray + /Decode[0 1]def + /DataSourcedef + /MultipleDataSources false def + /BitsPerComponent 8 def + currentdict end + systemdict/image gx + grestore + }if + consumeimagedata +}def +/add_res +{ + dup/CSD eq{ + pop + //Adobe_AGM_Core begin + /AGMCORE_CSD_cache load 3 1 roll put + end + }{ + defineresource pop + }ifelse +}def +/del_res +{ + { + aload pop exch + dup/CSD eq{ + pop + {//Adobe_AGM_Core/AGMCORE_CSD_cache get exch undef}forall + }{ + exch + {1 index undefineresource}forall + pop + }ifelse + }forall +}def +/get_res +{ + dup/CSD eq{ + pop + dup type dup/nametype eq exch/stringtype eq or{ + AGMCORE_CSD_cache exch get + }if + }{ + findresource + }ifelse +}def +/get_csa_by_name +{ + dup type dup/nametype eq exch/stringtype eq or{ + /CSA get_res + }if +}def +/paintproc_buf_init +{ + /count get 0 0 put +}def +/paintproc_buf_next +{ + dup/count get dup 0 get + dup 3 1 roll + 1 add 0 xpt + get +}def +/cachepaintproc_compress +{ + 5 dict begin + currentfile exch 0 exch/SubFileDecode filter/ReadFilter exch def + /ppdict 20 dict def + /string_size 16000 def + /readbuffer string_size string def + currentglobal true setglobal + ppdict 1 array dup 0 1 put/count xpt + setglobal + /LZWFilter + { + exch + dup length 0 eq{ + pop + }{ + ppdict dup length 1 sub 3 -1 roll put + }ifelse + {string_size}{0}ifelse string + }/LZWEncode filter def + { + ReadFilter readbuffer readstring + exch LZWFilter exch writestring + not{exit}if + }loop + LZWFilter closefile + ppdict + end +}def +/cachepaintproc +{ + 2 dict begin + currentfile exch 0 exch/SubFileDecode filter/ReadFilter exch def + /ppdict 20 dict def + currentglobal true setglobal + ppdict 1 array dup 0 1 put/count xpt + setglobal + { + ReadFilter 16000 string readstring exch + ppdict dup length 1 sub 3 -1 roll put + not{exit}if + }loop + ppdict dup dup length 1 sub()put + end +}def +/make_pattern +{ + exch clonedict exch + dup matrix currentmatrix matrix concatmatrix 0 0 3 2 roll itransform + exch 3 index/XStep get 1 index exch 2 copy div cvi mul sub sub + exch 3 index/YStep get 1 index exch 2 copy div cvi mul sub sub + matrix translate exch matrix concatmatrix + 1 index begin + BBox 0 get XStep div cvi XStep mul/xshift exch neg def + BBox 1 get YStep div cvi YStep mul/yshift exch neg def + BBox 0 get xshift add + BBox 1 get yshift add + BBox 2 get xshift add + BBox 3 get yshift add + 4 array astore + /BBox exch def + [xshift yshift/translate load null/exec load]dup + 3/PaintProc load put cvx/PaintProc exch def + end + gsave 0 setgray + makepattern + grestore +}def +/set_pattern +{ + dup/PatternType get 1 eq{ + dup/PaintType get 1 eq{ + currentoverprint sop[/DeviceGray]setcolorspace 0 setgray + }if + }if + setpattern +}def +/setcolorspace_opt +{ + dup currentcolorspace eq{pop}{setcolorspace}ifelse +}def +/updatecolorrendering +{ + currentcolorrendering/RenderingIntent known{ + currentcolorrendering/RenderingIntent get + } + { + Intent/AbsoluteColorimetric eq + { + /absolute_colorimetric_crd AGMCORE_gget dup null eq + } + { + Intent/RelativeColorimetric eq + { + /relative_colorimetric_crd AGMCORE_gget dup null eq + } + { + Intent/Saturation eq + { + /saturation_crd AGMCORE_gget dup null eq + } + { + /perceptual_crd AGMCORE_gget dup null eq + }ifelse + }ifelse + }ifelse + { + pop null + } + { + /RenderingIntent known{null}{Intent}ifelse + }ifelse + }ifelse + Intent ne{ + Intent/ColorRendering{findresource}stopped + { + pop pop systemdict/findcolorrendering known + { + Intent findcolorrendering + { + /ColorRendering findresource true exch + } + { + /ColorRendering findresource + product(Xerox Phaser 5400)ne + exch + }ifelse + dup Intent/AbsoluteColorimetric eq + { + /absolute_colorimetric_crd exch AGMCORE_gput + } + { + Intent/RelativeColorimetric eq + { + /relative_colorimetric_crd exch AGMCORE_gput + } + { + Intent/Saturation eq + { + /saturation_crd exch AGMCORE_gput + } + { + Intent/Perceptual eq + { + /perceptual_crd exch AGMCORE_gput + } + { + pop + }ifelse + }ifelse + }ifelse + }ifelse + 1 index{exch}{pop}ifelse + } + {false}ifelse + } + {true}ifelse + { + dup begin + currentdict/TransformPQR known{ + currentdict/TransformPQR get aload pop + 3{{}eq 3 1 roll}repeat or or + } + {true}ifelse + currentdict/MatrixPQR known{ + currentdict/MatrixPQR get aload pop + 1.0 eq 9 1 roll 0.0 eq 9 1 roll 0.0 eq 9 1 roll + 0.0 eq 9 1 roll 1.0 eq 9 1 roll 0.0 eq 9 1 roll + 0.0 eq 9 1 roll 0.0 eq 9 1 roll 1.0 eq + and and and and and and and and + } + {true}ifelse + end + or + { + clonedict begin + /TransformPQR[ + {4 -1 roll 3 get dup 3 1 roll sub 5 -1 roll 3 get 3 -1 roll sub div + 3 -1 roll 3 get 3 -1 roll 3 get dup 4 1 roll sub mul add}bind + {4 -1 roll 4 get dup 3 1 roll sub 5 -1 roll 4 get 3 -1 roll sub div + 3 -1 roll 4 get 3 -1 roll 4 get dup 4 1 roll sub mul add}bind + {4 -1 roll 5 get dup 3 1 roll sub 5 -1 roll 5 get 3 -1 roll sub div + 3 -1 roll 5 get 3 -1 roll 5 get dup 4 1 roll sub mul add}bind + ]def + /MatrixPQR[0.8951 -0.7502 0.0389 0.2664 1.7135 -0.0685 -0.1614 0.0367 1.0296]def + /RangePQR[-0.3227950745 2.3229645538 -1.5003771057 3.5003465881 -0.1369979095 2.136967392]def + currentdict end + }if + setcolorrendering_opt + }if + }if +}def +/set_crd +{ + AGMCORE_host_sep not level2 and{ + currentdict/ColorRendering known{ + ColorRendering/ColorRendering{findresource}stopped not{setcolorrendering_opt}if + }{ + currentdict/Intent known{ + updatecolorrendering + }if + }ifelse + currentcolorspace dup type/arraytype eq + {0 get}if + /DeviceRGB eq + { + currentdict/UCR known + {/UCR}{/AGMCORE_currentucr}ifelse + load setundercolorremoval + currentdict/BG known + {/BG}{/AGMCORE_currentbg}ifelse + load setblackgeneration + }if + }if +}def +/set_ucrbg +{ + dup null eq{pop/AGMCORE_currentbg load}{/Procedure get_res}ifelse setblackgeneration + dup null eq{pop/AGMCORE_currentucr load}{/Procedure get_res}ifelse setundercolorremoval +}def +/setcolorrendering_opt +{ + dup currentcolorrendering eq{ + pop + }{ + product(HP Color LaserJet 2605)anchorsearch{ + pop pop pop + }{ + pop + clonedict + begin + /Intent Intent def + currentdict + end + setcolorrendering + }ifelse + }ifelse +}def +/cpaint_gcomp +{ + convert_to_process//Adobe_AGM_Core/AGMCORE_ConvertToProcess xddf + //Adobe_AGM_Core/AGMCORE_ConvertToProcess get not + { + (%end_cpaint_gcomp)flushinput + }if +}def +/cpaint_gsep +{ + //Adobe_AGM_Core/AGMCORE_ConvertToProcess get + { + (%end_cpaint_gsep)flushinput + }if +}def +/cpaint_gend +{np}def +/T1_path +{ + currentfile token pop currentfile token pop mo + { + currentfile token pop dup type/stringtype eq + {pop exit}if + 0 exch rlineto + currentfile token pop dup type/stringtype eq + {pop exit}if + 0 rlineto + }loop +}def +/T1_gsave + level3 + {/clipsave} + {/gsave}ifelse + load def +/T1_grestore + level3 + {/cliprestore} + {/grestore}ifelse + load def +/set_spot_alias_ary +{ + dup inherit_aliases + //Adobe_AGM_Core/AGMCORE_SpotAliasAry xddf +}def +/set_spot_normalization_ary +{ + dup inherit_aliases + dup length + /AGMCORE_SpotAliasAry where{pop AGMCORE_SpotAliasAry length add}if + array + //Adobe_AGM_Core/AGMCORE_SpotAliasAry2 xddf + /AGMCORE_SpotAliasAry where{ + pop + AGMCORE_SpotAliasAry2 0 AGMCORE_SpotAliasAry putinterval + AGMCORE_SpotAliasAry length + }{0}ifelse + AGMCORE_SpotAliasAry2 3 1 roll exch putinterval + true set_spot_alias +}def +/inherit_aliases +{ + {dup/Name get map_alias{/CSD put}{pop}ifelse}forall +}def +/set_spot_alias +{ + /AGMCORE_SpotAliasAry2 where{ + /AGMCORE_current_spot_alias 3 -1 roll put + }{ + pop + }ifelse +}def +/current_spot_alias +{ + /AGMCORE_SpotAliasAry2 where{ + /AGMCORE_current_spot_alias get + }{ + false + }ifelse +}def +/map_alias +{ + /AGMCORE_SpotAliasAry2 where{ + begin + /AGMCORE_name xdf + false + AGMCORE_SpotAliasAry2{ + dup/Name get AGMCORE_name eq{ + /CSD get/CSD get_res + exch pop true + exit + }{ + pop + }ifelse + }forall + end + }{ + pop false + }ifelse +}bdf +/spot_alias +{ + true set_spot_alias + /AGMCORE_&setcustomcolor AGMCORE_key_known not{ + //Adobe_AGM_Core/AGMCORE_&setcustomcolor/setcustomcolor load put + }if + /customcolor_tint 1 AGMCORE_gput + //Adobe_AGM_Core begin + /setcustomcolor + { + //Adobe_AGM_Core begin + dup/customcolor_tint exch AGMCORE_gput + 1 index aload pop pop 1 eq exch 1 eq and exch 1 eq and exch 1 eq and not + current_spot_alias and{1 index 4 get map_alias}{false}ifelse + { + false set_spot_alias + /sep_colorspace_dict AGMCORE_gget null ne + {/sep_colorspace_dict AGMCORE_gget/ForeignContent known not}{false}ifelse + 3 1 roll 2 index{ + exch pop/sep_tint AGMCORE_gget exch + }if + mark 3 1 roll + setsepcolorspace + counttomark 0 ne{ + setsepcolor + }if + pop + not{/sep_tint 1.0 AGMCORE_gput/sep_colorspace_dict AGMCORE_gget/ForeignContent true put}if + pop + true set_spot_alias + }{ + AGMCORE_&setcustomcolor + }ifelse + end + }bdf + end +}def +/begin_feature +{ + Adobe_AGM_Core/AGMCORE_feature_dictCount countdictstack put + count Adobe_AGM_Core/AGMCORE_feature_opCount 3 -1 roll put + {Adobe_AGM_Core/AGMCORE_feature_ctm matrix currentmatrix put}if +}def +/end_feature +{ + 2 dict begin + /spd/setpagedevice load def + /setpagedevice{get_gstate spd set_gstate}def + stopped{$error/newerror false put}if + end + count Adobe_AGM_Core/AGMCORE_feature_opCount get sub dup 0 gt{{pop}repeat}{pop}ifelse + countdictstack Adobe_AGM_Core/AGMCORE_feature_dictCount get sub dup 0 gt{{end}repeat}{pop}ifelse + {Adobe_AGM_Core/AGMCORE_feature_ctm get setmatrix}if +}def +/set_negative +{ + //Adobe_AGM_Core begin + /AGMCORE_inverting exch def + level2{ + currentpagedevice/NegativePrint known AGMCORE_distilling not and{ + currentpagedevice/NegativePrint get//Adobe_AGM_Core/AGMCORE_inverting get ne{ + true begin_feature true{ + <>setpagedevice + }end_feature + }if + /AGMCORE_inverting false def + }if + }if + AGMCORE_inverting{ + [{1 exch sub}/exec load dup currenttransfer exch]cvx bind settransfer + AGMCORE_distilling{ + erasepage + }{ + gsave np clippath 1/setseparationgray where{pop setseparationgray}{setgray}ifelse + /AGMIRS_&fill where{pop AGMIRS_&fill}{fill}ifelse grestore + }ifelse + }if + end +}def +/lw_save_restore_override{ + /md where{ + pop + md begin + initializepage + /initializepage{}def + /pmSVsetup{}def + /endp{}def + /pse{}def + /psb{}def + /orig_showpage where + {pop} + {/orig_showpage/showpage load def} + ifelse + /showpage{orig_showpage gR}def + end + }if +}def +/pscript_showpage_override{ + /NTPSOct95 where + { + begin + showpage + save + /showpage/restore load def + /restore{exch pop}def + end + }if +}def +/driver_media_override +{ + /md where{ + pop + md/initializepage known{ + md/initializepage{}put + }if + md/rC known{ + md/rC{4{pop}repeat}put + }if + }if + /mysetup where{ + /mysetup[1 0 0 1 0 0]put + }if + Adobe_AGM_Core/AGMCORE_Default_CTM matrix currentmatrix put + level2 + {Adobe_AGM_Core/AGMCORE_Default_PageSize currentpagedevice/PageSize get put}if +}def +/capture_mysetup +{ + /Pscript_Win_Data where{ + pop + Pscript_Win_Data/mysetup known{ + Adobe_AGM_Core/save_mysetup Pscript_Win_Data/mysetup get put + }if + }if +}def +/restore_mysetup +{ + /Pscript_Win_Data where{ + pop + Pscript_Win_Data/mysetup known{ + Adobe_AGM_Core/save_mysetup known{ + Pscript_Win_Data/mysetup Adobe_AGM_Core/save_mysetup get put + Adobe_AGM_Core/save_mysetup undef + }if + }if + }if +}def +/driver_check_media_override +{ + /PrepsDict where + {pop} + { + Adobe_AGM_Core/AGMCORE_Default_CTM get matrix currentmatrix ne + Adobe_AGM_Core/AGMCORE_Default_PageSize get type/arraytype eq + { + Adobe_AGM_Core/AGMCORE_Default_PageSize get 0 get currentpagedevice/PageSize get 0 get eq and + Adobe_AGM_Core/AGMCORE_Default_PageSize get 1 get currentpagedevice/PageSize get 1 get eq and + }if + { + Adobe_AGM_Core/AGMCORE_Default_CTM get setmatrix + }if + }ifelse +}def +AGMCORE_err_strings begin + /AGMCORE_bad_environ(Environment not satisfactory for this job. Ensure that the PPD is correct or that the PostScript level requested is supported by this printer. )def + /AGMCORE_color_space_onhost_seps(This job contains colors that will not separate with on-host methods. )def + /AGMCORE_invalid_color_space(This job contains an invalid color space. )def +end +/set_def_ht +{AGMCORE_def_ht sethalftone}def +/set_def_flat +{AGMCORE_Default_flatness setflat}def +end +systemdict/setpacking known +{setpacking}if +%%EndResource +%%BeginResource: procset Adobe_CoolType_Core 2.31 0 %%Copyright: Copyright 1997-2006 Adobe Systems Incorporated. All Rights Reserved. %%Version: 2.31 0 10 dict begin /Adobe_CoolType_Passthru currentdict def /Adobe_CoolType_Core_Defined userdict/Adobe_CoolType_Core known def Adobe_CoolType_Core_Defined {/Adobe_CoolType_Core userdict/Adobe_CoolType_Core get def} if userdict/Adobe_CoolType_Core 70 dict dup begin put /Adobe_CoolType_Version 2.31 def /Level2? systemdict/languagelevel known dup {pop systemdict/languagelevel get 2 ge} if def Level2? not { /currentglobal false def /setglobal/pop load def /gcheck{pop false}bind def /currentpacking false def /setpacking/pop load def /SharedFontDirectory 0 dict def } if currentpacking true setpacking currentglobal false setglobal userdict/Adobe_CoolType_Data 2 copy known not {2 copy 10 dict put} if get begin /@opStackCountByLevel 32 dict def /@opStackLevel 0 def /@dictStackCountByLevel 32 dict def /@dictStackLevel 0 def end setglobal currentglobal true setglobal userdict/Adobe_CoolType_GVMFonts known not {userdict/Adobe_CoolType_GVMFonts 10 dict put} if setglobal currentglobal false setglobal userdict/Adobe_CoolType_LVMFonts known not {userdict/Adobe_CoolType_LVMFonts 10 dict put} if setglobal /ct_VMDictPut { dup gcheck{Adobe_CoolType_GVMFonts}{Adobe_CoolType_LVMFonts}ifelse 3 1 roll put }bind def /ct_VMDictUndef { dup Adobe_CoolType_GVMFonts exch known {Adobe_CoolType_GVMFonts exch undef} { dup Adobe_CoolType_LVMFonts exch known {Adobe_CoolType_LVMFonts exch undef} {pop} ifelse }ifelse }bind def /ct_str1 1 string def /ct_xshow { /_ct_na exch def /_ct_i 0 def currentpoint /_ct_y exch def /_ct_x exch def { pop pop ct_str1 exch 0 exch put ct_str1 show {_ct_na _ct_i get}stopped {pop pop} { _ct_x _ct_y moveto 0 rmoveto } ifelse /_ct_i _ct_i 1 add def currentpoint /_ct_y exch def /_ct_x exch def } exch @cshow }bind def /ct_yshow { /_ct_na exch def /_ct_i 0 def currentpoint /_ct_y exch def /_ct_x exch def { pop pop ct_str1 exch 0 exch put ct_str1 show {_ct_na _ct_i get}stopped {pop pop} { _ct_x _ct_y moveto 0 exch rmoveto } ifelse /_ct_i _ct_i 1 add def currentpoint /_ct_y exch def /_ct_x exch def } exch @cshow }bind def /ct_xyshow { /_ct_na exch def /_ct_i 0 def currentpoint /_ct_y exch def /_ct_x exch def { pop pop ct_str1 exch 0 exch put ct_str1 show {_ct_na _ct_i get}stopped {pop pop} { {_ct_na _ct_i 1 add get}stopped {pop pop pop} { _ct_x _ct_y moveto rmoveto } ifelse } ifelse /_ct_i _ct_i 2 add def currentpoint /_ct_y exch def /_ct_x exch def } exch @cshow }bind def /xsh{{@xshow}stopped{Adobe_CoolType_Data begin ct_xshow end}if}bind def /ysh{{@yshow}stopped{Adobe_CoolType_Data begin ct_yshow end}if}bind def /xysh{{@xyshow}stopped{Adobe_CoolType_Data begin ct_xyshow end}if}bind def currentglobal true setglobal /ct_T3Defs { /BuildChar { 1 index/Encoding get exch get 1 index/BuildGlyph get exec }bind def /BuildGlyph { exch begin GlyphProcs exch get exec end }bind def }bind def setglobal /@_SaveStackLevels { Adobe_CoolType_Data begin /@vmState currentglobal def false setglobal @opStackCountByLevel @opStackLevel 2 copy known not { 2 copy 3 dict dup/args 7 index 5 add array put put get } { get dup/args get dup length 3 index lt { dup length 5 add array exch 1 index exch 0 exch putinterval 1 index exch/args exch put } {pop} ifelse } ifelse begin count 1 sub 1 index lt {pop count} if dup/argCount exch def dup 0 gt { args exch 0 exch getinterval astore pop } {pop} ifelse count /restCount exch def end /@opStackLevel @opStackLevel 1 add def countdictstack 1 sub @dictStackCountByLevel exch @dictStackLevel exch put /@dictStackLevel @dictStackLevel 1 add def @vmState setglobal end }bind def /@_RestoreStackLevels { Adobe_CoolType_Data begin /@opStackLevel @opStackLevel 1 sub def @opStackCountByLevel @opStackLevel get begin count restCount sub dup 0 gt {{pop}repeat} {pop} ifelse args 0 argCount getinterval{}forall end /@dictStackLevel @dictStackLevel 1 sub def @dictStackCountByLevel @dictStackLevel get end countdictstack exch sub dup 0 gt {{end}repeat} {pop} ifelse }bind def /@_PopStackLevels { Adobe_CoolType_Data begin /@opStackLevel @opStackLevel 1 sub def /@dictStackLevel @dictStackLevel 1 sub def end }bind def /@Raise { exch cvx exch errordict exch get exec stop }bind def /@ReRaise { cvx $error/errorname get errordict exch get exec stop }bind def /@Stopped { 0 @#Stopped }bind def /@#Stopped { @_SaveStackLevels stopped {@_RestoreStackLevels true} {@_PopStackLevels false} ifelse }bind def /@Arg { Adobe_CoolType_Data begin @opStackCountByLevel @opStackLevel 1 sub get begin args exch argCount 1 sub exch sub get end end }bind def currentglobal true setglobal /CTHasResourceForAllBug Level2? { 1 dict dup /@shouldNotDisappearDictValue true def Adobe_CoolType_Data exch/@shouldNotDisappearDict exch put begin count @_SaveStackLevels {(*){pop stop}128 string/Category resourceforall} stopped pop @_RestoreStackLevels currentdict Adobe_CoolType_Data/@shouldNotDisappearDict get dup 3 1 roll ne dup 3 1 roll { /@shouldNotDisappearDictValue known { { end currentdict 1 index eq {pop exit} if } loop } if } { pop end } ifelse } {false} ifelse def true setglobal /CTHasResourceStatusBug Level2? { mark {/steveamerige/Category resourcestatus} stopped {cleartomark true} {cleartomark currentglobal not} ifelse } {false} ifelse def setglobal /CTResourceStatus { mark 3 1 roll /Category findresource begin ({ResourceStatus}stopped)0()/SubFileDecode filter cvx exec {cleartomark false} {{3 2 roll pop true}{cleartomark false}ifelse} ifelse end }bind def /CTWorkAroundBugs { Level2? { /cid_PreLoad/ProcSet resourcestatus { pop pop currentglobal mark { (*) { dup/CMap CTHasResourceStatusBug {CTResourceStatus} {resourcestatus} ifelse { pop dup 0 eq exch 1 eq or { dup/CMap findresource gcheck setglobal /CMap undefineresource } { pop CTHasResourceForAllBug {exit} {stop} ifelse } ifelse } {pop} ifelse } 128 string/CMap resourceforall } stopped {cleartomark} stopped pop setglobal } if } if }bind def /ds { Adobe_CoolType_Core begin CTWorkAroundBugs /mo/moveto load def /nf/newencodedfont load def /msf{makefont setfont}bind def /uf{dup undefinefont ct_VMDictUndef}bind def /ur/undefineresource load def /chp/charpath load def /awsh/awidthshow load def /wsh/widthshow load def /ash/ashow load def /@xshow/xshow load def /@yshow/yshow load def /@xyshow/xyshow load def /@cshow/cshow load def /sh/show load def /rp/repeat load def /.n/.notdef def end currentglobal false setglobal userdict/Adobe_CoolType_Data 2 copy known not {2 copy 10 dict put} if get begin /AddWidths? false def /CC 0 def /charcode 2 string def /@opStackCountByLevel 32 dict def /@opStackLevel 0 def /@dictStackCountByLevel 32 dict def /@dictStackLevel 0 def /InVMFontsByCMap 10 dict def /InVMDeepCopiedFonts 10 dict def end setglobal }bind def /dt { currentdict Adobe_CoolType_Core eq {end} if }bind def /ps { Adobe_CoolType_Core begin Adobe_CoolType_GVMFonts begin Adobe_CoolType_LVMFonts begin SharedFontDirectory begin }bind def /pt { end end end end }bind def /unload { systemdict/languagelevel known { systemdict/languagelevel get 2 ge { userdict/Adobe_CoolType_Core 2 copy known {undef} {pop pop} ifelse } if } if }bind def /ndf { 1 index where {pop pop pop} {dup xcheck{bind}if def} ifelse }def /findfont systemdict begin userdict begin /globaldict where{/globaldict get begin}if dup where pop exch get /globaldict where{pop end}if end end Adobe_CoolType_Core_Defined {/systemfindfont exch def} { /findfont 1 index def /systemfindfont exch def } ifelse /undefinefont {pop}ndf /copyfont { currentglobal 3 1 roll 1 index gcheck setglobal dup null eq{0}{dup length}ifelse 2 index length add 1 add dict begin exch { 1 index/FID eq {pop pop} {def} ifelse } forall dup null eq {pop} {{def}forall} ifelse currentdict end exch setglobal }bind def /copyarray { currentglobal exch dup gcheck setglobal dup length array copy exch setglobal }bind def /newencodedfont { currentglobal { SharedFontDirectory 3 index known {SharedFontDirectory 3 index get/FontReferenced known} {false} ifelse } { FontDirectory 3 index known {FontDirectory 3 index get/FontReferenced known} { SharedFontDirectory 3 index known {SharedFontDirectory 3 index get/FontReferenced known} {false} ifelse } ifelse } ifelse dup { 3 index findfont/FontReferenced get 2 index dup type/nametype eq {findfont} if ne {pop false} if } if dup { 1 index dup type/nametype eq {findfont} if dup/CharStrings known { /CharStrings get length 4 index findfont/CharStrings get length ne { pop false } if } {pop} ifelse } if { pop 1 index findfont /Encoding get exch 0 1 255 {2 copy get 3 index 3 1 roll put} for pop pop pop } { currentglobal 4 1 roll dup type/nametype eq {findfont} if dup gcheck setglobal dup dup maxlength 2 add dict begin exch { 1 index/FID ne 2 index/Encoding ne and {def} {pop pop} ifelse } forall /FontReferenced exch def /Encoding exch dup length array copy def /FontName 1 index dup type/stringtype eq{cvn}if def dup currentdict end definefont ct_VMDictPut setglobal } ifelse }bind def /SetSubstituteStrategy { $SubstituteFont begin dup type/dicttype ne {0 dict} if currentdict/$Strategies known { exch $Strategies exch 2 copy known { get 2 copy maxlength exch maxlength add dict begin {def}forall {def}forall currentdict dup/$Init known {dup/$Init get exec} if end /$Strategy exch def } {pop pop pop} ifelse } {pop pop} ifelse end }bind def /scff { $SubstituteFont begin dup type/stringtype eq {dup length exch} {null} ifelse /$sname exch def /$slen exch def /$inVMIndex $sname null eq { 1 index $str cvs dup length $slen sub $slen getinterval cvn } {$sname} ifelse def end {findfont} @Stopped { dup length 8 add string exch 1 index 0(BadFont:)putinterval 1 index exch 8 exch dup length string cvs putinterval cvn {findfont} @Stopped {pop/Courier findfont} if } if $SubstituteFont begin /$sname null def /$slen 0 def /$inVMIndex null def end }bind def /isWidthsOnlyFont { dup/WidthsOnly known {pop pop true} { dup/FDepVector known {/FDepVector get{isWidthsOnlyFont dup{exit}if}forall} { dup/FDArray known {/FDArray get{isWidthsOnlyFont dup{exit}if}forall} {pop} ifelse } ifelse } ifelse }bind def /ct_StyleDicts 4 dict dup begin /Adobe-Japan1 4 dict dup begin Level2? { /Serif /HeiseiMin-W3-83pv-RKSJ-H/Font resourcestatus {pop pop/HeiseiMin-W3} { /CIDFont/Category resourcestatus { pop pop /HeiseiMin-W3/CIDFont resourcestatus {pop pop/HeiseiMin-W3} {/Ryumin-Light} ifelse } {/Ryumin-Light} ifelse } ifelse def /SansSerif /HeiseiKakuGo-W5-83pv-RKSJ-H/Font resourcestatus {pop pop/HeiseiKakuGo-W5} { /CIDFont/Category resourcestatus { pop pop /HeiseiKakuGo-W5/CIDFont resourcestatus {pop pop/HeiseiKakuGo-W5} {/GothicBBB-Medium} ifelse } {/GothicBBB-Medium} ifelse } ifelse def /HeiseiMaruGo-W4-83pv-RKSJ-H/Font resourcestatus {pop pop/HeiseiMaruGo-W4} { /CIDFont/Category resourcestatus { pop pop /HeiseiMaruGo-W4/CIDFont resourcestatus {pop pop/HeiseiMaruGo-W4} { /Jun101-Light-RKSJ-H/Font resourcestatus {pop pop/Jun101-Light} {SansSerif} ifelse } ifelse } { /Jun101-Light-RKSJ-H/Font resourcestatus {pop pop/Jun101-Light} {SansSerif} ifelse } ifelse } ifelse /RoundSansSerif exch def /Default Serif def } { /Serif/Ryumin-Light def /SansSerif/GothicBBB-Medium def { (fonts/Jun101-Light-83pv-RKSJ-H)status }stopped {pop}{ {pop pop pop pop/Jun101-Light} {SansSerif} ifelse /RoundSansSerif exch def }ifelse /Default Serif def } ifelse end def /Adobe-Korea1 4 dict dup begin /Serif/HYSMyeongJo-Medium def /SansSerif/HYGoThic-Medium def /RoundSansSerif SansSerif def /Default Serif def end def /Adobe-GB1 4 dict dup begin /Serif/STSong-Light def /SansSerif/STHeiti-Regular def /RoundSansSerif SansSerif def /Default Serif def end def /Adobe-CNS1 4 dict dup begin /Serif/MKai-Medium def /SansSerif/MHei-Medium def /RoundSansSerif SansSerif def /Default Serif def end def end def Level2?{currentglobal true setglobal}if /ct_BoldRomanWidthProc { stringwidth 1 index 0 ne{exch .03 add exch}if setcharwidth 0 0 }bind def /ct_Type0WidthProc { dup stringwidth 0 0 moveto 2 index true charpath pathbbox 0 -1 7 index 2 div .88 setcachedevice2 pop 0 0 }bind def /ct_Type0WMode1WidthProc { dup stringwidth pop 2 div neg -0.88 2 copy moveto 0 -1 5 -1 roll true charpath pathbbox setcachedevice }bind def /cHexEncoding [/c00/c01/c02/c03/c04/c05/c06/c07/c08/c09/c0A/c0B/c0C/c0D/c0E/c0F/c10/c11/c12 /c13/c14/c15/c16/c17/c18/c19/c1A/c1B/c1C/c1D/c1E/c1F/c20/c21/c22/c23/c24/c25 /c26/c27/c28/c29/c2A/c2B/c2C/c2D/c2E/c2F/c30/c31/c32/c33/c34/c35/c36/c37/c38 /c39/c3A/c3B/c3C/c3D/c3E/c3F/c40/c41/c42/c43/c44/c45/c46/c47/c48/c49/c4A/c4B /c4C/c4D/c4E/c4F/c50/c51/c52/c53/c54/c55/c56/c57/c58/c59/c5A/c5B/c5C/c5D/c5E /c5F/c60/c61/c62/c63/c64/c65/c66/c67/c68/c69/c6A/c6B/c6C/c6D/c6E/c6F/c70/c71 /c72/c73/c74/c75/c76/c77/c78/c79/c7A/c7B/c7C/c7D/c7E/c7F/c80/c81/c82/c83/c84 /c85/c86/c87/c88/c89/c8A/c8B/c8C/c8D/c8E/c8F/c90/c91/c92/c93/c94/c95/c96/c97 /c98/c99/c9A/c9B/c9C/c9D/c9E/c9F/cA0/cA1/cA2/cA3/cA4/cA5/cA6/cA7/cA8/cA9/cAA /cAB/cAC/cAD/cAE/cAF/cB0/cB1/cB2/cB3/cB4/cB5/cB6/cB7/cB8/cB9/cBA/cBB/cBC/cBD /cBE/cBF/cC0/cC1/cC2/cC3/cC4/cC5/cC6/cC7/cC8/cC9/cCA/cCB/cCC/cCD/cCE/cCF/cD0 /cD1/cD2/cD3/cD4/cD5/cD6/cD7/cD8/cD9/cDA/cDB/cDC/cDD/cDE/cDF/cE0/cE1/cE2/cE3 /cE4/cE5/cE6/cE7/cE8/cE9/cEA/cEB/cEC/cED/cEE/cEF/cF0/cF1/cF2/cF3/cF4/cF5/cF6 /cF7/cF8/cF9/cFA/cFB/cFC/cFD/cFE/cFF]def /ct_BoldBaseFont 11 dict begin /FontType 3 def /FontMatrix[1 0 0 1 0 0]def /FontBBox[0 0 1 1]def /Encoding cHexEncoding def /_setwidthProc/ct_BoldRomanWidthProc load def /_bcstr1 1 string def /BuildChar { exch begin _basefont setfont _bcstr1 dup 0 4 -1 roll put dup _setwidthProc 3 copy moveto show _basefonto setfont moveto show end }bind def currentdict end def systemdict/composefont known { /ct_DefineIdentity-H { /Identity-H/CMap resourcestatus { pop pop } { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering(Identity)def /Supplement 0 def end def /CMapName/Identity-H def /CMapVersion 1.000 def /CMapType 1 def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } ifelse } def /ct_BoldBaseCIDFont 11 dict begin /CIDFontType 1 def /CIDFontName/ct_BoldBaseCIDFont def /FontMatrix[1 0 0 1 0 0]def /FontBBox[0 0 1 1]def /_setwidthProc/ct_Type0WidthProc load def /_bcstr2 2 string def /BuildGlyph { exch begin _basefont setfont _bcstr2 1 2 index 256 mod put _bcstr2 0 3 -1 roll 256 idiv put _bcstr2 dup _setwidthProc 3 copy moveto show _basefonto setfont moveto show end }bind def currentdict end def }if Level2?{setglobal}if /ct_CopyFont{ { 1 index/FID ne 2 index/UniqueID ne and {def}{pop pop}ifelse }forall }bind def /ct_Type0CopyFont { exch dup length dict begin ct_CopyFont [ exch FDepVector { dup/FontType get 0 eq { 1 index ct_Type0CopyFont /_ctType0 exch definefont } { /_ctBaseFont exch 2 index exec } ifelse exch } forall pop ] /FDepVector exch def currentdict end }bind def /ct_MakeBoldFont { dup/ct_SyntheticBold known { dup length 3 add dict begin ct_CopyFont /ct_StrokeWidth .03 0 FontMatrix idtransform pop def /ct_SyntheticBold true def currentdict end definefont } { dup dup length 3 add dict begin ct_CopyFont /PaintType 2 def /StrokeWidth .03 0 FontMatrix idtransform pop def /dummybold currentdict end definefont dup/FontType get dup 9 ge exch 11 le and { ct_BoldBaseCIDFont dup length 3 add dict copy begin dup/CIDSystemInfo get/CIDSystemInfo exch def ct_DefineIdentity-H /_Type0Identity/Identity-H 3 -1 roll[exch]composefont /_basefont exch def /_Type0Identity/Identity-H 3 -1 roll[exch]composefont /_basefonto exch def currentdict end /CIDFont defineresource } { ct_BoldBaseFont dup length 3 add dict copy begin /_basefont exch def /_basefonto exch def currentdict end definefont } ifelse } ifelse }bind def /ct_MakeBold{ 1 index 1 index findfont currentglobal 5 1 roll dup gcheck setglobal dup /FontType get 0 eq { dup/WMode known{dup/WMode get 1 eq}{false}ifelse version length 4 ge and {version 0 4 getinterval cvi 2015 ge} {true} ifelse {/ct_Type0WidthProc} {/ct_Type0WMode1WidthProc} ifelse ct_BoldBaseFont/_setwidthProc 3 -1 roll load put {ct_MakeBoldFont}ct_Type0CopyFont definefont } { dup/_fauxfont known not 1 index/SubstMaster known not and { ct_BoldBaseFont/_setwidthProc /ct_BoldRomanWidthProc load put ct_MakeBoldFont } { 2 index 2 index eq {exch pop } { dup length dict begin ct_CopyFont currentdict end definefont } ifelse } ifelse } ifelse pop pop pop setglobal }bind def /?str1 256 string def /?set { $SubstituteFont begin /$substituteFound false def /$fontname 1 index def /$doSmartSub false def end dup findfont $SubstituteFont begin $substituteFound {false} { dup/FontName known { dup/FontName get $fontname eq 1 index/DistillerFauxFont known not and /currentdistillerparams where {pop false 2 index isWidthsOnlyFont not and} if } {false} ifelse } ifelse exch pop /$doSmartSub true def end { 5 1 roll pop pop pop pop findfont } { 1 index findfont dup/FontType get 3 eq { 6 1 roll pop pop pop pop pop false } {pop true} ifelse { $SubstituteFont begin pop pop /$styleArray 1 index def /$regOrdering 2 index def pop pop 0 1 $styleArray length 1 sub { $styleArray exch get ct_StyleDicts $regOrdering 2 copy known { get exch 2 copy known not {pop/Default} if get dup type/nametype eq { ?str1 cvs length dup 1 add exch ?str1 exch(-)putinterval exch dup length exch ?str1 exch 3 index exch putinterval add ?str1 exch 0 exch getinterval cvn } { pop pop/Unknown } ifelse } { pop pop pop pop/Unknown } ifelse } for end findfont }if } ifelse currentglobal false setglobal 3 1 roll null copyfont definefont pop setglobal }bind def setpacking userdict/$SubstituteFont 25 dict put 1 dict begin /SubstituteFont dup $error exch 2 copy known {get} {pop pop{pop/Courier}bind} ifelse def /currentdistillerparams where dup { pop pop currentdistillerparams/CannotEmbedFontPolicy 2 copy known {get/Error eq} {pop pop false} ifelse } if not { countdictstack array dictstack 0 get begin userdict begin $SubstituteFont begin /$str 128 string def /$fontpat 128 string def /$slen 0 def /$sname null def /$match false def /$fontname null def /$substituteFound false def /$inVMIndex null def /$doSmartSub true def /$depth 0 def /$fontname null def /$italicangle 26.5 def /$dstack null def /$Strategies 10 dict dup begin /$Type3Underprint { currentglobal exch false setglobal 11 dict begin /UseFont exch $WMode 0 ne { dup length dict copy dup/WMode $WMode put /UseFont exch definefont } if def /FontName $fontname dup type/stringtype eq{cvn}if def /FontType 3 def /FontMatrix[.001 0 0 .001 0 0]def /Encoding 256 array dup 0 1 255{/.notdef put dup}for pop def /FontBBox[0 0 0 0]def /CCInfo 7 dict dup begin /cc null def /x 0 def /y 0 def end def /BuildChar { exch begin CCInfo begin 1 string dup 0 3 index put exch pop /cc exch def UseFont 1000 scalefont setfont cc stringwidth/y exch def/x exch def x y setcharwidth $SubstituteFont/$Strategy get/$Underprint get exec 0 0 moveto cc show x y moveto end end }bind def currentdict end exch setglobal }bind def /$GetaTint 2 dict dup begin /$BuildFont { dup/WMode known {dup/WMode get} {0} ifelse /$WMode exch def $fontname exch dup/FontName known { dup/FontName get dup type/stringtype eq{cvn}if } {/unnamedfont} ifelse exch Adobe_CoolType_Data/InVMDeepCopiedFonts get 1 index/FontName get known { pop Adobe_CoolType_Data/InVMDeepCopiedFonts get 1 index get null copyfont } {$deepcopyfont} ifelse exch 1 index exch/FontBasedOn exch put dup/FontName $fontname dup type/stringtype eq{cvn}if put definefont Adobe_CoolType_Data/InVMDeepCopiedFonts get begin dup/FontBasedOn get 1 index def end }bind def /$Underprint { gsave x abs y abs gt {/y 1000 def} {/x -1000 def 500 120 translate} ifelse Level2? { [/Separation(All)/DeviceCMYK{0 0 0 1 pop}] setcolorspace } {0 setgray} ifelse 10 setlinewidth x .8 mul [7 3] { y mul 8 div 120 sub x 10 div exch moveto 0 y 4 div neg rlineto dup 0 rlineto 0 y 4 div rlineto closepath gsave Level2? {.2 setcolor} {.8 setgray} ifelse fill grestore stroke } forall pop grestore }bind def end def /$Oblique 1 dict dup begin /$BuildFont { currentglobal exch dup gcheck setglobal null copyfont begin /FontBasedOn currentdict/FontName known { FontName dup type/stringtype eq{cvn}if } {/unnamedfont} ifelse def /FontName $fontname dup type/stringtype eq{cvn}if def /currentdistillerparams where {pop} { /FontInfo currentdict/FontInfo known {FontInfo null copyfont} {2 dict} ifelse dup begin /ItalicAngle $italicangle def /FontMatrix FontMatrix [1 0 ItalicAngle dup sin exch cos div 1 0 0] matrix concatmatrix readonly end 4 2 roll def def } ifelse FontName currentdict end definefont exch setglobal }bind def end def /$None 1 dict dup begin /$BuildFont{}bind def end def end def /$Oblique SetSubstituteStrategy /$findfontByEnum { dup type/stringtype eq{cvn}if dup/$fontname exch def $sname null eq {$str cvs dup length $slen sub $slen getinterval} {pop $sname} ifelse $fontpat dup 0(fonts/*)putinterval exch 7 exch putinterval /$match false def $SubstituteFont/$dstack countdictstack array dictstack put mark { $fontpat 0 $slen 7 add getinterval {/$match exch def exit} $str filenameforall } stopped { cleardictstack currentdict true $SubstituteFont/$dstack get { exch { 1 index eq {pop false} {true} ifelse } {begin false} ifelse } forall pop } if cleartomark /$slen 0 def $match false ne {$match(fonts/)anchorsearch pop pop cvn} {/Courier} ifelse }bind def /$ROS 1 dict dup begin /Adobe 4 dict dup begin /Japan1 [/Ryumin-Light/HeiseiMin-W3 /GothicBBB-Medium/HeiseiKakuGo-W5 /HeiseiMaruGo-W4/Jun101-Light]def /Korea1 [/HYSMyeongJo-Medium/HYGoThic-Medium]def /GB1 [/STSong-Light/STHeiti-Regular]def /CNS1 [/MKai-Medium/MHei-Medium]def end def end def /$cmapname null def /$deepcopyfont { dup/FontType get 0 eq { 1 dict dup/FontName/copied put copyfont begin /FDepVector FDepVector copyarray 0 1 2 index length 1 sub { 2 copy get $deepcopyfont dup/FontName/copied put /copied exch definefont 3 copy put pop pop } for def currentdict end } {$Strategies/$Type3Underprint get exec} ifelse }bind def /$buildfontname { dup/CIDFont findresource/CIDSystemInfo get begin Registry length Ordering length Supplement 8 string cvs 3 copy length 2 add add add string dup 5 1 roll dup 0 Registry putinterval dup 4 index(-)putinterval dup 4 index 1 add Ordering putinterval 4 2 roll add 1 add 2 copy(-)putinterval end 1 add 2 copy 0 exch getinterval $cmapname $fontpat cvs exch anchorsearch {pop pop 3 2 roll putinterval cvn/$cmapname exch def} {pop pop pop pop pop} ifelse length $str 1 index(-)putinterval 1 add $str 1 index $cmapname $fontpat cvs putinterval $cmapname length add $str exch 0 exch getinterval cvn }bind def /$findfontByROS { /$fontname exch def $ROS Registry 2 copy known { get Ordering 2 copy known {get} {pop pop[]} ifelse } {pop pop[]} ifelse false exch { dup/CIDFont resourcestatus { pop pop save 1 index/CIDFont findresource dup/WidthsOnly known {dup/WidthsOnly get} {false} ifelse exch pop exch restore {pop} {exch pop true exit} ifelse } {pop} ifelse } forall {$str cvs $buildfontname} { false(*) { save exch dup/CIDFont findresource dup/WidthsOnly known {dup/WidthsOnly get not} {true} ifelse exch/CIDSystemInfo get dup/Registry get Registry eq exch/Ordering get Ordering eq and and {exch restore exch pop true exit} {pop restore} ifelse } $str/CIDFont resourceforall {$buildfontname} {$fontname $findfontByEnum} ifelse } ifelse }bind def end end currentdict/$error known currentdict/languagelevel known and dup {pop $error/SubstituteFont known} if dup {$error} {Adobe_CoolType_Core} ifelse begin { /SubstituteFont /CMap/Category resourcestatus { pop pop { $SubstituteFont begin /$substituteFound true def dup length $slen gt $sname null ne or $slen 0 gt and { $sname null eq {dup $str cvs dup length $slen sub $slen getinterval cvn} {$sname} ifelse Adobe_CoolType_Data/InVMFontsByCMap get 1 index 2 copy known { get false exch { pop currentglobal { GlobalFontDirectory 1 index known {exch pop true exit} {pop} ifelse } { FontDirectory 1 index known {exch pop true exit} { GlobalFontDirectory 1 index known {exch pop true exit} {pop} ifelse } ifelse } ifelse } forall } {pop pop false} ifelse { exch pop exch pop } { dup/CMap resourcestatus { pop pop dup/$cmapname exch def /CMap findresource/CIDSystemInfo get{def}forall $findfontByROS } { 128 string cvs dup(-)search { 3 1 roll search { 3 1 roll pop {dup cvi} stopped {pop pop pop pop pop $findfontByEnum} { 4 2 roll pop pop exch length exch 2 index length 2 index sub exch 1 sub -1 0 { $str cvs dup length 4 index 0 4 index 4 3 roll add getinterval exch 1 index exch 3 index exch putinterval dup/CMap resourcestatus { pop pop 4 1 roll pop pop pop dup/$cmapname exch def /CMap findresource/CIDSystemInfo get{def}forall $findfontByROS true exit } {pop} ifelse } for dup type/booleantype eq {pop} {pop pop pop $findfontByEnum} ifelse } ifelse } {pop pop pop $findfontByEnum} ifelse } {pop pop $findfontByEnum} ifelse } ifelse } ifelse } {//SubstituteFont exec} ifelse /$slen 0 def end } } { { $SubstituteFont begin /$substituteFound true def dup length $slen gt $sname null ne or $slen 0 gt and {$findfontByEnum} {//SubstituteFont exec} ifelse end } } ifelse bind readonly def Adobe_CoolType_Core/scfindfont/systemfindfont load put } { /scfindfont { $SubstituteFont begin dup systemfindfont dup/FontName known {dup/FontName get dup 3 index ne} {/noname true} ifelse dup { /$origfontnamefound 2 index def /$origfontname 4 index def/$substituteFound true def } if exch pop { $slen 0 gt $sname null ne 3 index length $slen gt or and { pop dup $findfontByEnum findfont dup maxlength 1 add dict begin {1 index/FID eq{pop pop}{def}ifelse} forall currentdict end definefont dup/FontName known{dup/FontName get}{null}ifelse $origfontnamefound ne { $origfontname $str cvs print ( substitution revised, using )print dup/FontName known {dup/FontName get}{(unspecified font)} ifelse $str cvs print(.\n)print } if } {exch pop} ifelse } {exch pop} ifelse end }bind def } ifelse end end Adobe_CoolType_Core_Defined not { Adobe_CoolType_Core/findfont { $SubstituteFont begin $depth 0 eq { /$fontname 1 index dup type/stringtype ne{$str cvs}if def /$substituteFound false def } if /$depth $depth 1 add def end scfindfont $SubstituteFont begin /$depth $depth 1 sub def $substituteFound $depth 0 eq and { $inVMIndex null ne {dup $inVMIndex $AddInVMFont} if $doSmartSub { currentdict/$Strategy known {$Strategy/$BuildFont get exec} if } if } if end }bind put } if } if end /$AddInVMFont { exch/FontName 2 copy known { get 1 dict dup begin exch 1 index gcheck def end exch Adobe_CoolType_Data/InVMFontsByCMap get exch $DictAdd } {pop pop pop} ifelse }bind def /$DictAdd { 2 copy known not {2 copy 4 index length dict put} if Level2? not { 2 copy get dup maxlength exch length 4 index length add lt 2 copy get dup length 4 index length add exch maxlength 1 index lt { 2 mul dict begin 2 copy get{forall}def 2 copy currentdict put end } {pop} ifelse } if get begin {def} forall end }bind def end end %%EndResource currentglobal true setglobal %%BeginResource: procset Adobe_CoolType_Utility_MAKEOCF 1.23 0 %%Copyright: Copyright 1987-2006 Adobe Systems Incorporated. %%Version: 1.23 0 systemdict/languagelevel known dup {currentglobal false setglobal} {false} ifelse exch userdict/Adobe_CoolType_Utility 2 copy known {2 copy get dup maxlength 27 add dict copy} {27 dict} ifelse put Adobe_CoolType_Utility begin /@eexecStartData def /@recognizeCIDFont null def /ct_Level2? exch def /ct_Clone? 1183615869 internaldict dup /CCRun known not exch/eCCRun known not ct_Level2? and or def ct_Level2? {globaldict begin currentglobal true setglobal} if /ct_AddStdCIDMap ct_Level2? {{ mark Adobe_CoolType_Utility/@recognizeCIDFont currentdict put { ((Hex)57 StartData 0615 1e27 2c39 1c60 d8a8 cc31 fe2b f6e0 7aa3 e541 e21c 60d8 a8c9 c3d0 6d9e 1c60 d8a8 c9c2 02d7 9a1c 60d8 a849 1c60 d8a8 cc36 74f4 1144 b13b 77)0()/SubFileDecode filter cvx exec } stopped { cleartomark Adobe_CoolType_Utility/@recognizeCIDFont get countdictstack dup array dictstack exch 1 sub -1 0 { 2 copy get 3 index eq {1 index length exch sub 1 sub{end}repeat exit} {pop} ifelse } for pop pop Adobe_CoolType_Utility/@eexecStartData get eexec } {cleartomark} ifelse }} {{ Adobe_CoolType_Utility/@eexecStartData get eexec }} ifelse bind def userdict/cid_extensions known dup{cid_extensions/cid_UpdateDB known and}if { cid_extensions begin /cid_GetCIDSystemInfo { 1 index type/stringtype eq {exch cvn exch} if cid_extensions begin dup load 2 index known { 2 copy cid_GetStatusInfo dup null ne { 1 index load 3 index get dup null eq {pop pop cid_UpdateDB} { exch 1 index/Created get eq {exch pop exch pop} {pop cid_UpdateDB} ifelse } ifelse } {pop cid_UpdateDB} ifelse } {cid_UpdateDB} ifelse end }bind def end } if ct_Level2? {end setglobal} if /ct_UseNativeCapability? systemdict/composefont known def /ct_MakeOCF 35 dict def /ct_Vars 25 dict def /ct_GlyphDirProcs 6 dict def /ct_BuildCharDict 15 dict dup begin /charcode 2 string def /dst_string 1500 string def /nullstring()def /usewidths? true def end def ct_Level2?{setglobal}{pop}ifelse ct_GlyphDirProcs begin /GetGlyphDirectory { systemdict/languagelevel known {pop/CIDFont findresource/GlyphDirectory get} { 1 index/CIDFont findresource/GlyphDirectory get dup type/dicttype eq { dup dup maxlength exch length sub 2 index lt { dup length 2 index add dict copy 2 index /CIDFont findresource/GlyphDirectory 2 index put } if } if exch pop exch pop } ifelse + }def /+ { systemdict/languagelevel known { currentglobal false setglobal 3 dict begin /vm exch def } {1 dict begin} ifelse /$ exch def systemdict/languagelevel known { vm setglobal /gvm currentglobal def $ gcheck setglobal } if ?{$ begin}if }def /?{$ type/dicttype eq}def /|{ userdict/Adobe_CoolType_Data known { Adobe_CoolType_Data/AddWidths? known { currentdict Adobe_CoolType_Data begin begin AddWidths? { Adobe_CoolType_Data/CC 3 index put ?{def}{$ 3 1 roll put}ifelse CC charcode exch 1 index 0 2 index 256 idiv put 1 index exch 1 exch 256 mod put stringwidth 2 array astore currentfont/Widths get exch CC exch put } {?{def}{$ 3 1 roll put}ifelse} ifelse end end } {?{def}{$ 3 1 roll put}ifelse} ifelse } {?{def}{$ 3 1 roll put}ifelse} ifelse }def /! { ?{end}if systemdict/languagelevel known {gvm setglobal} if end }def /:{string currentfile exch readstring pop}executeonly def end ct_MakeOCF begin /ct_cHexEncoding [/c00/c01/c02/c03/c04/c05/c06/c07/c08/c09/c0A/c0B/c0C/c0D/c0E/c0F/c10/c11/c12 /c13/c14/c15/c16/c17/c18/c19/c1A/c1B/c1C/c1D/c1E/c1F/c20/c21/c22/c23/c24/c25 /c26/c27/c28/c29/c2A/c2B/c2C/c2D/c2E/c2F/c30/c31/c32/c33/c34/c35/c36/c37/c38 /c39/c3A/c3B/c3C/c3D/c3E/c3F/c40/c41/c42/c43/c44/c45/c46/c47/c48/c49/c4A/c4B /c4C/c4D/c4E/c4F/c50/c51/c52/c53/c54/c55/c56/c57/c58/c59/c5A/c5B/c5C/c5D/c5E /c5F/c60/c61/c62/c63/c64/c65/c66/c67/c68/c69/c6A/c6B/c6C/c6D/c6E/c6F/c70/c71 /c72/c73/c74/c75/c76/c77/c78/c79/c7A/c7B/c7C/c7D/c7E/c7F/c80/c81/c82/c83/c84 /c85/c86/c87/c88/c89/c8A/c8B/c8C/c8D/c8E/c8F/c90/c91/c92/c93/c94/c95/c96/c97 /c98/c99/c9A/c9B/c9C/c9D/c9E/c9F/cA0/cA1/cA2/cA3/cA4/cA5/cA6/cA7/cA8/cA9/cAA /cAB/cAC/cAD/cAE/cAF/cB0/cB1/cB2/cB3/cB4/cB5/cB6/cB7/cB8/cB9/cBA/cBB/cBC/cBD /cBE/cBF/cC0/cC1/cC2/cC3/cC4/cC5/cC6/cC7/cC8/cC9/cCA/cCB/cCC/cCD/cCE/cCF/cD0 /cD1/cD2/cD3/cD4/cD5/cD6/cD7/cD8/cD9/cDA/cDB/cDC/cDD/cDE/cDF/cE0/cE1/cE2/cE3 /cE4/cE5/cE6/cE7/cE8/cE9/cEA/cEB/cEC/cED/cEE/cEF/cF0/cF1/cF2/cF3/cF4/cF5/cF6 /cF7/cF8/cF9/cFA/cFB/cFC/cFD/cFE/cFF]def /ct_CID_STR_SIZE 8000 def /ct_mkocfStr100 100 string def /ct_defaultFontMtx[.001 0 0 .001 0 0]def /ct_1000Mtx[1000 0 0 1000 0 0]def /ct_raise{exch cvx exch errordict exch get exec stop}bind def /ct_reraise {cvx $error/errorname get(Error: )print dup( )cvs print errordict exch get exec stop }bind def /ct_cvnsi { 1 index add 1 sub 1 exch 0 4 1 roll { 2 index exch get exch 8 bitshift add } for exch pop }bind def /ct_GetInterval { Adobe_CoolType_Utility/ct_BuildCharDict get begin /dst_index 0 def dup dst_string length gt {dup string/dst_string exch def} if 1 index ct_CID_STR_SIZE idiv /arrayIndex exch def 2 index arrayIndex get 2 index arrayIndex ct_CID_STR_SIZE mul sub { dup 3 index add 2 index length le { 2 index getinterval dst_string dst_index 2 index putinterval length dst_index add/dst_index exch def exit } { 1 index length 1 index sub dup 4 1 roll getinterval dst_string dst_index 2 index putinterval pop dup dst_index add/dst_index exch def sub /arrayIndex arrayIndex 1 add def 2 index dup length arrayIndex gt {arrayIndex get} { pop exit } ifelse 0 } ifelse } loop pop pop pop dst_string 0 dst_index getinterval end }bind def ct_Level2? { /ct_resourcestatus currentglobal mark true setglobal {/unknowninstancename/Category resourcestatus} stopped {cleartomark setglobal true} {cleartomark currentglobal not exch setglobal} ifelse { { mark 3 1 roll/Category findresource begin ct_Vars/vm currentglobal put ({ResourceStatus}stopped)0()/SubFileDecode filter cvx exec {cleartomark false} {{3 2 roll pop true}{cleartomark false}ifelse} ifelse ct_Vars/vm get setglobal end } } {{resourcestatus}} ifelse bind def /CIDFont/Category ct_resourcestatus {pop pop} { currentglobal true setglobal /Generic/Category findresource dup length dict copy dup/InstanceType/dicttype put /CIDFont exch/Category defineresource pop setglobal } ifelse ct_UseNativeCapability? { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering(Identity)def /Supplement 0 def end def /CMapName/Identity-H def /CMapVersion 1.000 def /CMapType 1 def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } if } { /ct_Category 2 dict begin /CIDFont 10 dict def /ProcSet 2 dict def currentdict end def /defineresource { ct_Category 1 index 2 copy known { get dup dup maxlength exch length eq { dup length 10 add dict copy ct_Category 2 index 2 index put } if 3 index 3 index put pop exch pop } {pop pop/defineresource/undefined ct_raise} ifelse }bind def /findresource { ct_Category 1 index 2 copy known { get 2 index 2 copy known {get 3 1 roll pop pop} {pop pop/findresource/undefinedresource ct_raise} ifelse } {pop pop/findresource/undefined ct_raise} ifelse }bind def /resourcestatus { ct_Category 1 index 2 copy known { get 2 index known exch pop exch pop { 0 -1 true } { false } ifelse } {pop pop/findresource/undefined ct_raise} ifelse }bind def /ct_resourcestatus/resourcestatus load def } ifelse /ct_CIDInit 2 dict begin /ct_cidfont_stream_init { { dup(Binary)eq { pop null currentfile ct_Level2? { {cid_BYTE_COUNT()/SubFileDecode filter} stopped {pop pop pop} if } if /readstring load exit } if dup(Hex)eq { pop currentfile ct_Level2? { {null exch/ASCIIHexDecode filter/readstring} stopped {pop exch pop(>)exch/readhexstring} if } {(>)exch/readhexstring} ifelse load exit } if /StartData/typecheck ct_raise } loop cid_BYTE_COUNT ct_CID_STR_SIZE le { 2 copy cid_BYTE_COUNT string exch exec pop 1 array dup 3 -1 roll 0 exch put } { cid_BYTE_COUNT ct_CID_STR_SIZE div ceiling cvi dup array exch 2 sub 0 exch 1 exch { 2 copy 5 index ct_CID_STR_SIZE string 6 index exec pop put pop } for 2 index cid_BYTE_COUNT ct_CID_STR_SIZE mod string 3 index exec pop 1 index exch 1 index length 1 sub exch put } ifelse cid_CIDFONT exch/GlyphData exch put 2 index null eq { pop pop pop } { pop/readstring load 1 string exch { 3 copy exec pop dup length 0 eq { pop pop pop pop pop true exit } if 4 index eq { pop pop pop pop false exit } if } loop pop } ifelse }bind def /StartData { mark { currentdict dup/FDArray get 0 get/FontMatrix get 0 get 0.001 eq { dup/CDevProc known not { /CDevProc 1183615869 internaldict/stdCDevProc 2 copy known {get} { pop pop {pop pop pop pop pop 0 -1000 7 index 2 div 880} } ifelse def } if } { /CDevProc { pop pop pop pop pop 0 1 cid_temp/cid_CIDFONT get /FDArray get 0 get /FontMatrix get 0 get div 7 index 2 div 1 index 0.88 mul }def } ifelse /cid_temp 15 dict def cid_temp begin /cid_CIDFONT exch def 3 copy pop dup/cid_BYTE_COUNT exch def 0 gt { ct_cidfont_stream_init FDArray { /Private get dup/SubrMapOffset known { begin /Subrs SubrCount array def Subrs SubrMapOffset SubrCount SDBytes ct_Level2? { currentdict dup/SubrMapOffset undef dup/SubrCount undef /SDBytes undef } if end /cid_SD_BYTES exch def /cid_SUBR_COUNT exch def /cid_SUBR_MAP_OFFSET exch def /cid_SUBRS exch def cid_SUBR_COUNT 0 gt { GlyphData cid_SUBR_MAP_OFFSET cid_SD_BYTES ct_GetInterval 0 cid_SD_BYTES ct_cvnsi 0 1 cid_SUBR_COUNT 1 sub { exch 1 index 1 add cid_SD_BYTES mul cid_SUBR_MAP_OFFSET add GlyphData exch cid_SD_BYTES ct_GetInterval 0 cid_SD_BYTES ct_cvnsi cid_SUBRS 4 2 roll GlyphData exch 4 index 1 index sub ct_GetInterval dup length string copy put } for pop } if } {pop} ifelse } forall } if cleartomark pop pop end CIDFontName currentdict/CIDFont defineresource pop end end } stopped {cleartomark/StartData ct_reraise} if }bind def currentdict end def /ct_saveCIDInit { /CIDInit/ProcSet ct_resourcestatus {true} {/CIDInitC/ProcSet ct_resourcestatus} ifelse { pop pop /CIDInit/ProcSet findresource ct_UseNativeCapability? {pop null} {/CIDInit ct_CIDInit/ProcSet defineresource pop} ifelse } {/CIDInit ct_CIDInit/ProcSet defineresource pop null} ifelse ct_Vars exch/ct_oldCIDInit exch put }bind def /ct_restoreCIDInit { ct_Vars/ct_oldCIDInit get dup null ne {/CIDInit exch/ProcSet defineresource pop} {pop} ifelse }bind def /ct_BuildCharSetUp { 1 index begin CIDFont begin Adobe_CoolType_Utility/ct_BuildCharDict get begin /ct_dfCharCode exch def /ct_dfDict exch def CIDFirstByte ct_dfCharCode add dup CIDCount ge {pop 0} if /cid exch def { GlyphDirectory cid 2 copy known {get} {pop pop nullstring} ifelse dup length FDBytes sub 0 gt { dup FDBytes 0 ne {0 FDBytes ct_cvnsi} {pop 0} ifelse /fdIndex exch def dup length FDBytes sub FDBytes exch getinterval /charstring exch def exit } { pop cid 0 eq {/charstring nullstring def exit} if /cid 0 def } ifelse } loop }def /ct_SetCacheDevice { 0 0 moveto dup stringwidth 3 -1 roll true charpath pathbbox 0 -1000 7 index 2 div 880 setcachedevice2 0 0 moveto }def /ct_CloneSetCacheProc { 1 eq { stringwidth pop -2 div -880 0 -1000 setcharwidth moveto } { usewidths? { currentfont/Widths get cid 2 copy known {get exch pop aload pop} {pop pop stringwidth} ifelse } {stringwidth} ifelse setcharwidth 0 0 moveto } ifelse }def /ct_Type3ShowCharString { ct_FDDict fdIndex 2 copy known {get} { currentglobal 3 1 roll 1 index gcheck setglobal ct_Type1FontTemplate dup maxlength dict copy begin FDArray fdIndex get dup/FontMatrix 2 copy known {get} {pop pop ct_defaultFontMtx} ifelse /FontMatrix exch dup length array copy def /Private get /Private exch def /Widths rootfont/Widths get def /CharStrings 1 dict dup/.notdef dup length string copy put def currentdict end /ct_Type1Font exch definefont dup 5 1 roll put setglobal } ifelse dup/CharStrings get 1 index/Encoding get ct_dfCharCode get charstring put rootfont/WMode 2 copy known {get} {pop pop 0} ifelse exch 1000 scalefont setfont ct_str1 0 ct_dfCharCode put ct_str1 exch ct_dfSetCacheProc ct_SyntheticBold { currentpoint ct_str1 show newpath moveto ct_str1 true charpath ct_StrokeWidth setlinewidth stroke } {ct_str1 show} ifelse }def /ct_Type4ShowCharString { ct_dfDict ct_dfCharCode charstring FDArray fdIndex get dup/FontMatrix get dup ct_defaultFontMtx ct_matrixeq not {ct_1000Mtx matrix concatmatrix concat} {pop} ifelse /Private get Adobe_CoolType_Utility/ct_Level2? get not { ct_dfDict/Private 3 -1 roll {put} 1183615869 internaldict/superexec get exec } if 1183615869 internaldict Adobe_CoolType_Utility/ct_Level2? get {1 index} {3 index/Private get mark 6 1 roll} ifelse dup/RunInt known {/RunInt get} {pop/CCRun} ifelse get exec Adobe_CoolType_Utility/ct_Level2? get not {cleartomark} if }bind def /ct_BuildCharIncremental { { Adobe_CoolType_Utility/ct_MakeOCF get begin ct_BuildCharSetUp ct_ShowCharString } stopped {stop} if end end end end }bind def /BaseFontNameStr(BF00)def /ct_Type1FontTemplate 14 dict begin /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0]def /FontBBox [-250 -250 1250 1250]def /Encoding ct_cHexEncoding def /PaintType 0 def currentdict end def /BaseFontTemplate 11 dict begin /FontMatrix [0.001 0 0 0.001 0 0]def /FontBBox [-250 -250 1250 1250]def /Encoding ct_cHexEncoding def /BuildChar/ct_BuildCharIncremental load def ct_Clone? { /FontType 3 def /ct_ShowCharString/ct_Type3ShowCharString load def /ct_dfSetCacheProc/ct_CloneSetCacheProc load def /ct_SyntheticBold false def /ct_StrokeWidth 1 def } { /FontType 4 def /Private 1 dict dup/lenIV 4 put def /CharStrings 1 dict dup/.notdefput def /PaintType 0 def /ct_ShowCharString/ct_Type4ShowCharString load def } ifelse /ct_str1 1 string def currentdict end def /BaseFontDictSize BaseFontTemplate length 5 add def /ct_matrixeq { true 0 1 5 { dup 4 index exch get exch 3 index exch get eq and dup not {exit} if } for exch pop exch pop }bind def /ct_makeocf { 15 dict begin exch/WMode exch def exch/FontName exch def /FontType 0 def /FMapType 2 def dup/FontMatrix known {dup/FontMatrix get/FontMatrix exch def} {/FontMatrix matrix def} ifelse /bfCount 1 index/CIDCount get 256 idiv 1 add dup 256 gt{pop 256}if def /Encoding 256 array 0 1 bfCount 1 sub{2 copy dup put pop}for bfCount 1 255{2 copy bfCount put pop}for def /FDepVector bfCount dup 256 lt{1 add}if array def BaseFontTemplate BaseFontDictSize dict copy begin /CIDFont exch def CIDFont/FontBBox known {CIDFont/FontBBox get/FontBBox exch def} if CIDFont/CDevProc known {CIDFont/CDevProc get/CDevProc exch def} if currentdict end BaseFontNameStr 3(0)putinterval 0 1 bfCount dup 256 eq{1 sub}if { FDepVector exch 2 index BaseFontDictSize dict copy begin dup/CIDFirstByte exch 256 mul def FontType 3 eq {/ct_FDDict 2 dict def} if currentdict end 1 index 16 BaseFontNameStr 2 2 getinterval cvrs pop BaseFontNameStr exch definefont put } for ct_Clone? {/Widths 1 index/CIDFont get/GlyphDirectory get length dict def} if FontName currentdict end definefont ct_Clone? { gsave dup 1000 scalefont setfont ct_BuildCharDict begin /usewidths? false def currentfont/Widths get begin exch/CIDFont get/GlyphDirectory get { pop dup charcode exch 1 index 0 2 index 256 idiv put 1 index exch 1 exch 256 mod put stringwidth 2 array astore def } forall end /usewidths? true def end grestore } {exch pop} ifelse }bind def currentglobal true setglobal /ct_ComposeFont { ct_UseNativeCapability? { 2 index/CMap ct_resourcestatus {pop pop exch pop} { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CMapName 3 index def /CMapVersion 1.000 def /CMapType 1 def exch/WMode exch def /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering CMapName ct_mkocfStr100 cvs (Adobe-)search { pop pop (-)search { dup length string copy exch pop exch pop } {pop(Identity)} ifelse } {pop (Identity)} ifelse def /Supplement 0 def end def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } ifelse composefont } { 3 2 roll pop 0 get/CIDFont findresource ct_makeocf } ifelse }bind def setglobal /ct_MakeIdentity { ct_UseNativeCapability? { 1 index/CMap ct_resourcestatus {pop pop} { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CMapName 2 index def /CMapVersion 1.000 def /CMapType 1 def /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering CMapName ct_mkocfStr100 cvs (Adobe-)search { pop pop (-)search {dup length string copy exch pop exch pop} {pop(Identity)} ifelse } {pop(Identity)} ifelse def /Supplement 0 def end def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } ifelse composefont } { exch pop 0 get/CIDFont findresource ct_makeocf } ifelse }bind def currentdict readonly pop end end %%EndResource setglobal %%BeginResource: procset Adobe_CoolType_Utility_T42 1.0 0 %%Copyright: Copyright 1987-2004 Adobe Systems Incorporated. %%Version: 1.0 0 userdict/ct_T42Dict 15 dict put ct_T42Dict begin /Is2015? { version cvi 2015 ge }bind def /AllocGlyphStorage { Is2015? { pop } { {string}forall }ifelse }bind def /Type42DictBegin { 25 dict begin /FontName exch def /CharStrings 256 dict begin /.notdef 0 def currentdict end def /Encoding exch def /PaintType 0 def /FontType 42 def /FontMatrix[1 0 0 1 0 0]def 4 array astore cvx/FontBBox exch def /sfnts }bind def /Type42DictEnd { currentdict dup/FontName get exch definefont end ct_T42Dict exch dup/FontName get exch put }bind def /RD{string currentfile exch readstring pop}executeonly def /PrepFor2015 { Is2015? { /GlyphDirectory 16 dict def sfnts 0 get dup 2 index (glyx) putinterval 2 index (locx) putinterval pop pop } { pop pop }ifelse }bind def /AddT42Char { Is2015? { /GlyphDirectory get begin def end pop pop } { /sfnts get 4 index get 3 index 2 index putinterval pop pop pop pop }ifelse }bind def /T0AddT42Mtx2 { /CIDFont findresource/Metrics2 get begin def end }bind def end %%EndResource currentglobal true setglobal %%BeginFile: MMFauxFont.prc %%Copyright: Copyright 1987-2001 Adobe Systems Incorporated. %%All Rights Reserved. userdict /ct_EuroDict 10 dict put ct_EuroDict begin /ct_CopyFont { { 1 index /FID ne {def} {pop pop} ifelse} forall } def /ct_GetGlyphOutline { gsave initmatrix newpath exch findfont dup length 1 add dict begin ct_CopyFont /Encoding Encoding dup length array copy dup 4 -1 roll 0 exch put def currentdict end /ct_EuroFont exch definefont 1000 scalefont setfont 0 0 moveto [ <00> stringwidth <00> false charpath pathbbox [ {/m cvx} {/l cvx} {/c cvx} {/cp cvx} pathforall grestore counttomark 8 add } def /ct_MakeGlyphProc { ] cvx /ct_PSBuildGlyph cvx ] cvx } def /ct_PSBuildGlyph { gsave 8 -1 roll pop 7 1 roll 6 -2 roll ct_FontMatrix transform 6 2 roll 4 -2 roll ct_FontMatrix transform 4 2 roll ct_FontMatrix transform currentdict /PaintType 2 copy known {get 2 eq}{pop pop false} ifelse dup 9 1 roll { currentdict /StrokeWidth 2 copy known { get 2 div 0 ct_FontMatrix dtransform pop 5 1 roll 4 -1 roll 4 index sub 4 1 roll 3 -1 roll 4 index sub 3 1 roll exch 4 index add exch 4 index add 5 -1 roll pop } { pop pop } ifelse } if setcachedevice ct_FontMatrix concat ct_PSPathOps begin exec end { currentdict /StrokeWidth 2 copy known { get } { pop pop 0 } ifelse setlinewidth stroke } { fill } ifelse grestore } def /ct_PSPathOps 4 dict dup begin /m {moveto} def /l {lineto} def /c {curveto} def /cp {closepath} def end def /ct_matrix1000 [1000 0 0 1000 0 0] def /ct_AddGlyphProc { 2 index findfont dup length 4 add dict begin ct_CopyFont /CharStrings CharStrings dup length 1 add dict copy begin 3 1 roll def currentdict end def /ct_FontMatrix ct_matrix1000 FontMatrix matrix concatmatrix def /ct_PSBuildGlyph /ct_PSBuildGlyph load def /ct_PSPathOps /ct_PSPathOps load def currentdict end definefont pop } def systemdict /languagelevel known { /ct_AddGlyphToPrinterFont { 2 copy ct_GetGlyphOutline 3 add -1 roll restore ct_MakeGlyphProc ct_AddGlyphProc } def } { /ct_AddGlyphToPrinterFont { pop pop restore Adobe_CTFauxDict /$$$FONTNAME get /Euro Adobe_CTFauxDict /$$$SUBSTITUTEBASE get ct_EuroDict exch get ct_AddGlyphProc } def } ifelse /AdobeSansMM { 556 0 24 -19 541 703 { 541 628 m 510 669 442 703 354 703 c 201 703 117 607 101 444 c 50 444 l 25 372 l 97 372 l 97 301 l 49 301 l 24 229 l 103 229 l 124 67 209 -19 350 -19 c 435 -19 501 25 509 32 c 509 131 l 492 105 417 60 343 60 c 267 60 204 127 197 229 c 406 229 l 430 301 l 191 301 l 191 372 l 455 372 l 479 444 l 194 444 l 201 531 245 624 348 624 c 433 624 484 583 509 534 c cp 556 0 m } ct_PSBuildGlyph } def /AdobeSerifMM { 500 0 10 -12 484 692 { 347 298 m 171 298 l 170 310 170 322 170 335 c 170 362 l 362 362 l 374 403 l 172 403 l 184 580 244 642 308 642 c 380 642 434 574 457 457 c 481 462 l 474 691 l 449 691 l 433 670 429 657 410 657 c 394 657 360 692 299 692 c 204 692 94 604 73 403 c 22 403 l 10 362 l 70 362 l 69 352 69 341 69 330 c 69 319 69 308 70 298 c 22 298 l 10 257 l 73 257 l 97 57 216 -12 295 -12 c 364 -12 427 25 484 123 c 458 142 l 425 101 384 37 316 37 c 256 37 189 84 173 257 c 335 257 l cp 500 0 m } ct_PSBuildGlyph } def end %%EndFile setglobal Adobe_CoolType_Core begin /$Oblique SetSubstituteStrategy end %%BeginResource: procset Adobe_AGM_Image 1.0 0 +%%Version: 1.0 0 +%%Copyright: Copyright(C)2000-2006 Adobe Systems, Inc. All Rights Reserved. +systemdict/setpacking known +{ + currentpacking + true setpacking +}if +userdict/Adobe_AGM_Image 71 dict dup begin put +/Adobe_AGM_Image_Id/Adobe_AGM_Image_1.0_0 def +/nd{ + null def +}bind def +/AGMIMG_&image nd +/AGMIMG_&colorimage nd +/AGMIMG_&imagemask nd +/AGMIMG_mbuf()def +/AGMIMG_ybuf()def +/AGMIMG_kbuf()def +/AGMIMG_c 0 def +/AGMIMG_m 0 def +/AGMIMG_y 0 def +/AGMIMG_k 0 def +/AGMIMG_tmp nd +/AGMIMG_imagestring0 nd +/AGMIMG_imagestring1 nd +/AGMIMG_imagestring2 nd +/AGMIMG_imagestring3 nd +/AGMIMG_imagestring4 nd +/AGMIMG_imagestring5 nd +/AGMIMG_cnt nd +/AGMIMG_fsave nd +/AGMIMG_colorAry nd +/AGMIMG_override nd +/AGMIMG_name nd +/AGMIMG_maskSource nd +/AGMIMG_flushfilters nd +/invert_image_samples nd +/knockout_image_samples nd +/img nd +/sepimg nd +/devnimg nd +/idximg nd +/ds +{ + Adobe_AGM_Core begin + Adobe_AGM_Image begin + /AGMIMG_&image systemdict/image get def + /AGMIMG_&imagemask systemdict/imagemask get def + /colorimage where{ + pop + /AGMIMG_&colorimage/colorimage ldf + }if + end + end +}def +/ps +{ + Adobe_AGM_Image begin + /AGMIMG_ccimage_exists{/customcolorimage where + { + pop + /Adobe_AGM_OnHost_Seps where + { + pop false + }{ + /Adobe_AGM_InRip_Seps where + { + pop false + }{ + true + }ifelse + }ifelse + }{ + false + }ifelse + }bdf + level2{ + /invert_image_samples + { + Adobe_AGM_Image/AGMIMG_tmp Decode length ddf + /Decode[Decode 1 get Decode 0 get]def + }def + /knockout_image_samples + { + Operator/imagemask ne{ + /Decode[1 1]def + }if + }def + }{ + /invert_image_samples + { + {1 exch sub}currenttransfer addprocs settransfer + }def + /knockout_image_samples + { + {pop 1}currenttransfer addprocs settransfer + }def + }ifelse + /img/imageormask ldf + /sepimg/sep_imageormask ldf + /devnimg/devn_imageormask ldf + /idximg/indexed_imageormask ldf + /_ctype 7 def + currentdict{ + dup xcheck 1 index type dup/arraytype eq exch/packedarraytype eq or and{ + bind + }if + def + }forall +}def +/pt +{ + end +}def +/dt +{ +}def +/AGMIMG_flushfilters +{ + dup type/arraytype ne + {1 array astore}if + dup 0 get currentfile ne + {dup 0 get flushfile}if + { + dup type/filetype eq + { + dup status 1 index currentfile ne and + {closefile} + {pop} + ifelse + }{pop}ifelse + }forall +}def +/AGMIMG_init_common +{ + currentdict/T known{/ImageType/T ldf currentdict/T undef}if + currentdict/W known{/Width/W ldf currentdict/W undef}if + currentdict/H known{/Height/H ldf currentdict/H undef}if + currentdict/M known{/ImageMatrix/M ldf currentdict/M undef}if + currentdict/BC known{/BitsPerComponent/BC ldf currentdict/BC undef}if + currentdict/D known{/Decode/D ldf currentdict/D undef}if + currentdict/DS known{/DataSource/DS ldf currentdict/DS undef}if + currentdict/O known{ + /Operator/O load 1 eq{ + /imagemask + }{ + /O load 2 eq{ + /image + }{ + /colorimage + }ifelse + }ifelse + def + currentdict/O undef + }if + currentdict/HSCI known{/HostSepColorImage/HSCI ldf currentdict/HSCI undef}if + currentdict/MD known{/MultipleDataSources/MD ldf currentdict/MD undef}if + currentdict/I known{/Interpolate/I ldf currentdict/I undef}if + currentdict/SI known{/SkipImageProc/SI ldf currentdict/SI undef}if + /DataSource load xcheck not{ + DataSource type/arraytype eq{ + DataSource 0 get type/filetype eq{ + /_Filters DataSource def + currentdict/MultipleDataSources known not{ + /DataSource DataSource dup length 1 sub get def + }if + }if + }if + currentdict/MultipleDataSources known not{ + /MultipleDataSources DataSource type/arraytype eq{ + DataSource length 1 gt + } + {false}ifelse def + }if + }if + /NComponents Decode length 2 div def + currentdict/SkipImageProc known not{/SkipImageProc{false}def}if +}bdf +/imageormask_sys +{ + begin + AGMIMG_init_common + save mark + level2{ + currentdict + Operator/imagemask eq{ + AGMIMG_&imagemask + }{ + use_mask{ + process_mask AGMIMG_&image + }{ + AGMIMG_&image + }ifelse + }ifelse + }{ + Width Height + Operator/imagemask eq{ + Decode 0 get 1 eq Decode 1 get 0 eq and + ImageMatrix/DataSource load + AGMIMG_&imagemask + }{ + BitsPerComponent ImageMatrix/DataSource load + AGMIMG_&image + }ifelse + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + cleartomark restore + end +}def +/overprint_plate +{ + currentoverprint{ + 0 get dup type/nametype eq{ + dup/DeviceGray eq{ + pop AGMCORE_black_plate not + }{ + /DeviceCMYK eq{ + AGMCORE_is_cmyk_sep not + }if + }ifelse + }{ + false exch + { + AGMOHS_sepink eq or + }forall + not + }ifelse + }{ + pop false + }ifelse +}def +/process_mask +{ + level3{ + dup begin + /ImageType 1 def + end + 4 dict begin + /DataDict exch def + /ImageType 3 def + /InterleaveType 3 def + /MaskDict 9 dict begin + /ImageType 1 def + /Width DataDict dup/MaskWidth known{/MaskWidth}{/Width}ifelse get def + /Height DataDict dup/MaskHeight known{/MaskHeight}{/Height}ifelse get def + /ImageMatrix[Width 0 0 Height neg 0 Height]def + /NComponents 1 def + /BitsPerComponent 1 def + /Decode DataDict dup/MaskD known{/MaskD}{[1 0]}ifelse get def + /DataSource Adobe_AGM_Core/AGMIMG_maskSource get def + currentdict end def + currentdict end + }if +}def +/use_mask +{ + dup/Mask known {dup/Mask get}{false}ifelse +}def +/imageormask +{ + begin + AGMIMG_init_common + SkipImageProc{ + currentdict consumeimagedata + } + { + save mark + level2 AGMCORE_host_sep not and{ + currentdict + Operator/imagemask eq DeviceN_PS2 not and{ + imagemask + }{ + AGMCORE_in_rip_sep currentoverprint and currentcolorspace 0 get/DeviceGray eq and{ + [/Separation/Black/DeviceGray{}]setcolorspace + /Decode[Decode 1 get Decode 0 get]def + }if + use_mask{ + process_mask image + }{ + DeviceN_NoneName DeviceN_PS2 Indexed_DeviceN level3 not and or or AGMCORE_in_rip_sep and + { + Names convert_to_process not{ + 2 dict begin + /imageDict xdf + /names_index 0 def + gsave + imageDict write_image_file{ + Names{ + dup(None)ne{ + [/Separation 3 -1 roll/DeviceGray{1 exch sub}]setcolorspace + Operator imageDict read_image_file + names_index 0 eq{true setoverprint}if + /names_index names_index 1 add def + }{ + pop + }ifelse + }forall + close_image_file + }if + grestore + end + }{ + Operator/imagemask eq{ + imagemask + }{ + image + }ifelse + }ifelse + }{ + Operator/imagemask eq{ + imagemask + }{ + image + }ifelse + }ifelse + }ifelse + }ifelse + }{ + Width Height + Operator/imagemask eq{ + Decode 0 get 1 eq Decode 1 get 0 eq and + ImageMatrix/DataSource load + /Adobe_AGM_OnHost_Seps where{ + pop imagemask + }{ + currentgray 1 ne{ + currentdict imageormask_sys + }{ + currentoverprint not{ + 1 AGMCORE_&setgray + currentdict imageormask_sys + }{ + currentdict ignoreimagedata + }ifelse + }ifelse + }ifelse + }{ + BitsPerComponent ImageMatrix + MultipleDataSources{ + 0 1 NComponents 1 sub{ + DataSource exch get + }for + }{ + /DataSource load + }ifelse + Operator/colorimage eq{ + AGMCORE_host_sep{ + MultipleDataSources level2 or NComponents 4 eq and{ + AGMCORE_is_cmyk_sep{ + MultipleDataSources{ + /DataSource DataSource 0 get xcheck + { + [ + DataSource 0 get/exec cvx + DataSource 1 get/exec cvx + DataSource 2 get/exec cvx + DataSource 3 get/exec cvx + /AGMCORE_get_ink_data cvx + ]cvx + }{ + DataSource aload pop AGMCORE_get_ink_data + }ifelse def + }{ + /DataSource + Width BitsPerComponent mul 7 add 8 idiv Height mul 4 mul + /DataSource load + filter_cmyk 0()/SubFileDecode filter def + }ifelse + /Decode[Decode 0 get Decode 1 get]def + /MultipleDataSources false def + /NComponents 1 def + /Operator/image def + invert_image_samples + 1 AGMCORE_&setgray + currentdict imageormask_sys + }{ + currentoverprint not Operator/imagemask eq and{ + 1 AGMCORE_&setgray + currentdict imageormask_sys + }{ + currentdict ignoreimagedata + }ifelse + }ifelse + }{ + MultipleDataSources NComponents AGMIMG_&colorimage + }ifelse + }{ + true NComponents colorimage + }ifelse + }{ + Operator/image eq{ + AGMCORE_host_sep{ + /DoImage true def + currentdict/HostSepColorImage known{HostSepColorImage not}{false}ifelse + { + AGMCORE_black_plate not Operator/imagemask ne and{ + /DoImage false def + currentdict ignoreimagedata + }if + }if + 1 AGMCORE_&setgray + DoImage + {currentdict imageormask_sys}if + }{ + use_mask{ + process_mask image + }{ + image + }ifelse + }ifelse + }{ + Operator/knockout eq{ + pop pop pop pop pop + currentcolorspace overprint_plate not{ + knockout_unitsq + }if + }if + }ifelse + }ifelse + }ifelse + }ifelse + cleartomark restore + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end +}def +/sep_imageormask +{ + /sep_colorspace_dict AGMCORE_gget begin + CSA map_csa + begin + AGMIMG_init_common + SkipImageProc{ + currentdict consumeimagedata + }{ + save mark + AGMCORE_avoid_L2_sep_space{ + /Decode[Decode 0 get 255 mul Decode 1 get 255 mul]def + }if + AGMIMG_ccimage_exists + MappedCSA 0 get/DeviceCMYK eq and + currentdict/Components known and + Name()ne and + Name(All)ne and + Operator/image eq and + AGMCORE_producing_seps not and + level2 not and + { + Width Height BitsPerComponent ImageMatrix + [ + /DataSource load/exec cvx + { + 0 1 2 index length 1 sub{ + 1 index exch + 2 copy get 255 xor put + }for + }/exec cvx + ]cvx bind + MappedCSA 0 get/DeviceCMYK eq{ + Components aload pop + }{ + 0 0 0 Components aload pop 1 exch sub + }ifelse + Name findcmykcustomcolor + customcolorimage + }{ + AGMCORE_producing_seps not{ + level2{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne AGMCORE_avoid_L2_sep_space not and currentcolorspace 0 get/Separation ne and{ + [/Separation Name MappedCSA sep_proc_name exch dup 0 get 15 string cvs(/Device)anchorsearch{pop pop 0 get}{pop}ifelse exch load]setcolorspace_opt + /sep_tint AGMCORE_gget setcolor + }if + currentdict imageormask + }{ + currentdict + Operator/imagemask eq{ + imageormask + }{ + sep_imageormask_lev1 + }ifelse + }ifelse + }{ + AGMCORE_host_sep{ + Operator/knockout eq{ + currentdict/ImageMatrix get concat + knockout_unitsq + }{ + currentgray 1 ne{ + AGMCORE_is_cmyk_sep Name(All)ne and{ + level2{ + Name AGMCORE_IsSeparationAProcessColor + { + Operator/imagemask eq{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{ + /sep_tint AGMCORE_gget 1 exch sub AGMCORE_&setcolor + }if + }{ + invert_image_samples + }ifelse + }{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{ + [/Separation Name[/DeviceGray] + { + sep_colorspace_proc AGMCORE_get_ink_data + 1 exch sub + }bind + ]AGMCORE_&setcolorspace + /sep_tint AGMCORE_gget AGMCORE_&setcolor + }if + }ifelse + currentdict imageormask_sys + }{ + currentdict + Operator/imagemask eq{ + imageormask_sys + }{ + sep_image_lev1_sep + }ifelse + }ifelse + }{ + Operator/imagemask ne{ + invert_image_samples + }if + currentdict imageormask_sys + }ifelse + }{ + currentoverprint not Name(All)eq or Operator/imagemask eq and{ + currentdict imageormask_sys + }{ + currentoverprint not + { + gsave + knockout_unitsq + grestore + }if + currentdict consumeimagedata + }ifelse + }ifelse + }ifelse + }{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{ + currentcolorspace 0 get/Separation ne{ + [/Separation Name MappedCSA sep_proc_name exch 0 get exch load]setcolorspace_opt + /sep_tint AGMCORE_gget setcolor + }if + }if + currentoverprint + MappedCSA 0 get/DeviceCMYK eq and + Name AGMCORE_IsSeparationAProcessColor not and + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{Name inRip_spot_has_ink not and}{false}ifelse + Name(All)ne and{ + imageormask_l2_overprint + }{ + currentdict imageormask + }ifelse + }ifelse + }ifelse + }ifelse + cleartomark restore + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end + end +}def +/colorSpaceElemCnt +{ + mark currentcolor counttomark dup 2 add 1 roll cleartomark +}bdf +/devn_sep_datasource +{ + 1 dict begin + /dataSource xdf + [ + 0 1 dataSource length 1 sub{ + dup currentdict/dataSource get/exch cvx/get cvx/exec cvx + /exch cvx names_index/ne cvx[/pop cvx]cvx/if cvx + }for + ]cvx bind + end +}bdf +/devn_alt_datasource +{ + 11 dict begin + /convProc xdf + /origcolorSpaceElemCnt xdf + /origMultipleDataSources xdf + /origBitsPerComponent xdf + /origDecode xdf + /origDataSource xdf + /dsCnt origMultipleDataSources{origDataSource length}{1}ifelse def + /DataSource origMultipleDataSources + { + [ + BitsPerComponent 8 idiv origDecode length 2 idiv mul string + 0 1 origDecode length 2 idiv 1 sub + { + dup 7 mul 1 add index exch dup BitsPerComponent 8 idiv mul exch + origDataSource exch get 0()/SubFileDecode filter + BitsPerComponent 8 idiv string/readstring cvx/pop cvx/putinterval cvx + }for + ]bind cvx + }{origDataSource}ifelse 0()/SubFileDecode filter def + [ + origcolorSpaceElemCnt string + 0 2 origDecode length 2 sub + { + dup origDecode exch get dup 3 -1 roll 1 add origDecode exch get exch sub 2 BitsPerComponent exp 1 sub div + 1 BitsPerComponent 8 idiv{DataSource/read cvx/not cvx{0}/if cvx/mul cvx}repeat/mul cvx/add cvx + }for + /convProc load/exec cvx + origcolorSpaceElemCnt 1 sub -1 0 + { + /dup cvx 2/add cvx/index cvx + 3 1/roll cvx/exch cvx 255/mul cvx/cvi cvx/put cvx + }for + ]bind cvx 0()/SubFileDecode filter + end +}bdf +/devn_imageormask +{ + /devicen_colorspace_dict AGMCORE_gget begin + CSA map_csa + 2 dict begin + dup + /srcDataStrs[3 -1 roll begin + AGMIMG_init_common + currentdict/MultipleDataSources known{MultipleDataSources{DataSource length}{1}ifelse}{1}ifelse + { + Width Decode length 2 div mul cvi + { + dup 65535 gt{1 add 2 div cvi}{exit}ifelse + }loop + string + }repeat + end]def + /dstDataStr srcDataStrs 0 get length string def + begin + AGMIMG_init_common + SkipImageProc{ + currentdict consumeimagedata + }{ + save mark + AGMCORE_producing_seps not{ + level3 not{ + Operator/imagemask ne{ + /DataSource[[ + DataSource Decode BitsPerComponent currentdict/MultipleDataSources known{MultipleDataSources}{false}ifelse + colorSpaceElemCnt/devicen_colorspace_dict AGMCORE_gget/TintTransform get + devn_alt_datasource 1/string cvx/readstring cvx/pop cvx]cvx colorSpaceElemCnt 1 sub{dup}repeat]def + /MultipleDataSources true def + /Decode colorSpaceElemCnt[exch{0 1}repeat]def + }if + }if + currentdict imageormask + }{ + AGMCORE_host_sep{ + Names convert_to_process{ + CSA get_csa_by_name 0 get/DeviceCMYK eq{ + /DataSource + Width BitsPerComponent mul 7 add 8 idiv Height mul 4 mul + DataSource Decode BitsPerComponent currentdict/MultipleDataSources known{MultipleDataSources}{false}ifelse + 4/devicen_colorspace_dict AGMCORE_gget/TintTransform get + devn_alt_datasource + filter_cmyk 0()/SubFileDecode filter def + /MultipleDataSources false def + /Decode[1 0]def + /DeviceGray setcolorspace + currentdict imageormask_sys + }{ + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate{ + /DataSource + DataSource Decode BitsPerComponent currentdict/MultipleDataSources known{MultipleDataSources}{false}ifelse + CSA get_csa_by_name 0 get/DeviceRGB eq{3}{1}ifelse/devicen_colorspace_dict AGMCORE_gget/TintTransform get + devn_alt_datasource + /MultipleDataSources false def + /Decode colorSpaceElemCnt[exch{0 1}repeat]def + currentdict imageormask_sys + }{ + gsave + knockout_unitsq + grestore + currentdict consumeimagedata + }ifelse + }ifelse + } + { + /devicen_colorspace_dict AGMCORE_gget/names_index known{ + Operator/imagemask ne{ + MultipleDataSources{ + /DataSource[DataSource devn_sep_datasource/exec cvx]cvx def + /MultipleDataSources false def + }{ + /DataSource/DataSource load dstDataStr srcDataStrs 0 get filter_devn def + }ifelse + invert_image_samples + }if + currentdict imageormask_sys + }{ + currentoverprint not Operator/imagemask eq and{ + currentdict imageormask_sys + }{ + currentoverprint not + { + gsave + knockout_unitsq + grestore + }if + currentdict consumeimagedata + }ifelse + }ifelse + }ifelse + }{ + currentdict imageormask + }ifelse + }ifelse + cleartomark restore + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end + end + end +}def +/imageormask_l2_overprint +{ + currentdict + currentcmykcolor add add add 0 eq{ + currentdict consumeimagedata + }{ + level3{ + currentcmykcolor + /AGMIMG_k xdf + /AGMIMG_y xdf + /AGMIMG_m xdf + /AGMIMG_c xdf + Operator/imagemask eq{ + [/DeviceN[ + AGMIMG_c 0 ne{/Cyan}if + AGMIMG_m 0 ne{/Magenta}if + AGMIMG_y 0 ne{/Yellow}if + AGMIMG_k 0 ne{/Black}if + ]/DeviceCMYK{}]setcolorspace + AGMIMG_c 0 ne{AGMIMG_c}if + AGMIMG_m 0 ne{AGMIMG_m}if + AGMIMG_y 0 ne{AGMIMG_y}if + AGMIMG_k 0 ne{AGMIMG_k}if + setcolor + }{ + /Decode[Decode 0 get 255 mul Decode 1 get 255 mul]def + [/Indexed + [ + /DeviceN[ + AGMIMG_c 0 ne{/Cyan}if + AGMIMG_m 0 ne{/Magenta}if + AGMIMG_y 0 ne{/Yellow}if + AGMIMG_k 0 ne{/Black}if + ] + /DeviceCMYK{ + AGMIMG_k 0 eq{0}if + AGMIMG_y 0 eq{0 exch}if + AGMIMG_m 0 eq{0 3 1 roll}if + AGMIMG_c 0 eq{0 4 1 roll}if + } + ] + 255 + { + 255 div + mark exch + dup dup dup + AGMIMG_k 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec 4 1 roll pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + AGMIMG_y 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec 4 2 roll pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + AGMIMG_m 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec 4 3 roll pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + AGMIMG_c 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + counttomark 1 add -1 roll pop + } + ]setcolorspace + }ifelse + imageormask_sys + }{ + write_image_file{ + currentcmykcolor + 0 ne{ + [/Separation/Black/DeviceGray{}]setcolorspace + gsave + /Black + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{4 1 roll pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + 0 ne{ + [/Separation/Yellow/DeviceGray{}]setcolorspace + gsave + /Yellow + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{4 2 roll pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + 0 ne{ + [/Separation/Magenta/DeviceGray{}]setcolorspace + gsave + /Magenta + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{4 3 roll pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + 0 ne{ + [/Separation/Cyan/DeviceGray{}]setcolorspace + gsave + /Cyan + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + close_image_file + }{ + imageormask + }ifelse + }ifelse + }ifelse +}def +/indexed_imageormask +{ + begin + AGMIMG_init_common + save mark + currentdict + AGMCORE_host_sep{ + Operator/knockout eq{ + /indexed_colorspace_dict AGMCORE_gget dup/CSA known{ + /CSA get get_csa_by_name + }{ + /Names get + }ifelse + overprint_plate not{ + knockout_unitsq + }if + }{ + Indexed_DeviceN{ + /devicen_colorspace_dict AGMCORE_gget dup/names_index known exch/Names get convert_to_process or{ + indexed_image_lev2_sep + }{ + currentoverprint not{ + knockout_unitsq + }if + currentdict consumeimagedata + }ifelse + }{ + AGMCORE_is_cmyk_sep{ + Operator/imagemask eq{ + imageormask_sys + }{ + level2{ + indexed_image_lev2_sep + }{ + indexed_image_lev1_sep + }ifelse + }ifelse + }{ + currentoverprint not{ + knockout_unitsq + }if + currentdict consumeimagedata + }ifelse + }ifelse + }ifelse + }{ + level2{ + Indexed_DeviceN{ + /indexed_colorspace_dict AGMCORE_gget begin + }{ + /indexed_colorspace_dict AGMCORE_gget dup null ne + { + begin + currentdict/CSDBase known{CSDBase/CSD get_res/MappedCSA get}{CSA}ifelse + get_csa_by_name 0 get/DeviceCMYK eq ps_level 3 ge and ps_version 3015.007 lt and + AGMCORE_in_rip_sep and{ + [/Indexed[/DeviceN[/Cyan/Magenta/Yellow/Black]/DeviceCMYK{}]HiVal Lookup] + setcolorspace + }if + end + } + {pop}ifelse + }ifelse + imageormask + Indexed_DeviceN{ + end + }if + }{ + Operator/imagemask eq{ + imageormask + }{ + indexed_imageormask_lev1 + }ifelse + }ifelse + }ifelse + cleartomark restore + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end +}def +/indexed_image_lev2_sep +{ + /indexed_colorspace_dict AGMCORE_gget begin + begin + Indexed_DeviceN not{ + currentcolorspace + dup 1/DeviceGray put + dup 3 + currentcolorspace 2 get 1 add string + 0 1 2 3 AGMCORE_get_ink_data 4 currentcolorspace 3 get length 1 sub + { + dup 4 idiv exch currentcolorspace 3 get exch get 255 exch sub 2 index 3 1 roll put + }for + put setcolorspace + }if + currentdict + Operator/imagemask eq{ + AGMIMG_&imagemask + }{ + use_mask{ + process_mask AGMIMG_&image + }{ + AGMIMG_&image + }ifelse + }ifelse + end end +}def + /OPIimage + { + dup type/dicttype ne{ + 10 dict begin + /DataSource xdf + /ImageMatrix xdf + /BitsPerComponent xdf + /Height xdf + /Width xdf + /ImageType 1 def + /Decode[0 1 def] + currentdict + end + }if + dup begin + /NComponents 1 cdndf + /MultipleDataSources false cdndf + /SkipImageProc{false}cdndf + /Decode[ + 0 + currentcolorspace 0 get/Indexed eq{ + 2 BitsPerComponent exp 1 sub + }{ + 1 + }ifelse + ]cdndf + /Operator/image cdndf + end + /sep_colorspace_dict AGMCORE_gget null eq{ + imageormask + }{ + gsave + dup begin invert_image_samples end + sep_imageormask + grestore + }ifelse + }def +/cachemask_level2 +{ + 3 dict begin + /LZWEncode filter/WriteFilter xdf + /readBuffer 256 string def + /ReadFilter + currentfile + 0(%EndMask)/SubFileDecode filter + /ASCII85Decode filter + /RunLengthDecode filter + def + { + ReadFilter readBuffer readstring exch + WriteFilter exch writestring + not{exit}if + }loop + WriteFilter closefile + end +}def +/spot_alias +{ + /mapto_sep_imageormask + { + dup type/dicttype ne{ + 12 dict begin + /ImageType 1 def + /DataSource xdf + /ImageMatrix xdf + /BitsPerComponent xdf + /Height xdf + /Width xdf + /MultipleDataSources false def + }{ + begin + }ifelse + /Decode[/customcolor_tint AGMCORE_gget 0]def + /Operator/image def + /SkipImageProc{false}def + currentdict + end + sep_imageormask + }bdf + /customcolorimage + { + Adobe_AGM_Image/AGMIMG_colorAry xddf + /customcolor_tint AGMCORE_gget + << + /Name AGMIMG_colorAry 4 get + /CSA[/DeviceCMYK] + /TintMethod/Subtractive + /TintProc null + /MappedCSA null + /NComponents 4 + /Components[AGMIMG_colorAry aload pop pop] + >> + setsepcolorspace + mapto_sep_imageormask + }ndf + Adobe_AGM_Image/AGMIMG_&customcolorimage/customcolorimage load put + /customcolorimage + { + Adobe_AGM_Image/AGMIMG_override false put + current_spot_alias{dup 4 get map_alias}{false}ifelse + { + false set_spot_alias + /customcolor_tint AGMCORE_gget exch setsepcolorspace + pop + mapto_sep_imageormask + true set_spot_alias + }{ + //Adobe_AGM_Image/AGMIMG_&customcolorimage get exec + }ifelse + }bdf +}def +/snap_to_device +{ + 6 dict begin + matrix currentmatrix + dup 0 get 0 eq 1 index 3 get 0 eq and + 1 index 1 get 0 eq 2 index 2 get 0 eq and or exch pop + { + 1 1 dtransform 0 gt exch 0 gt/AGMIMG_xSign? exch def/AGMIMG_ySign? exch def + 0 0 transform + AGMIMG_ySign?{floor 0.1 sub}{ceiling 0.1 add}ifelse exch + AGMIMG_xSign?{floor 0.1 sub}{ceiling 0.1 add}ifelse exch + itransform/AGMIMG_llY exch def/AGMIMG_llX exch def + 1 1 transform + AGMIMG_ySign?{ceiling 0.1 add}{floor 0.1 sub}ifelse exch + AGMIMG_xSign?{ceiling 0.1 add}{floor 0.1 sub}ifelse exch + itransform/AGMIMG_urY exch def/AGMIMG_urX exch def + [AGMIMG_urX AGMIMG_llX sub 0 0 AGMIMG_urY AGMIMG_llY sub AGMIMG_llX AGMIMG_llY]concat + }{ + }ifelse + end +}def +level2 not{ + /colorbuf + { + 0 1 2 index length 1 sub{ + dup 2 index exch get + 255 exch sub + 2 index + 3 1 roll + put + }for + }def + /tint_image_to_color + { + begin + Width Height BitsPerComponent ImageMatrix + /DataSource load + end + Adobe_AGM_Image begin + /AGMIMG_mbuf 0 string def + /AGMIMG_ybuf 0 string def + /AGMIMG_kbuf 0 string def + { + colorbuf dup length AGMIMG_mbuf length ne + { + dup length dup dup + /AGMIMG_mbuf exch string def + /AGMIMG_ybuf exch string def + /AGMIMG_kbuf exch string def + }if + dup AGMIMG_mbuf copy AGMIMG_ybuf copy AGMIMG_kbuf copy pop + } + addprocs + {AGMIMG_mbuf}{AGMIMG_ybuf}{AGMIMG_kbuf}true 4 colorimage + end + }def + /sep_imageormask_lev1 + { + begin + MappedCSA 0 get dup/DeviceRGB eq exch/DeviceCMYK eq or has_color not and{ + { + 255 mul round cvi GrayLookup exch get + }currenttransfer addprocs settransfer + currentdict imageormask + }{ + /sep_colorspace_dict AGMCORE_gget/Components known{ + MappedCSA 0 get/DeviceCMYK eq{ + Components aload pop + }{ + 0 0 0 Components aload pop 1 exch sub + }ifelse + Adobe_AGM_Image/AGMIMG_k xddf + Adobe_AGM_Image/AGMIMG_y xddf + Adobe_AGM_Image/AGMIMG_m xddf + Adobe_AGM_Image/AGMIMG_c xddf + AGMIMG_y 0.0 eq AGMIMG_m 0.0 eq and AGMIMG_c 0.0 eq and{ + {AGMIMG_k mul 1 exch sub}currenttransfer addprocs settransfer + currentdict imageormask + }{ + currentcolortransfer + {AGMIMG_k mul 1 exch sub}exch addprocs 4 1 roll + {AGMIMG_y mul 1 exch sub}exch addprocs 4 1 roll + {AGMIMG_m mul 1 exch sub}exch addprocs 4 1 roll + {AGMIMG_c mul 1 exch sub}exch addprocs 4 1 roll + setcolortransfer + currentdict tint_image_to_color + }ifelse + }{ + MappedCSA 0 get/DeviceGray eq{ + {255 mul round cvi ColorLookup exch get 0 get}currenttransfer addprocs settransfer + currentdict imageormask + }{ + MappedCSA 0 get/DeviceCMYK eq{ + currentcolortransfer + {255 mul round cvi ColorLookup exch get 3 get 1 exch sub}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 2 get 1 exch sub}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 1 get 1 exch sub}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 0 get 1 exch sub}exch addprocs 4 1 roll + setcolortransfer + currentdict tint_image_to_color + }{ + currentcolortransfer + {pop 1}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 2 get}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 1 get}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 0 get}exch addprocs 4 1 roll + setcolortransfer + currentdict tint_image_to_color + }ifelse + }ifelse + }ifelse + }ifelse + end + }def + /sep_image_lev1_sep + { + begin + /sep_colorspace_dict AGMCORE_gget/Components known{ + Components aload pop + Adobe_AGM_Image/AGMIMG_k xddf + Adobe_AGM_Image/AGMIMG_y xddf + Adobe_AGM_Image/AGMIMG_m xddf + Adobe_AGM_Image/AGMIMG_c xddf + {AGMIMG_c mul 1 exch sub} + {AGMIMG_m mul 1 exch sub} + {AGMIMG_y mul 1 exch sub} + {AGMIMG_k mul 1 exch sub} + }{ + {255 mul round cvi ColorLookup exch get 0 get 1 exch sub} + {255 mul round cvi ColorLookup exch get 1 get 1 exch sub} + {255 mul round cvi ColorLookup exch get 2 get 1 exch sub} + {255 mul round cvi ColorLookup exch get 3 get 1 exch sub} + }ifelse + AGMCORE_get_ink_data currenttransfer addprocs settransfer + currentdict imageormask_sys + end + }def + /indexed_imageormask_lev1 + { + /indexed_colorspace_dict AGMCORE_gget begin + begin + currentdict + MappedCSA 0 get dup/DeviceRGB eq exch/DeviceCMYK eq or has_color not and{ + {HiVal mul round cvi GrayLookup exch get HiVal div}currenttransfer addprocs settransfer + imageormask + }{ + MappedCSA 0 get/DeviceGray eq{ + {HiVal mul round cvi Lookup exch get HiVal div}currenttransfer addprocs settransfer + imageormask + }{ + MappedCSA 0 get/DeviceCMYK eq{ + currentcolortransfer + {4 mul HiVal mul round cvi 3 add Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + {4 mul HiVal mul round cvi 2 add Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + {4 mul HiVal mul round cvi 1 add Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + {4 mul HiVal mul round cvi Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + setcolortransfer + tint_image_to_color + }{ + currentcolortransfer + {pop 1}exch addprocs 4 1 roll + {3 mul HiVal mul round cvi 2 add Lookup exch get HiVal div}exch addprocs 4 1 roll + {3 mul HiVal mul round cvi 1 add Lookup exch get HiVal div}exch addprocs 4 1 roll + {3 mul HiVal mul round cvi Lookup exch get HiVal div}exch addprocs 4 1 roll + setcolortransfer + tint_image_to_color + }ifelse + }ifelse + }ifelse + end end + }def + /indexed_image_lev1_sep + { + /indexed_colorspace_dict AGMCORE_gget begin + begin + {4 mul HiVal mul round cvi Lookup exch get HiVal div 1 exch sub} + {4 mul HiVal mul round cvi 1 add Lookup exch get HiVal div 1 exch sub} + {4 mul HiVal mul round cvi 2 add Lookup exch get HiVal div 1 exch sub} + {4 mul HiVal mul round cvi 3 add Lookup exch get HiVal div 1 exch sub} + AGMCORE_get_ink_data currenttransfer addprocs settransfer + currentdict imageormask_sys + end end + }def +}if +end +systemdict/setpacking known +{setpacking}if +%%EndResource +currentdict Adobe_AGM_Utils eq {end} if +%%EndProlog +%%BeginSetup +Adobe_AGM_Utils begin +2 2010 Adobe_AGM_Core/ds gx +Adobe_CoolType_Core/ds get exec Adobe_AGM_Image/ds gx +currentdict Adobe_AGM_Utils eq {end} if +%%EndSetup +%%Page: 1 1 +%%EndPageComments +%%BeginPageSetup +%ADOBeginClientInjection: PageSetup Start "AI11EPS" +%AI12_RMC_Transparency: Balance=75 RasterRes=300 GradRes=150 Text=0 Stroke=1 Clip=1 OP=0 +%ADOEndClientInjection: PageSetup Start "AI11EPS" +Adobe_AGM_Utils begin +Adobe_AGM_Core/ps gx +Adobe_AGM_Utils/capture_cpd gx +Adobe_CoolType_Core/ps get exec Adobe_AGM_Image/ps gx +%ADOBeginClientInjection: PageSetup End "AI11EPS" +/currentdistillerparams where {pop currentdistillerparams /CoreDistVersion get 5000 lt} {true} ifelse { userdict /AI11_PDFMark5 /cleartomark load put userdict /AI11_ReadMetadata_PDFMark5 {flushfile cleartomark } bind put} { userdict /AI11_PDFMark5 /pdfmark load put userdict /AI11_ReadMetadata_PDFMark5 {/PUT pdfmark} bind put } ifelse [/NamespacePush AI11_PDFMark5 [/_objdef {ai_metadata_stream_123} /type /stream /OBJ AI11_PDFMark5 [{ai_metadata_stream_123} currentfile 0 (% &&end XMP packet marker&&) /SubFileDecode filter AI11_ReadMetadata_PDFMark5 + + + + application/postscript + + + Web + + + + + Adobe Illustrator CS4 + 2013-02-17T10:07:03-05:00 + 2013-02-17T10:07:03-05:00 + 2013-02-17T10:07:03-05:00 + + + + 256 + 76 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgATAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8Ah353+QvzJ/K620y8/wAf 6lrFpqMkkPL1rq2eORFDU4evMGBB68voxVv8kPIP5kfmjZ6ne/8AKwNS0i206RIf766uZHd1LfZ9 eABQO/I/LFX0b+cX5U+Z/PL6fPoXm658tSafHMrQW4l4XDyFSnqNFNDx48KV4t1xV8T6f5o/Mm+1 620SHzPqQu7q6SyjZr66CepJIIgSeVePI+GKvt78nvyt8xeRE1Q615suvND6kLb01uVlAtjB6vMR mWeflz9Udl+z9yrMfNl8+n+VdZv0JV7SxuZ1ZftAxws4Ir32xV+fHlDWPzT82eZLDy7pXmXUf0hq LmOD1tQuUjBVS5LMGagCqe2Kt2nnf8w9E84w2moeYtSaXTNRWK7jN9O8Za3nCyL8T8WWqnrtir9G 8VeRfnh+U/nDzbKNa8vecLrQX06xdF0yEzJFcSIzycnlimThUNxr6bdMVfIPk3X/AMwvM3mvSfL0 fm3VLV9VuorRbhry5cIZWC8iokWtK+OKvoOf/nGX84oIXmsPzRvHvUFYEeW9hUt4GRZ5GX5hTirC fIf/ADkZ+ZPkHzg/ln8w5ptS061uDa6itzSS7tiDxMscw+KVf2qMW5L9kiuKvrXzHpr+ZfKV9Yab qTWLataNHaarb1cxesnwTR8WjJpWoow+eKviT86NG/Mj8sfMlto8vnfUtUivLUXcFytzdQGhdoyr RmaShBTsxxV6X/zjv+W/n7zLaaN5+v8Az1frYQ3vqLozvcXAuI7aUq6Su06KocqR9htsVSHzxp/5 lecf+cjte8peW/MF7p8QkSVit3cR29vAltEXfhGw/aYAADdjirGPzj8s/ml+WFzpcF/53v8AUTqi TPG0F3doEEJQEHm/fniqeflP+V/5tfmN5XfzBZefrywhS5ktfQmurx25RqjFqrJSh54qyL/nGw+d 5PzF86+U/MGt3s13Y6dc2TNNcTXCRTpcJD6sYd+3VSKbYqwv88fKH5kflZc6Qr+fNS1e21dZzDKJ 7q2dGtvT5hk9eYU/fLQ8vHFUb+UX5bfmz+ZXlu512x8+3mnxW149i0M91eOxaOKKXkCslKUmA+jF UZ5/8rf85C/lDZweYIfOV5qekiQJNMlxPNHDIxAX1ra55x8XOwah32NKiqr2v/nHn89V/MjS7iw1 WOO380aYivdJF8MdxCTxE8akkrRqK69ASCOtAqxv/nIf8sPPEkeveftE86XlhbWVsk7aBE08MQjt 4wspSWObjybiWp6XXv3xV8+fla/5iefvOdp5Yh85anp8l2kzi6e6uZQvoxtJTgJU68adcVe0ap/z jj+eGm2M17of5k3t7qMKlorQ3F5amSgrwWT15F5N0AYAeJGKpL+SP/OTvmm08yweVPzCnN1aXE31 SPUp1Edza3HLgqzkBeSc/hYsOS9SaCmKvreaWKGJ5pXEcUal5HY0VVUVJJPYDFXwn+YP5r+e/wA3 /P0Xl3QLqe20S9uhZ6RpcbtEjozcRPdcftEr8bcqhB07kqsp/Mj/AJxWv/I3k1vNfl3Xbi91LSEE +pRhPQPpj+8mt2RuS+n9oqxPw1NdqFVl3/OLH576z5hvG8keabpry/SJptH1GY1mlWIVkglY7uyr 8asd6Bqnpiq//nN7/lFvLX/MdN/yZxV3/OEP/KLeZf8AmOh/5M4q+lMVfmt5T/8AJp6N/wBty2/6 i1xV+lOKsL/Oq8Fp+Unm+UmnLSbuHcV/v4jF2/18VfHf/OKtj9Z/O/Q5CvJbSO8nYEAj/eWSMHfw aQH54qxb857L6l+bPm+ClAdWu5QNuk0zSjp7Pir9FtHvDe6TZXhNTc28U1SKH94gboPnirerf8cq 9/4wS/8AEDir86vyU/8AJueUP+2ra/8AJ0Yq/R/FXxR/zmboUVj+Z1nqkSBRq2nRPO23xTQO8JJ/ 55LGMVfQ3/OM2uy6x+S/l9535z2SS2DnwW2lZIh9EPAYq8G/5za/5T/Qv+2UP+omXFXtf/OJ/wD5 JLSP+M95/wBRL4qyXQPym0zR/wA0Nd/MGO9lmv8AXLcW0loyqI4k/ck8SNzX6uvXFXgv/Ocf/HV8 o/8AGC9/4nDirP8A/nDb/wAlHN/21bn/AJNQ4qznyv8AlLp3l/8AMfzB55hv5p7vzAhSazdVEcYL o9VYfEf7vvirxH/nOf8A6Yn/ALen/YnirK/+cKv/ACVmq/8AbcuP+oO0xV61+ZehQ69+X3mLSJUE n1vT7hYwRWkojLRMB4rIqsMVfEH/ADjTr0uj/nP5fZX4xX8klhcLWgZbiNlUH5S8G+jFX2f+df8A 5KPzf/2yrr/k0cVfIH/OJ/8A5O3R/wDjBef9Q0mKvvTFX5+/85NaFFo/50a+kCBIb5or9QKfauYl eU7eM3M4q+mvOXne6m/5xWm8yNKWvNQ0K2guJe5mvBHaTEdf2pWxV4L/AM4d6LHf/m415InIaTp1 xcxsR0kkZLcfTwnbFX21qVhbajp11p90vO2vIZLedD0McqlGH0g4q/Oj8stQufK/5ueX5nbg9jq8 Nvckf77ab0Jx9KM2Kvov/nN7/lFvLX/MdN/yZxV3/OEP/KLeZf8AmOh/5M4q+lMVfmt5T/8AJp6N /wBty2/6i1xV+lOKvLf+cnb0Wn5H+ZGrR5ltYUG+/qXcKt0/yanFXzx/zhhY+v8Amre3BHw2mkTu GoDR3ngjA9vhZsVYv/zk9ZfVPzw8yACiTNazodt/UtIS3T/Lrir7X/Km8F5+WHlK5rUyaPY86Cg5 i2QNSv8AlA4qyDVv+OVe/wDGCX/iBxV+dX5Kf+Tc8of9tW1/5OjFX6P4q+P/APnN5l/xZ5aWo5Cw lJXuAZtj+GKvV/8AnERWH5NWpIIDX12VJ7jmBUfSMVePf85tf8p/oX/bKH/UTLir2v8A5xP/APJJ aR/xnvP+ol8Vev4q+S/+c4/+Or5R/wCMF7/xOHFWf/8AOG3/AJKOb/tq3P8AyahxV7rir5V/5zn/ AOmJ/wC3p/2J4qyv/nCr/wAlZqv/AG3Lj/qDtMVe56y6Jo987sFRbeUsxNAAEJJJOKvzu/JJHf8A N3ygFUsRqlsaAV2WQEn6AMVfdH51/wDko/N//bKuv+TRxV8gf84n/wDk7dH/AOMF5/1DSYq+9MVf Cv8Azl2yn85boAglbK0DAdjwJofoOKvRPPsskH/OGvl+M/D662KENsSvrNIKV/1QfliqT/8AOEFs W8z+ZrniCIrKCPn3HqSlqfT6eKvr7FX5+ec/yy/MiD8y9dvLHyprN1ZxazdTWtzb6fdFJY1unZJI 2VCvFloVINMVe3/85vf8ot5a/wCY6b/kzirv+cIf+UW8y/8AMdD/AMmcVfSmKvzW8p/+TT0b/tuW 3/UWuKv0pxV4f/zmHe/V/wAnzDWn1zUrWCnKlaCSalP2v7rp9OKvNv8AnB6x5675qvuP9xa2sHKg 29eSRqV9/RxVi/8AzmNZfV/zeSalPrml209dt6PLD2/4xd8VfTX/ADjnei8/JTyrMDXjbSQ9/wDd E8kPf/UxVnurf8cq9/4wS/8AEDir86vyU/8AJueUP+2ra/8AJ0Yq/R53REZ3YKiglmJoABuSScVf An/OS3n7T/Ov5oTz6TKLnS9Mgj02znQ1SUxszySJTqDJKygj7QAOKvsT8kvKVx5T/K3y9ol0np3s Vv694hFGWa5drh0b3QycPoxV82/85tf8p/oX/bKH/UTLir2v/nE//wAklpH/ABnvP+ol8Vev4q+S /wDnOP8A46vlH/jBe/8AE4cVZ/8A84bf+Sjm/wC2rc/8mocVe64q+Vf+c5/+mJ/7en/YnirK/wDn Cr/yVmq/9ty4/wCoO0xVmn/OQX5h6Z5O/LfVhJcomsapbSWWl2ob967zqY2kVQa8YlYuW6bU6kDF XzB/ziX5Qudb/Ni21QxFrDy/DJd3EhHw+rIjQwJX+Ys5cf6hxV9a/nX/AOSj83/9sq6/5NHFXyB/ zif/AOTt0f8A4wXn/UNJir7tv7+y0+ynvr6eO1s7ZGluLiVgkaIoqzMx2AGKvzt/M/zFN+Y35tan qGkRvP8Apa8jtNJhoeTxoFtoKKfslwganicVfR//ADk9oUXl/wD5x80LQ4iCml3OnWYZejehbSIW /wBlxrirFf8AnBz/AI6vm7/jBZf8TmxV9aYq7FXzX/zm9/yi3lr/AJjpv+TOKvE/ye/JHzf+Ymm6 he6Fq9vpsVjMkMyTvMhZnXkCPSVh08cVeg/9Cd/mn/1NNj/yNvP+qeKvD/I0LwfmP5fhc8ni1izR mHQlbpATir9L8VfOX/Obd6U8jaBZV2n1MzU2/wB027r8/wDd2KvC/wAnvyP83/mJp2o3+hatbabF ZTJBMJ3mRnZlLinpIwoB44ql35w/lN5l/LrUtOttd1CHUZdQheSGaBpWCrG3EqTKqnq1dsVfVv8A ziTffWfyW0+GtfqV3eQUrWnKYzUp2/vemKvW9W/45V7/AMYJf+IHFX5meUNAvfMPmjStDsZltrzU rmK2t53LBUeRgqsSoLUHtir3i7/5w5/NVraQL5k064JH9y812Fb2JMTfqxV5X5amk/K/80rc+b9B S+n0S4H1vTpm3RtmSeIqeDsoIePlVT9xCr9DtI1Ww1fSrPVdPlE9jfwx3NrMOjRyqHU/ccVfIH/O bX/Kf6F/2yh/1Ey4q9r/AOcT/wDySWkf8Z7z/qJfFXr+Kvkv/nOP/jq+Uf8AjBe/8ThxVn//ADht /wCSjm/7atz/AMmocVe64q+Vf+c5/wDpif8At6f9ieKvMvyi/IXzp+YXlu51rQ9attOtLe8ezeCd 51YyJFFIXAiRloVlA+jFXnPmPSr3SPNF/o2sStJc6ZdyWV3KCWJMEhjYoW6j4arir9Efy0/Lryp5 E8txaV5cjJglpNcXshDTXLsNpJHAA6dAAAOwxVDfnX/5KPzf/wBsq6/5NHFXwV+WXkjV/O3nC18u 6ReR2N9cpK8dzMXVFEUbSMCYwzbhadMVe0yf84afmXOBHceZtPeIkcgz3T/TxMdMVeu/k5/zjR5Z /L69XW725OteYkUiC6dBHBb8hRvRjq55kEjmx6dAN6qpd/zmT/5KOH/tq23/ACamxVgH/ODn/HV8 3f8AGCy/4nNir0T87/8AnIy//LXzdZ+X7bQY9VF3Yx3oma4aJg0k00XAKsclf7mvXvirz+4/5za1 22uJba58mxw3ELtHNDJdyI6OhoysphBBBFCDiqef85vf8ot5a/5jpv8Akzirv+cIf+UW8y/8x0P/ ACZxV9KYq/Nbyn/5NPRv+25bf9Ra4q/SnFXyp/znJeHl5OshWgF/M+woa/V1Wh6/zYqyv/nCyz9L 8sNTuT9q51iam+3GO3gA/wCG5Yqxf/nOSy+Hyfejsb+FzvXf6uye3ZsVZN/zhVemX8tNWtGNTbav Iy9NlktoKD/glbFXvGrf8cq9/wCMEv8AxA4q/Or8lP8AybnlD/tq2v8AydGKv0fxV8Zf85q6Tb2/ 5g6PqUYCyX+mhJwP2mgmcBz78XC/Rir3D/nFPU5778ldISYlmsprq1VjuSizs6j/AGIk4j5Yq8S/ 5za/5T/Qv+2UP+omXFXtf/OJ/wD5JLSP+M95/wBRL4q9fxV8l/8AOcf/AB1fKP8Axgvf+Jw4qz// AJw2/wDJRzf9tW5/5NQ4q91xV8q/85z/APTE/wDb0/7E8VZX/wA4Vf8AkrNV/wC25cf9QdpirwD/ AJyg0L9EfnRrnBOEOoiC/i9/WiX1D9Myvir7M/JrXBrn5VeVtS5c3fToIZmJqTLbr6Ep/wCDjOKr Pzr/APJR+b/+2Vdf8mjir5A/5xP/APJ26P8A8YLz/qGkxV96Yq7FXh//ADmHAZPyfL1p6GpWshHj USJT/h8Veb/84PTINc81wGvN7W0dfCiSSA/8TGKvS/PP5ba7rv8Azkp5Q8yPpZufK2m6cFvL1jGY 47mFryWJSjNzJEjxHZe+KvkT81lZvzV84qoLM2u6kFUbkk3kmKvpT/nN7/lFvLX/ADHTf8mcVd/z hD/yi3mX/mOh/wCTOKvpTFX5reU//Jp6N/23Lb/qLXFX6U4q+Nv+c2rzn590KzrvDpXrUpv++uJV 6/8APLFXtH/OJtkbf8k9KlIp9cuLyYdN6XDw9v8AjF3xVi3/ADm1Z8/IOhXv++dV9Hr/AL+tpW6f 88cVSn/nBy8L6Z5us67Qz2U1KbfvkmXr/wA8sVfS2rf8cq9/4wS/8QOKvzq/JT/ybnlD/tq2v/J0 Yq/R/FXx3/zm5cRN5z8u2wP72PTnkcf5Mk7Kv4xnFXr/APziRbSw/ktp8jii3F3dyxHfdRMY6/8A BRnFXjX/ADm1/wAp/oX/AGyh/wBRMuKva/8AnE//AMklpH/Ge8/6iXxV6/ir5L/5zj/46vlH/jBe /wDE4cVZ/wD84bf+Sjm/7atz/wAmocVe64q+Vf8AnOf/AKYn/t6f9ieKsr/5wq/8lZqv/bcuP+oO 0xVgX/Ob2h+l5j8ta6o/3ss5rJz2BtZRKtfn9ZP3Yq9I/wCcOdcF/wDlO+nM9ZNI1CeBUPaOYLcK fkXlfFWf/nX/AOSj83/9sq6/5NHFXyB/zif/AOTt0f8A4wXn/UNJir70xV2KvJ/+cptOa9/JLXWR eT2jWtyopU0W6jVyN9qIzHFXhf8AzhPfrH+YWtWJan1nSmlUGm5huIhTxrSU4q+zMVfmywHmH82S sFWGsa9SLjUk/Wbz4aVFf2/DFX0L/wA5r6zpFxoXluxt72Ga8W6mme3jkV3WP0wvJgpNBU7VxVd/ zhRq+lW+geZLO4vIYbpruGVYJJFVzH6RXkFJBIqKVxV9LX2p6bYRNNfXcNpEqlmknkWNQq9SSxAo MVfmx5avrO3/ADF0q/nmWOzh1i3nluGNEWJbpXZyfAKK4q/SaHVtKnt/rEF7BLb0U+skqMlG+yeQ NN+2Kvhv/nLLXdP1f83ZjY3MV3DY2VvatJC4kQOOcjLyFRVTLvT9eKvp7/nG680cfk75Zs7S8glm jgk9aGN0LrK80krqyKahgW3r88VYr/zmHfaPL+VYtWvIfryalbvBbeonqsyLIj0SvL4Vc1p0xV5j /wA4Wa/p2n+bNf0+8uobZr+zieATOqc3glIKpyIqaS1oP4Yq+r/M2u6Jp2gX13f39va2wtZJPVll RF48DuCTvXtTFX57fk3PBB+a3lOaeRYoY9UtWklchVVRIKksdgMVfoPeeefJdlayXV3r2nw28Slp JHuoQAB/ssVfCn5ueabz82Pzemk8uwSXkdw8Wm6HCFIeSKKoDkH7Id2eTenFTv0OKvuTyD5WtvJv kbR/L4kXhpdqkc8xNFaX7cz1NKBpGZsVfJ3/ADmdqmm335haSlldQ3L22mKlwIXWT03M8rBX4k8W 4kGh7Yq9p/5xN1fSn/J7TbFbyE3sFzdLNbeovqIzzu6BkryHJTUYq9pZlVSzEKqirMdgAO5xV8hf 85sappl3rfleC0u4bia3t7o3EUUiu0YkeIpzCk8eXE0rirPP+cN9b0dfyyutOa9gS/h1SZ5LVpFW UJJFFwbgSDxahAPSoIxV9BEhQSTQDck9AMVfJf8Azm7qumXdz5PtrW7huLi2XUHuIYpFdo1l+qiM uFJ48vTaletDirKf+cLdZ0mP8u9W06S9gS/GsSzm1aRVl9KS2tkR+BNeLNGwB8RiqZf85laGb38r bbUkHx6TqMMjtT/dU6PCw/4N0xVgX/OEGuenrPmfQmf/AHot7e+iQ9R9XdopCPn66V+jFXuv576z pFl+VHmuG7vYYJptOnhhikkVXeSVOKIqk1JYkUxV8if84u6hYWH50aNNfXEdrC0d1GJZnWNOb2zh V5MQKsdh4nFX3zDNDMgkhkWSM9HQhht7jFV+KpN508uQ+ZvKOseX5SFXVLOa1WRuiPIhCP8A7BqN 9GKvg78mvM0v5b/nDYz66jWUVrPNputRyAgxLIDE5Yf8VSUc/LFX2l+bH5j6R5P/AC71HXxexevP bOmicHVjPcypSExUPxKCwdivRd8VfIX/ADi35KufMf5safemItp2gV1G7lp8IdARbrXpyM1GA8FP hir6A/6E2/KP/f2q/wDSTF/1RxV3/Qm35R/7+1X/AKSYv+qOKs3/ADO/JTyd+Y8+nz+YXu0fTVkS 3+qSrGKTFS3LkklfsDFWEf8AQm35R/7+1X/pJi/6o4qnWmf84xflvp3lnW/LtvLqJ0/X2tHvi88Z kBsXeSL02EQC7yHlUHFUkf8A5w0/KVkKi41ZCRQOtzDUe4rAR+GKp75E/wCcZ/y78leZrDzJpM2o yalp4kEJuZ43RvWheBy6pFH+zIelN8VS/wAx/wDOJn5Y6/rOpaxd3OqQ32q3U17cNBcQhRLcSGV+ CvC4C8mNK12xVBj/AJw2/KOn9/qx9/rMX/VHFU+8zf8AONH5deY7XRbbUJdQEeg2EemWPpTopMER LL6lY2q3xdRTFUh/6E2/KP8A39qv/STF/wBUcVd/0Jt+Uf8Av7Vf+kmL/qjir0DyB+Tn5e+Qy8vl 3S1ivZV4y6hOzT3JXuokcngp7qlAe+Ksj8y6BY+YvL+oaFflxZanA9tcGIhX4SLxbixDAGntirxz /oTb8o/9/ar/ANJMX/VHFUz8tf8AOKv5Y+XfMGn67YS6kb3TJ47m3EtxGyc425LyURKSKjxxV6vq +mW2q6Te6XdFhbX8EttOUNG4TIUbiSDQ0bbFXiv/AEJt+Uf+/tV/6SYv+qOKoiw/5xC/Kmxvra9h m1QzWsqTRhrmMryjYMKj0RtUYq9mv7KG+sbmymqIbqJ4ZeJo3GRSrUO+9DirxH/oTb8o/wDf2q/9 JMX/AFRxVEWH/OIX5U2N9bXsM2qGa1lSaMNcxleUbBhUeiNqjFWY/nvpkGpfk95stp2REXT5LhTI QF52tLiMVPcvEAvvir5C/wCcWdeTSPzn0hZHEcGpR3FjKxNBWSIvGPpljQYq+pfPn/ON35e+d/M1 x5i1mS/XULlY0kFvOiR0iQRrRWjc9F8cVY9/0Jt+Uf8Av7Vf+kmL/qjir1TyF5G0XyP5Zt/LujNM 2n2zSPGbhw8lZXMjVZVQdW8MVZDirsVeWfml/wA46eQvzBvG1S5Eul64wCyajZ8QZQoovrRsCr0H 7Wzdq0xV51bf84R6H6sQvfNl5Pax/wC6Y7aONqE1IVmeULX/AFcVe6+Q/wAvvK3kXQ10by7afV7b lznlc85ppCKGSWQ/ab8B0AAxVkeKuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV 2KuxV8df85S/4V/5WZcfpn9M/wC8ltX6n6P1X7J409T9rxxV42P+VW1FP07XtT6pir7K/wCcW/qP /KtJfqX1/wBH9Iz/APHT4+vy9OKtOO3HwxV6/irsVdirsVdirsVdir//2Q== + + + + + + proof:pdf + uuid:65E6390686CF11DBA6E2D887CEACB407 + xmp.did:F97F11740720681183E4E38E2F07D646 + xmp.iid:F97F11740720681183E4E38E2F07D646 + + + + converted + from application/pdf to <unknown> + + + saved + xmp.iid:D47F11740720681191099C3B601C4548 + 2008-04-17T14:19:21+05:30 + Adobe Illustrator CS4 + + + / + + + + + converted + from application/pdf to <unknown> + + + converted + from application/pdf to <unknown> + + + saved + xmp.iid:FD7F11740720681197C1BF14D1759E83 + 2008-05-16T17:01:20-07:00 + Adobe Illustrator CS4 + + + / + + + + + saved + xmp.iid:F77F117407206811BC18AC99CBA78E83 + 2008-05-19T18:10:15-07:00 + Adobe Illustrator CS4 + + + / + + + + + converted + from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator + + + saved + xmp.iid:FB7F117407206811B628E3BF27C8C41B + 2008-05-22T14:26:44-07:00 + Adobe Illustrator CS4 + + + / + + + + + converted + from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator + + + saved + xmp.iid:08C3BD25102DDD1181B594070CEB88D9 + 2008-05-28T16:51:46-07:00 + Adobe Illustrator CS4 + + + / + + + + + converted + from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator + + + saved + xmp.iid:F77F11740720681192B0DFFC927805D7 + 2008-05-30T21:26:38-07:00 + Adobe Illustrator CS4 + + + / + + + + + converted + from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator + + + saved + xmp.iid:F87F11740720681192B0DFFC927805D7 + 2008-05-30T21:27-07:00 + Adobe Illustrator CS4 + + + / + + + + + converted + from application/vnd.adobe.illustrator to application/vnd.adobe.illustrator + + + saved + xmp.iid:F97F1174072068119098B097FDA39BEF + 2008-06-02T13:26:10-07:00 + Adobe Illustrator CS4 + + + / + + + + + saved + xmp.iid:F97F11740720681183E4E38E2F07D646 + 2013-02-17T10:07:03-05:00 + Adobe Illustrator CS4 + / + + + + + uuid:32300939-b1c4-8440-b812-b255b7b0d326 + xmp.did:F97F1174072068119098B097FDA39BEF + uuid:65E6390686CF11DBA6E2D887CEACB407 + proof:pdf + + + + Web + + + 1 + False + False + + 1280.000000 + 768.000000 + Pixels + + + + Cyan + Magenta + Yellow + Black + + + + + + Default Swatch Group + 0 + + + + White + RGB + PROCESS + 255 + 255 + 255 + + + Black + RGB + PROCESS + 0 + 0 + 0 + + + RGB Red + RGB + PROCESS + 255 + 0 + 0 + + + RGB Yellow + RGB + PROCESS + 255 + 255 + 0 + + + RGB Green + RGB + PROCESS + 0 + 255 + 0 + + + RGB Cyan + RGB + PROCESS + 0 + 255 + 255 + + + RGB Blue + RGB + PROCESS + 0 + 0 + 255 + + + RGB Magenta + RGB + PROCESS + 255 + 0 + 255 + + + R=193 G=39 B=45 + RGB + PROCESS + 193 + 39 + 45 + + + R=237 G=28 B=36 + RGB + PROCESS + 237 + 28 + 36 + + + R=241 G=90 B=36 + RGB + PROCESS + 241 + 90 + 36 + + + R=247 G=147 B=30 + RGB + PROCESS + 247 + 147 + 30 + + + R=251 G=176 B=59 + RGB + PROCESS + 251 + 176 + 59 + + + R=252 G=238 B=33 + RGB + PROCESS + 252 + 238 + 33 + + + R=217 G=224 B=33 + RGB + PROCESS + 217 + 224 + 33 + + + R=140 G=198 B=63 + RGB + PROCESS + 140 + 198 + 63 + + + R=57 G=181 B=74 + RGB + PROCESS + 57 + 181 + 74 + + + R=0 G=146 B=69 + RGB + PROCESS + 0 + 146 + 69 + + + R=0 G=104 B=55 + RGB + PROCESS + 0 + 104 + 55 + + + R=34 G=181 B=115 + RGB + PROCESS + 34 + 181 + 115 + + + R=0 G=169 B=157 + RGB + PROCESS + 0 + 169 + 157 + + + R=41 G=171 B=226 + RGB + PROCESS + 41 + 171 + 226 + + + R=0 G=113 B=188 + RGB + PROCESS + 0 + 113 + 188 + + + R=46 G=49 B=146 + RGB + PROCESS + 46 + 49 + 146 + + + R=27 G=20 B=100 + RGB + PROCESS + 27 + 20 + 100 + + + R=102 G=45 B=145 + RGB + PROCESS + 102 + 45 + 145 + + + R=147 G=39 B=143 + RGB + PROCESS + 147 + 39 + 143 + + + R=158 G=0 B=93 + RGB + PROCESS + 158 + 0 + 93 + + + R=212 G=20 B=90 + RGB + PROCESS + 212 + 20 + 90 + + + R=237 G=30 B=121 + RGB + PROCESS + 237 + 30 + 121 + + + R=199 G=178 B=153 + RGB + PROCESS + 199 + 178 + 153 + + + R=153 G=134 B=117 + RGB + PROCESS + 153 + 134 + 117 + + + R=115 G=99 B=87 + RGB + PROCESS + 115 + 99 + 87 + + + R=83 G=71 B=65 + RGB + PROCESS + 83 + 71 + 65 + + + R=198 G=156 B=109 + RGB + PROCESS + 198 + 156 + 109 + + + R=166 G=124 B=82 + RGB + PROCESS + 166 + 124 + 82 + + + R=140 G=98 B=57 + RGB + PROCESS + 140 + 98 + 57 + + + R=117 G=76 B=36 + RGB + PROCESS + 117 + 76 + 36 + + + R=96 G=56 B=19 + RGB + PROCESS + 96 + 56 + 19 + + + R=66 G=33 B=11 + RGB + PROCESS + 66 + 33 + 11 + + + + + + Grays + 1 + + + + R=0 G=0 B=0 + RGB + PROCESS + 0 + 0 + 0 + + + R=26 G=26 B=26 + RGB + PROCESS + 26 + 26 + 26 + + + R=51 G=51 B=51 + RGB + PROCESS + 51 + 51 + 51 + + + R=77 G=77 B=77 + RGB + PROCESS + 77 + 77 + 77 + + + R=102 G=102 B=102 + RGB + PROCESS + 102 + 102 + 102 + + + R=128 G=128 B=128 + RGB + PROCESS + 128 + 128 + 128 + + + R=153 G=153 B=153 + RGB + PROCESS + 153 + 153 + 153 + + + R=179 G=179 B=179 + RGB + PROCESS + 179 + 179 + 179 + + + R=204 G=204 B=204 + RGB + PROCESS + 204 + 204 + 204 + + + R=230 G=230 B=230 + RGB + PROCESS + 230 + 230 + 230 + + + R=242 G=242 B=242 + RGB + PROCESS + 242 + 242 + 242 + + + + + + Web Color Group + 1 + + + + R=63 G=169 B=245 + RGB + PROCESS + 63 + 169 + 245 + + + R=122 G=201 B=67 + RGB + PROCESS + 122 + 201 + 67 + + + R=255 G=147 B=30 + RGB + PROCESS + 255 + 147 + 30 + + + R=255 G=29 B=37 + RGB + PROCESS + 255 + 29 + 37 + + + R=255 G=123 B=172 + RGB + PROCESS + 255 + 123 + 172 + + + R=189 G=204 B=212 + RGB + PROCESS + 189 + 204 + 212 + + + + + + + + + Adobe PDF library 9.00 + + + + + + + + + + + + + + + + + + + + + + + + + % &&end XMP packet marker&& [{ai_metadata_stream_123} <> /PUT AI11_PDFMark5 [/Document 1 dict begin /Metadata {ai_metadata_stream_123} def currentdict end /BDC AI11_PDFMark5 +%ADOEndClientInjection: PageSetup End "AI11EPS" +%%EndPageSetup +1 -1 scale 0 -78.2041 translate +pgsv +[1 0 0 1 0 0 ]ct +gsave +np +gsave +0 0 mo +0 78.2041 li +265.915 78.2041 li +265.915 0 li +cp +clp +[1 0 0 1 0 0 ]ct +131.083 3.91699 mo +131.583 3.91699 li +131.583 1 li +115.083 1 li +115.083 3.91699 li +115.583 3.91699 li +119.505 3.91699 119.833 5.07422 119.833 6.1582 cv +119.833 64.1748 li +119.833 65.2598 119.505 66.417 115.583 66.417 cv +115.083 66.417 li +115.083 69.333 li +131.583 69.333 li +131.583 66.417 li +131.083 66.417 li +127.162 66.417 126.833 65.2598 126.833 64.1748 cv +126.833 6.1582 li +126.833 5.07422 127.162 3.91699 131.083 3.91699 cv +cp +false sop +/0 +[/DeviceCMYK] /CSA add_res +.75021 .679683 .670222 .90164 cmyk +f +162.607 11.751 mo +162.606 1 li +134.944 1 li +134.944 11.751 li +137.861 11.75 li +137.861 11.25 li +137.861 9.45605 138.1 7.70801 138.519 6.45313 cv +138.762 5.72363 139.292 4.5 140.103 4.5 cv +145.401 4.5 li +145.401 64.1748 li +145.401 65.2598 145.073 66.417 141.151 66.417 cv +140.651 66.417 li +140.651 69.333 li +157.151 69.333 li +157.151 66.417 li +156.651 66.417 li +152.729 66.417 152.401 65.2598 152.401 64.1748 cv +152.401 4.5 li +157.448 4.5 li +158.706 4.5 159.69 7.46484 159.69 11.25 cv +159.69 11.75 li +162.607 11.751 li +cp +f +36.6626 3.91699 mo +37.1626 3.91699 li +37.1626 1 li +20.6626 1 li +20.6626 3.91699 li +21.1626 3.91699 li +25.084 3.91699 25.4126 5.07422 25.4126 6.1582 cv +25.4126 33.667 li +11.7505 33.667 li +11.7505 6.1582 li +11.7505 5.07422 12.0791 3.91699 16.0005 3.91699 cv +16.5005 3.91699 li +16.5005 1 li +0 1 li +0 3.91699 li +.5 3.91699 li +4.42139 3.91699 4.75 5.07422 4.75 6.1582 cv +4.75 64.1797 li +4.74854 65.2627 4.41895 66.417 .500488 66.417 cv +.000488281 66.417 li +.000488281 69.333 li +16.5005 69.333 li +16.5005 66.417 li +16.0005 66.417 li +12.0791 66.417 11.7505 65.2598 11.7505 64.1748 cv +11.7505 38.167 li +25.4126 38.167 li +25.4126 64.1748 li +25.4126 65.2598 25.084 66.417 21.1626 66.417 cv +20.6626 66.417 li +20.6626 69.333 li +37.1626 69.333 li +37.1626 66.417 li +36.6626 66.417 li +32.7412 66.417 32.4126 65.2598 32.4126 64.1748 cv +32.4126 6.1582 li +32.4126 5.07422 32.7412 3.91699 36.6626 3.91699 cv +cp +f +61.5352 33.666 mo +53.8101 33.666 li +56.4551 5.36621 li +58.8896 5.36621 li +61.5352 33.666 li +cp +75.4165 66.417 mo +71.4951 66.417 71.1665 65.2598 71.1665 64.1748 cv +71.1445 63.6748 li +71.1025 63.6748 li +65.6479 1.32227 li +65.5952 1.05566 li +65.5952 .866211 li +49.6724 .866211 li +49.6743 1.59082 li +44.2422 63.6748 li +44.1665 63.6748 li +44.1665 64.1748 li +44.1665 65.2598 43.8379 66.417 39.9165 66.417 cv +39.4165 66.417 li +39.4165 69.333 li +48.7383 69.3311 li +50.7583 69.5078 li +50.7739 69.333 li +55.9165 69.333 li +55.9165 66.417 li +55.4165 66.417 li +51.8594 66.417 51.2847 65.4707 51.1934 64.5371 cv +53.5005 38.167 li +61.8447 38.167 li +64.145 64.3887 li +64.0737 65.4424 63.5117 66.417 59.9165 66.417 cv +59.4165 66.417 li +59.4165 69.333 li +64.5718 69.333 li +64.5874 69.5078 li +66.5635 69.333 li +75.9165 69.333 li +75.9165 66.417 li +75.4165 66.417 li +cp +f +96.7446 33.666 mo +89.9175 33.666 li +89.9175 4.3291 li +96.7456 4.3291 li +101.152 4.3291 103.579 9.74609 103.579 19.583 cv +103.579 29.0596 101.343 33.666 96.7446 33.666 cv +cp +96.7456 66.0039 mo +89.9175 66.0039 li +89.9175 38.167 li +96.7446 38.167 li +96.9194 38.167 97.0869 38.1582 97.2539 38.1484 cv +97.4507 38.1348 li +100.633 38.3564 103.579 40.0381 103.579 50.749 cv +103.579 60.5859 101.152 66.0039 96.7456 66.0039 cv +cp +104.523 36.084 mo +108.596 33.3555 110.579 27.9424 110.579 19.583 cv +110.579 7.07813 106.054 1 96.7446 1 cv +78.1665 1 li +78.1665 3.91699 li +78.6665 3.91699 li +82.2207 3.91699 82.917 4.84277 82.9175 6.44043 cv +82.9165 63.8916 li +82.9165 65.4902 82.2212 66.416 78.6665 66.416 cv +78.1665 66.416 li +78.1665 69.333 li +96.7446 69.333 li +106.054 69.333 110.579 63.2539 110.579 50.749 cv +110.579 42.6143 108.755 38.1475 104.523 36.084 cv +cp +f +220.353 33.667 mo +213.525 33.667 li +213.525 4.3291 li +220.353 4.3291 li +224.759 4.3291 227.186 9.74609 227.186 19.583 cv +227.186 29.0605 224.952 33.667 220.353 33.667 cv +cp +220.352 1 mo +201.775 1 li +201.775 3.91699 li +202.275 3.91699 li +206.196 3.91699 206.525 5.07422 206.525 6.1582 cv +206.525 64.1748 li +206.525 65.2598 206.196 66.417 202.275 66.417 cv +201.775 66.417 li +201.775 69.333 li +218.275 69.333 li +218.275 66.417 li +217.775 66.417 li +213.854 66.417 213.525 65.2598 213.525 64.1748 cv +213.525 38.167 li +220.352 38.167 li +229.661 38.167 234.186 32.0879 234.186 19.583 cv +234.186 7.07813 229.661 1 220.352 1 cv +cp +f +191.062 19.583 mo +191.062 29.0605 188.827 33.667 184.229 33.667 cv +177.525 33.667 li +177.525 4.5 li +184.229 4.5 li +188.635 4.5 191.062 9.85645 191.062 19.583 cv +cp +201.651 75.2871 mo +198.718 75.2871 197.99 74.6445 197.712 74.0645 cv +197.518 73.0459 li +197.504 72.5459 li +197.417 72.5459 li +190.504 36.9346 li +195.588 34.5938 198.062 28.9111 198.062 19.583 cv +198.062 7.07813 193.538 1 184.228 1 cv +165.775 1 li +165.775 3.91699 li +166.275 3.91699 li +170.196 3.91699 170.525 5.07422 170.525 6.1582 cv +170.525 64.1748 li +170.525 65.2598 170.196 66.417 166.275 66.417 cv +165.775 66.417 li +165.775 69.333 li +182.275 69.333 li +182.275 66.417 li +181.775 66.417 li +177.854 66.417 177.525 65.2598 177.525 64.1748 cv +177.525 38.167 li +183.616 38.167 li +190.399 73.0684 li +190.391 74.1416 190.057 75.2871 186.151 75.2871 cv +185.651 75.2871 li +185.651 78.2041 li +202.151 78.2041 li +202.151 75.2871 li +201.651 75.2871 li +cp +f +252.417 37.166 mo +252.417 47.917 li +255.334 47.916 li +255.334 47.416 li +255.334 45.6221 255.575 43.874 255.993 42.6191 cv +256.236 41.8896 256.764 40.666 257.577 40.666 cv +258.899 40.666 li +258.789 53.9834 258.122 66.833 252.083 66.833 cv +245.74 66.833 245.25 53.708 245.25 35.167 cv +245.25 20.2451 245.25 3.33301 252.084 3.33301 cv +257.413 3.33301 258.451 14.7559 258.768 23.3477 cv +258.786 23.8291 li +265.762 23.8301 li +265.741 23.3105 li +265.152 8.19238 262.801 0 252.083 0 cv +239.558 0 238.25 10.7158 238.25 35.167 cv +238.25 59.6172 239.558 70.333 252.083 70.333 cv +264.428 70.333 265.823 59.8721 265.913 37.668 cv +265.915 37.166 li +252.417 37.166 li +cp +f +%ADOBeginClientInjection: EndPageContent "AI11EPS" +userdict /annotatepage 2 copy known {get exec}{pop pop} ifelse +%ADOEndClientInjection: EndPageContent "AI11EPS" +grestore +grestore +pgrs +%%PageTrailer +%ADOBeginClientInjection: PageTrailer Start "AI11EPS" +[/EMC AI11_PDFMark5 [/NamespacePop AI11_PDFMark5 +%ADOEndClientInjection: PageTrailer Start "AI11EPS" +[ +[/CSA [/0 ]] +] del_res +Adobe_AGM_Image/pt gx +Adobe_CoolType_Core/pt get exec Adobe_AGM_Core/pt gx +currentdict Adobe_AGM_Utils eq {end} if +%%Trailer +Adobe_AGM_Image/dt get exec +Adobe_CoolType_Core/dt get exec Adobe_AGM_Core/dt get exec +%%EOF +%AI9_PrintingDataEnd userdict /AI9_read_buffer 256 string put userdict begin /ai9_skip_data { mark { currentfile AI9_read_buffer { readline } stopped { } { not { exit } if (%AI9_PrivateDataEnd) eq { exit } if } ifelse } loop cleartomark } def end userdict /ai9_skip_data get exec %AI9_PrivateDataBegin %!PS-Adobe-3.0 EPSF-3.0 %%Creator: Adobe Illustrator(R) 14.0 %%AI8_CreatorVersion: 14.0.0 %%For: (Joanna P) () %%Title: (habitrpg_bl.eps) %%CreationDate: 13-02-17 10:07 AM %%Canvassize: 16383 %AI9_DataStream %Gb"-6CQBc%DBSc`i]lLj#%iD?\Y3Y,F,^Qt7inl&QL06M`ZE$"1hsVF4geKseQZYC9Za\R"?fT]'b3Z*`*]2LFW@,OLh&C4#CHm^o+[?bQF-lhBk^HhGi` %Vg!(9^&R=u2lAmSI!bJ:07RYbH$T"BYMQp;pTHNr2_VrCT@6ks^ImBi?GCc.H2%=;jbFd6hp)4DrnHJZYMXa-2a?4"rmgmQh1WE@ %3.-W4J,<0^e^22%"TGI2hnAj]qcCOM87-fF=82+.5J\G-Dt3R??@DUq3moErNQBRUs5SU8"TI[u^0T1R?2L%VRS1G'B((0FV9U^6 %RpZ*BhnAmFohQ[d^\mTth:qu/9.&g"]-$KK^Ajf7n_?=RQhb78L&Uk:=8+qnH26D6Z7j8o[dgMRp>,Yl/R/7[Q-]H,k*t*(ji,[j %4%irZbNi70qQ%WVmp%gVXO/D^\diOX7sAWqt`[T1BXT(UOW71qc@]t-iEo(c-%3=0+C5I %:(;[Do)0*&4uC*#pZ?,H_QRj7]V,e`MjJp@p&6>Y?ULL$?7t_(4d@h1ar12F1UP:B>31.B#gQVI9p:tRheRqD %CP>fs67_g?^-esM$!NlpnDrg:IlX'mc/f"V]j,T4V_8k]qrL/j&B.$jd6hiJ^VZ3*pWukEI"tbL-hn1RTMO!7qA]&4^-`k'&U]@_ %$Rb.@kS&?!s$;_(4('6r&SC5=I#!0g:Okd(i17e\H9\cgI_g5AF1D9E`Wmn>*YT?H9CSLDUn/7m6p&dcD[4ErN2-W*5qD.@Hj<>% %K%+T4)iY^4KN?Ga).DM34U_4@n*iR**K<:2-cuGkhk:eW&&-L@P*?Mbl2C*@OTD+[j@@4KkTd5t/)m&fKmHMD"6Doe]pCFrGYnkE %Ji:t;O\FsZ4AEMd^I%tY41$4-LAhc\kZRZjD4l+%=!H4#*Nkd@?D:e2P?QcKk*L3Ko@JNSZ!d9dsHpi5e2i0oSmO>XSp8[lchQTo`+bE0s?50Cb(4q9i?E"\c1g^mYC??n3_V'hh'nI'f/iZi2cmRYN`*P#h"4Nf4qjalUp%BlqLAN:?aG3D5)6Cqmkh3>J,(2b %D#Ntq'N=ROZ2ZGC6eT9#EuO#d=7A@uqTEnc5667CO_pO(OA9^Hg'qA)?/(@I>5Mq;^[#D,'NnA6*[l5^cV1$_fl-,[XV3_Ua'Dmim"s.,5Am^bpEc6+Egi>(GCMCr18h>C6*iL3^Abc[d02k6I#8[/Cb2J'u"&1-Bg_WIoX3cfNu7@?#7H&Z&DrCp.4<:;MXt %>4iK!Q8d2>CjRU@BE#sDd>/gMB6I8As&?i?Hn[pQP"cHW%LS2d]Ct%bcXmUYaap4(QK6RUVS0IkrkY\6hrrnEYPsks$odsmUlMd= %pb_PN*)GJn21FHD/BP4"9FZhp`G$F4\Nu4MVkfA!52*PEGJ>G)_%Vb!PR]Vl_b8]tq>fT$.[unA4k/]p$D33fs5^j&c^bUp<;+e6*pZ.->7*oodAg*>DliT(stZVlfBAkh,#6]fe5pfK!g>Z:eod:]F&h@[8SbuJ[Nn!a?LU3h?%d( %rS&FUnZQcr%:5"%^i:F+plmt?Val60HSpJeqitIW.m[?M@qc'#l.C?gp@\>&ug/s4So/gH6RaU:*(j)O#_jS">RS %43_^(JNbtV2Y+U$Z(4a;bYr2>5;=Ybef+2ahS$?,m2Glc/4gea-4[/%Ar5m]BnoKP-apGXTb!1,Vt^J^VS:j#rFB=pqJ"&)lZn5(D&qfT*tKhj])]Hdh%mZCMmeC->I*>Ta_. %VZ5[p(Pt,8Uk2Tm.(!V0D]r>ls'KC+EjiCub*r=+gS\iu7]+*j0.T\ZT+u*2s(q>a;#5F_JB(mhKeV(BKm,gXK9Yk4<+F<*BPk/` %rVt75ZbArBKgfHUJBM-[@UG/"6Q,obX1uXXX;/EtP2I/MN!KKj7_afRg-56bS9V3bFm4.1Q[i>M,CMSM3u^u/S5$r9do5aPbm%:KLhL6!''D1&Dh*'i/r$49;^OH4Nc+>(]Du)QpjdTIm6h(-5rlr(+qXn&`fpMaCs&-T0op_T.TDnE[ %]:XTNX1/-,DYh?2qfff)Fn;*sO?3!Nc/8O"J%YXG__5qW_1;O>2a>(VnET`/lQ:WP.&+nSQg\;;cl_#[pgVX!X.,+2eMOhQhL%bO$hXPB/i9fF9,mJ5D`!t:93kYL^+7:__ott]]J,G@drUf[*S(Bm;J,/+H?@THYJt9[BS*4d_,$9?- %s8BPgYCF.PnmilXIeiu\lgN]CqWstlGN%!6E7)];@n]hFmBa_DCHTOF %4N2BSMjJ[&KXtb>KG7?WH2<#d1YXQAr9!W5F^)T/5@=SADk@>]6@>Kq`$+rJplkAt7c*7H]`imin@+#)Cu6W<6&^MI^g?hcCnG>, %1fg5QBBEEk2n8k1?VG=M]u($KGWB#Dqio9Ead:ME&MF(B0([o\mdBLjGIflm*U`2u\QeLm?+PrkL:KHr^,qt5Vt9G+R*PA0 %RTYg"ko:Z.rRlb4Fh0Z*:T)\3F?%fL3A%.V8uHn@-\$07Ai^t$Np#HeGW[%-oiR6t0d"k!*lHB$9bca-j`#E'+&7UOiGba?,[CoU %O:Ldl%fYBi^FqQIB\;*KLWHKGDF);Qg,\BRJmF%)68p-4'fl`YD&n?A+M#+RYijC-'D5h %&q["#,[aP\<;TYU6Ts!IgG#KQ+I=tkRj''tEop.2/H1`u?!([bf?E-A\t^N1r%usT^.8)=nWTiI.m[BOe-C %=SmBZT?$Zs`*2edRe5t7j9)ulkuHmiXUa4H-V1@TTAUCJ=O=?Hqd*+N<1Nk)#W %0/.=6e=G?D.SO*VnNU+o,l__5I^=Gf[c2=erur4/#b*TTlJO="]'FNjtqO= %I[H71*#T>!SntKH:8f^'1%^9o"+KMM %!t>2AAI=_KOV^"2;Tmj-SR>]?6Zq?7Ut_>9VsF2XOl@A=grHO@[97gf8:=b*"A!U_:_DF2AeC:V8DS,] %D=OJ@A"D8Q@Vp3.cY@!Kot,-+?XA3]lK6!rRt(28R./Sis)",c)9b@&&$D)6p_\3SB-XC%Q^7.XTQ1`+^iL`FFur?XO1tZ9'r$h; %f0bB^r.X@sL2KRAf:u+RI`fJ*jni,_hHGtR"[K0U$aK!`b0qh8X#.r$"j<3J>I&._^24W=EYK4F`lM[U%STiMs)6oh(P9SQ`0+m8gMq]@ABSlX;JNfkd5/C)+?NdT=qo<:QS=+Unip %30Fh0.`nd9i:`gL8X?2?_7ZGZS5FTmCh#@R'-D\QfgS=`kQ0381g[t?)M*Kd?pS([;'.&)2u8-RUlfMBZYWtP>7Se-i`?Z!#g[>& %j\>n10W;6Z`#*Mt1upth(CIe0ER;[7cm>rL=LM.DE!YLRpaNPLn>q3Z`H)C+"t,X/.DWOYKK>bN8?Ukk\UkLkK&dB808R=G\Uju* %%WDWo*Fsqe>s`L-Lu78-ZU>';P3p\0aJeL+!Y%V(`1F15I&4^mqENsMQ7"a %s.4L>A_,dnj'7l/>a8q@IAciUbe0/=4L$DZa%O;-DmOr<*qn+_6dE*LfA^25PHCIYT>c0bJ*;CGHVXd=3Cr_%iC5d=O(-FT7ZB-7 %(3GF_1biVDV_pi;\k.!J+.>`Q3(FZm&E.-jELWka;B&G98P5$lSJPN\5V")g;1K4@kg;-tVC$)c:68XBME5UE,UF@?Q,#iJb_q.l %73\J08W,uNR^fIC;tEo'CP=?,n8&2Q]GY3`"eESAF(`h^3s[q,T_PB3,)4AYN!Zk82[&LU#KomZO28Ac%NK/-n;cJl0=*&*h<*%5 %HA(_V&$NGV1-'*"<'m@B_^:BG-$L=KTqJ]EPr$HG!lmJQ\9p5L^uN4MNeO7P*+qb4I-+7H2pSO6Sp;?rQ$B%B(h1g-S"Fo!=a:b, %)WOtE\!@eAUb:d4*@ %:)%jt>@3$D-:WG(@cS.FmkJj>OS9Uf/2?]%KB*^nQPb"/P#mX+pGdFR&[`dF7T$P7"OVBZBKPajD$4^3ThVo,QnXsWkRBX_PVDn*"d]!asrJ+SbR[M6"^ai8SmKL5'*SBi\+<0e#5:B;q62$J %%;@VRP0SE[12KqeW&(!se=#AET$pn-F\a8,UDue6D2_\$ACr[]GMJKGb*lmL$8I4$EQ9UiW^G4)ObnLKUg==Q+L(`thSknX'Lt!8 %/&j%GDFS/q=@Bd/d.@mf@Dg'X-3=WU;<*W4PFtri4Xmt*aL3A=':C.TU.Ksc6mdcW8)j1!a[&i-eqDF(K@p6G!TjF.Jr-lt%5A;E %%P&&=$n)Q283?-L*'`/E+Sk.3KCPCT^t;q/ngoqtUNBgZ8:^LB,NE<>7L#k;K[usP'+;)n[^.,8DM_4eK#/.7_T9ZiSr`5qae%// %Sdp$(f"%u:@t2NRMg8485'#j2UFn-s?Os)V:McV#'SGB^*bSh=<68+AVo(prC)_G#)oQ9XEVU`p'hh\2@'Z6)"W&=;@ZRoe5-[VVP#.S%;"WoSYI"!aVSD0n;(WrtJKYpP?\#\-4F4s+Pn#^]CfrI=2^rMi)[GdEX5mZHLu*RY#>dL"Z/$D.BN(@o=4_ %$^6FWM)TQTIqiSV.\P>>7CB.'Sa:sE>K2[^L*M&DjF\Lmf9^Z,0,V$9=>_C4A?Emn.W=I@r->"J)"6R4%dZbhV[%AT*5]U0)NEEY %ZjnsCP*DD,"$@pK_mqW;kQs5i'(/n8!j#k1'?>JKP\PiM70BPJUhYL]*!GP8+H3Et**QWAGdU%p,X(':Er_t&c?q`)a1u^0-e+:l %0d:H`E?g;\'Hh-t %UT-bX#Vj*8?%RP%ehL?F'7Ln??+hcYWsWKIAbDtRPL;RpZ_eRrs1E#[;4'jVEr&Xtc.hm#[-(Ne0'Tr3_`kfs6?6PKVOjogR,UA2;olGG!B5pEn;4M4J'QbO1)3Zq\M\ZsaQFZMmZ?JF\Cr>P=[rX>7Z2AHpBs*P&PM2CKGdAWrReTatX`#uA %8\>["aqng01'=\i%;dPICCX!h1m$qVOqSVsjW8d4B2=i)ao41TTeMm$f>36LiGNfVZ](>p%\qLkZ$=&i&NLW#-#B:3_W#\O,,A!P %'!I3/.K+:\M>9%iQ_?Ta?D>334K_O[&lDR@H,/G%OHtGL=KrRHc5*#r(A45L0>`S-[_2_t^K`Y<6rukU.7lr]?GCoBVmA4\BFX2D %A%I(77kF?E=?Lu@im\K?1j>\)LQc==*$3:]d/Eo_GLQ=%m)](Gq!mFap[(]Gi0Rm,hJV$8]'kjQhmX[)i59&,au](P`eu(&0r[`r %;QI$d"K',DQf(in!7c)15.QjcPR4,W'".Hl)21FZM-ZIlp0Zd7J:%k'Tu*O=?&6t=$m*RK %\T#V@A*+8WkG8Js^]giLL^Xo=!X?[pA=#.kK4rn8lI7'cJm\Vs?aWH1KY9L&]IHC^+NalXXec=7Utj`_Ih%WY+HBGV*"9FeG8Rmu %Y\0&:J"YNV)O"9e,q[^inb\gYIY1Q[?1W:_\>.O%a86q$GuP#JpR.qnc"mjnacR]m+NPT/g)L=^-\8.cUHkGr&;YNtO15j,-Bk"f %+3;dR4[U16CGeh6jt,nWH"X[E2s"d9f;93-5uS,4jhmglB5Kkk_QtXQMo)eKCdc*mV4P9^Lb@]?5:H.Acglpc:HRV1-0O;PnXRoa %n,E+b>t&o-pTK'kqs"8)j4].Zl3GOh8Jj/g,:7$@1ujA%re\laAWYg];D,H'5`I]q\8Bg#s7F^m6UGe/O%"#WPOgLpBZ/_m4GTD( %DO9;IB+XYgY"tjmVD"%hnaTBeW8G=;qssa:pGY;jkbcVP-;hB_'O-Ukn,\L>"4#K#f?I(bDQ^scX`^=GR`Ei.MY^L>@"WpZoOu*q %e+<5jPK9P)Qp^NdWgOo!\WEVF1h7%9D:T9/5?*]=T2tH61U6pl$ZRFjGXIK:jN5p.TXl)UfZ);Y5*-W)R1C=nkokO;fIu[$T/_6D %TXpG!#)i?971\u3ZlD\&`dcD"$mg/6`I)oPT+:6A\5jU=N0>795N,4T61LB=i'*^jb/1Di_dr.fmJlMmFaCDiO4[2'7rSADPi:Gj %$YBB"/F&"?T6;-p!L#M@/a-WXr^'hAKmuibj6c;$]/?/pUh/$Ad9jfFZ,Y(2%;X!&44?'g*Gb"8T5X,D^8K3m?g!?NZc,YApeX]( %PZ!p(*-"nKA]P@RFT]%LKcc)_#K_A7P=^3Wkmp,_7'E8&f]5%J84/:pS; %JY`(5GEW=mSR=5K"?iMfRo@\?$AQ1pF"W!`HQ"[J9Q%]&;n3(,leUFL]9,[T)%i3j43t[P1?Y %!jFcf>dChgI,27LmU-teb:gNG[r7(0&*9.G2SSOH2@XIs)p4]0Ze?&BZi?@Jh#3IF5LCKQqHB0\3c$dY(r7atITXY'3It`#Og<(5 %eB.F<*?HU#\9Lr.&^M>u_MaYNLZ-2YQeTaKH/$h24Wqh#R5N1[E$1tu6TQ.h!\^"M4D(kkSE5B8$(iM)L_2sF"NCSQ#r@#ZcW>/= %+E%_[eN=l$jp\X$,1uf:Kd91ZmT+&tgN37a:o\87TkaFg_Gh%GUEW/'RIQ9-n-1N>e!I94>V:e5:g$/Hk3.YlYgDtVI+4saYGbXq %5lq=#U)8JO&Lsp\pl.U%NYbObgQJsjofG#2R@(uo"nC>9Q?C: %5nDO.\Iun8;k@E!GD_L3a1+,?s%nr&95ejIpO_!=3KdWo$:mTCgK@E%.KL^[i'1M'8O!8g)hcMCj3;b;G=H$IbC//>Q^8QC2:?;q`fERO8DRq%&4cK %\CBC7m?_QtXQMo)eK4IFG"X7%Fta#WXGlPCi!0nN1na-*3gl#qd`Fe>.# %.*mC:]3b6fofB\M4Wia9.afgTkV8R*=$GD*[YF4ZfH?eei,,kj%H=,U0XfG5Xf&,Ke%l_3)2jr,f?[J13D?K2VL^7dP%?;3H@hG= %-)[n9"JVk;K')pJ'huM.-g1LpgC7-,'THt4_B\c[N/r@1]+E.nS+&6]MhO`DL\AK:IU:.ES6a$L`M!=U,+rs=1)=U.(e`K=tO&:RI7TFO&Tm<-;6ekLU1SED/6P4 %U$V#4e!,0A;@sCMbtiYmo]^ZfnHh&@D4BmSF(".AN+]dPdq-6F$QP,F,T)kgI3=1>3X@a#S@utEQJ>M9`"A<*l@;RR`()r&YG'j/ %@`VWfpgo@]g5dC9E9hgpNZgX-OIp%(cS=MD)"M_Vq)O3-*(,18+[8JTScaboU@9]@c_Bo?1$$*:1tEpYL3O_cG@&pK0BD7'Hgepm %kCpV<@^t[>ghpP]8G,>J__J-iT9JQ*.o!ub>:m9jb0oaXkVjlp>kDf6QD&ch7nLamI#M,6K5?W[=Lf9F_TKeF&U]5J_tQ38=g4(Y %TIeG1NtY/QQN\TCBUA7SFQ7$$I14DX0gPW''t.9:3-(,j?E*_3*l3U^rXNEud9rF%e@TkILrsNm2+`!FN5c6I`ec?94'k-\VuM!8 %Eh`X^]qf(dY]gf^ci78[0^Yok5maWJ?3buKilm6J+EOJb_V)Me5bbJ(ie-baHC8j?VKD!t4#B"l'+_emI,:*85&UQh$9iBH=+X(( %L+AS$&4gMJHaclhKC(NQ1;VFM$e%9f$B)H%`L`LR/dH)oXU$"!%-=2E?t'QFia@K!+['3jJ`m/<(60Ii$H<(u9*4R+Xk7b<4FeAd)P^\N?qi(!`u>EG^2p#Mc2t.bluEq^kF3D/k]o[a'2Fg][$eLmI5.\'=&LC9Ze+& %98XJ:MG-I)P"(^`EbT)R/kup+dt*2`>sl4f;_`+frQ3mZ58D&Lj^o$Q'GG])HSki@d$.Uk%#II%I$M/O$c+9A*cnU/5KhMi5Z*\5$Y/oiZk*ccgihrpR5mG9R8m7_-`\CR?IQ.[r70=S!O10#.)[D'o@8U#l"7sNeujuqZ$\lL^tBs6*Q]t$>.16R5':JGkJ.Ct\Ofo2+4f6!F6C %Gc0ae?pdV2&>L=9Ug?6 %A&YUDETqaIZth5I`J(oN;n4Ch1$RXDZb9OK:Y#&_W29KP\WqQ43'FhW0uYFSj(dnoJU(Aumu9?bo` %g:^@bIAWR`?hRq`QG#MT6=rkT)CkeSF<5Xl.Sc&Z4Mq:>lAVOa;]7shuJ)ZGGG>J$l%jsIG\t39Bc$sdts6Ust %2VDW]P?jkr.XY1Xj/N"--[/WVG,+cQXkJOA4gjIb6+*.li38u8PrTPK*GrX:6h-%cGIV*O0+/fV!js]%=2XeT^#&BnNqhGC?QY#Z %UToHM.;.tY\'L\0dOP!oXts8'@rW$TYhC@'S&1%u/a':*lOp>"*Yt/m\,k^?A8$8uTh#`kLJ^eW,\M!-IAad&9]?I4^CR2TVVMs2 %3gkB-^A5K"48HkmU*if]i3$FjfVCLLH,"!KVE4MF76b56/6Q%4$O1BLjQ[g6PcCAXjW'#a"AdC=ORSn!>#[$MG?[E %kN"@_DF'>U;X$=!o18&":3Zdo>X,mZ`PZ?LGkgbi<]2j)@EI>8-JnC"DWJ5J$?&qb]OKu5gb %0H.JtZL+8BmAEHkqtffbha<.soMcUIIM)p0m^c\V)o3Wag5b4aFSDZ1P?dgm7jIbbIb@X?S3GIVH`DGAa5l`+]=/*NL7)M)"\L>^ %TbOH>KF;:ZL2^C/(=TYDXNBLbQIU\Tlg"7H6:nX]k%82a]pdJ!XMGSoM?hJ;(u$Ip@*k-qdmh--N?]."g0L2OL%*8$m+n&n]&]sr %5TW"b6RJcqNa^8ufR8l=4a@H'p-c>N+u]Em]-44CEM:i"d["?hIUHptRpu,S9AGHY:5/)InSYu"3IW?\]?3u_m+pARG8pFlT5Njt %-$0+11!)L<$.Z!h$6tjWX$*ejd((c^/HFb %*,/YT\YA]*Et/k%1#F%i4DNSBd4gh,_,Q'8A99`0#q?UT_0O<6rK>7HkV:/sleqUUHY$bb$lbk2!J&q9(VE^TZoO=HC:)mWHR>"a>@)QX&oH90+&p1$K+Rb"=\s?1F2=&%&9r;:H8BCVJl0YOePo@Mc`L %MbFk`5O"(>(q;hRjA/\:KYHW+I^!HRhts>8c_8tQ<^oYHEuK*7/C=1 %C)U5*Y4g9YljufV;Un_0<7f3\b!pN:!qVp!#Ur>PaJ1W[N&>J>BpOf$1muS/io6"#291q(*4ra[D9=AQ6$J1Ms++Kmb`$640mKY2=fQ6OrrV^%6R/k_/Hhg"#!6-;ri%Rr7H`D %%Z[G6#dm7>>oS3rD^4+T!2I4hdXU\m#6)OH\#%a.$Q(O,dqU28$!?6d+(_d3*Tm$cPjL9klIFLh^0LI]Q7JD.*4QY0W*`P5Y+JhH %-QDY\`dAPk`tG6mK4W,.=ll^:S*7laPT_Vi)Y-bF>Na#uP9K=(82hEl9VsPXKJq43@PfDkOWu5f:6OfTGTh/lh.bl-V[Fm-S5%kh %8[h?>ne6_I`Y9gnK&T1sBul!kHmgA*0V%8Gcgm)DH(-.+4qdjErfQ]NAWuHc1WE;kluAUj53J8nF;sQ1j/Vo<1=iMRSj$ran<*74 %5'm7uaUqm*ZDAi;hS@V4F2H!M#BFG%SoLuupL$21k@44mbJ2^`"q.hH+7`%=oZJ3Zh6RE6Er#D=jp+-"#%TC`HLgUbM$FSUp;l+R %!=D&gq;[4YDEZgR9&[o[O8('f.T:UP$ZY),%qfZcOa/fAq^Ue^0%j/,h:D+.Df_tg*bTHWdF6Y&k`Sa,LWdBflrp/'h2a>4mG<5\ %JPY9)9^"L:067=lEsF0"c+\^70\mY>i7natW)_6U!VZeZk`B/1DU+1AL;50Zd+':PVkCq+0j3_9"l&I\f]1\6i;Nja-8QHUX*VPY %d2qAjQ`4=KO.HsY7[]1D;)Q+P*pL4&9=V6O)bgT"4#:Cd?goaA:YT-!J<"m"`,*Fe\f5[O]V_6_Qj9tJ%Qk3Cc/IS(Q,MIg]$;9U %Ur,oR`snkcEokM"a;XXr9]@41l/[c6M#F?nm7!"r_DPZJkU=>edNaGNoGZsZU"bDYm%#Hh0a8*/:9+AI4\?"MKcZHFObM6tpZ6hY %c$9iVV`-bYWS[%hH]NT`([sS2n_Qc@a#ED8HN';scOYK%#KoJ!oi^uQdmJV0TY*+17Q44I/%lbq"GO@CiZgM?n/'tY:G7+T^%)]k %/.W6IoJ&;JJ,J-nV_mer/UrK/jZi?cq/u3B@/KS,l().-7BqJFai\3o$gj3)DoV<5;GIC$3G$SP6D=6FTX8<$i4-$JBJ`q4A<"0-'n^LZ9JpPm-tMgB"gN1kAiJ>lcE,9,[7'G6`q$(UHUsa#Y!Y %PK,Nd:MCr\-s]#3a=L`N&_FTM?>VgbMF^f!e"t1l^.e?S95QpgT5dFd@)VYspQH4q6W*i(D][74qU@YJO5Foj]s`=O@-HYFF6jkb %[@c\LA,;n&o)g(pHLlD\m/qag%O'm$71":MSH4[bp;f[nXL51]4;28s&6L<9VhY.W\n;:O4oT+;HIAC1I %5PHeZVXF"tg(X*]HYj:5:uB<&L%\,UikjhUm>3;M4HMrMCZ?2`#do\*=M&8If7@=U5MZk@kQ:d<#hdWS"Z((s%l;i`FbqmpGF4r= %?`I3YE1o,FDaLpd4Ceb(#N6KP,g2cNZ@$tumILc3cSpeFh_=VK_oVEY,L8<->fePr[m7I4X7UeWaR]ics68QocdtcOMo)eK4H%(K %m$?p4XD>DM9(8h+rn2-(eD4'-F`e&"Qt#ZqEb@;&Sis_=h&+a(2q*k5A,io;`ElG\"gLP7r=eie^?a %-ScR28m9jo-@AL=_CEl^2#1d!llCe>l?`d-4Dtic76@Yjg__YOg$GS^N8fOHVi6bQ %V><#:,O%n(?74P-KV4)Z;m1h9e78!N%[Sk&"]dU`io&l@'K(Pf66V`/Y:RF&e!I&Zh0LVpXN/27?Jq_EHKq(h%M+koQU='f:=SP! %5`,<7$d<\kS*-"hMD8++q`7;jl-J&[mPU:n0,[sT^1d-PJkpTZR78@F`*9g9S44gemPmMW@;8N06P5`1G=giD5ptBf%'8h4h69"^ %[b;a6#!'mBMO6OpWkIAH%BDSIh5.ej3=i$#LdsW-7J_#-#f2OUr!<8rYW4d.D9pA\p;b]LS$>BN\k)j5e^?WEB4t?:aVkoQ1%&pJ@5/l1a\N[-RD*b`fFX9.d^cf'a>n?%,92#G8q`"!#rqcTZ[i\qkrSd(Gc,sPr %0`B7Wn2p]1"'?h_#h'K%`[`ekn4Q9]TCP/jjobo@$lEc!/>J83#-,VDn?fC*%_)qnM3 %8N5nBq94s,Ae=pZGN)uq%YPpNZ,Hj=Ir>:6JH?3reLsY"QNc7hYY]7D/Uhql%joW:fYJs(_<2`;;X8!pV"c!^ATo*YP(r/o@.NZB %*IRE!Th1ib**`>XXG^QmeTnH9IjQlQ0PILg$\@#[t="6DaP^^8U,FBJ%FQ'>ot6gGOiQCF2u\CBDSGp.3m=ug[i8=n_r(`K6%;28!FmTeSL8M* %?oHp?mQa(*Rt%i/g1=i6f_-Z@cCA0K"46L.mADoSr^:Vhkt$&pg<\\l+IR'PalC4/YN>*LJWV$disKCThih+c2$[BGR_ju3TS1hH8h(PVtb(@"h:hUeh^FmIO&4d%chiP=3\6#S!r6KB>'JGYa)Q1hie$).Y)[NlqD#Q/m@qK<9AN %D<)o46ZqT&bg+*hSUSOT#P(5\R!m0`rOSd$"N8X"fO2;5BWneX>nHALmV0odjh.pc&;AB=c!`#OPhQ0h@=Un.fcoeDF=e[N`m7J# %DdYC4i`>NN@$^XH5,!+96Wgt;iabDf#@oRW=XrHbgD9L6\:CI-_q#9HKrZHGB52ta4:@l`(rVPEc:-Z56@!(h+S7lkj %*HGXc\nV*::g2I./pnZ`<+9VaV#e=J^q%HC=^jWMA(ZF%a59X"Ee6,!<._85D4Z1_i*MNPRSXoAX\(P7NXI4TCgP_B5d`Qo39S`F %Q^$HgmWSdoW %BsMTFce@G=+9Fuhbc]2=[fkn#HoFk/i06f,WolG46!;%P$e.tZ;"#/Mr2BrK0?(23L6kI1$-k&l-8_XgPASLis %eBl1OrQTC.S=WMn7@X_ti)!5hE4L*DHYQc@@BiCBZVH\Q-5K(tKY#E(mW$O?,bfi/[EH@V-KT)2(M7U_(Dn!Z'4N-JQgb&9#fC$f %-_U2i@uYOF"MSF*3LI+0TF$toE:Ve-WBrme5!&is9PeHH,!E\+E.=1Ni#j_X1BE(/"Dl)G&2;F:&HjalCbJqI1CM8uY0?FH7RZ3Q %URt]+RU(UeDne1lJak?8qh2G0U$FOu2$edPP5o)d!j"^jmoU5;Q2J4&,B)!\amW$OAp\ %cbe4gb!hp5.4#R/-dO`R)#.+f-O*UZ49:f.(dWl_cNoiBTZf?&X>D]+:%D*t=IM'&YD^_aTLkpfJZGlkQDM8?nEZf]'FWQa6$g`0 %?CL@/VDcXen1Lptn8+]>h!sM^1DO$DP(8Y"`XL0$!LnM%S02L6ORX*bn]uW)D=TI=dQ/;7?Ctt@'G8?<:o(1u3\!9,%32d"jARDbFRg7-(keR0TI;@C(fi"ojF0 %9:CRpT\LDe(7A[c+K.R?9aO3QCqVFU[(sm4?qd:VC6c=??oR]4a-\"N)rnd>!I.0EFE:=_80Ud!6L(bI\`6'f8D0=t3i@%g&0r)] %)),Ab!#T;4(2tt8,u_$)NMHhHcikdU0EH5u(1F9G0t^*m-jq.-kB5e2J2^lrVQe5M;'jAZ>F\5]aX$/)[?;4]@+UU@&daq@&SApi %,R@;C9A5PJQSumZXu;mD<,:/l_9@:D2VAPIFO#g8K72gE+HTYn=tTi?]El]TYcbT\;/5#cZcqJq.]*5^T.d;C8t4V0<'qDqBh(IicR3,?Ykf'GJhG_.MP/6\k'WX\d'eOAld_(?PG\H!ZLFn] %?kK>^a^5e]6LUr!A?(*t_?N\M=g0A$"O.t3<@/,&H30h_:;\sPbeQ1Kl^&a8^2S!hXCT0,lY9lCkj\/d^dhf@b:2::/J]^t"7S>` %=]t3o="CkO7h+[Nr.oVDXDQlBI3N$l9&!p;WZRV90Vfn5?Di"$WDZ.JEfSr+B//Pk9SQon,sqkq'OEp@G_s#3`PSZG"#mn>i1'q( %fZKlba_kn0Wp>V!=GPh:9B=2#*ZDJ(Ao8BhO,c&("$-]Wn[e">[gcT.A-aF<&qr3"#H[1B\]F@'QD2^78M$B7Z97$2Fr.hg[00c# %qq*1@Jhh6_o5>K0P)N,*"mF.(^ml*)(NBqr&f<:u;0.\(`OfNF8AOS'a=G4hBW45S;7DEUcctmVQ?hsq@m%+ %J-Pc=WhArq!F\2Zpl$-EN1s$uBZ]-tirg&_)n1 %%bMi)L78<./02fSYb&R]7?ml\oc=ejk@^i&_M'nVY\K%[X6^NdSl%O05ZL=QM3 %L;aPIZU57">qgO+L;5O"k.kPeJ4gS(Uf-[^?s0(.]V>)a1.EV)/R"mV$E?76G-3#SMjR-;G"%>iPQ_,c>+,4IUUVPqb;.9*VGYuNiR,'Cu*,'@XZm-/h:md1& %!Wcr*1%D]Y(0lK$k7I_no4V5[aN"9'mP#7$R%5FWC>_R'*6%FfCE>bKU<4,_\6Z%?1#"8M`@n+YS?uY^&AbE!TTrf^dLr9K) %V9Z;;88J8J+g_d^PSL#q(=$Z2)mi'@R+U)/">m`V&3)*#)Y64EC[*j)(2cp!.KG@pM*2s19Ra\cg$Jq_A7cMD;nNaF7l5'ZA'i7B^#Q_"] %C/930hJbcIX$6,YgE,$@jAZ-+#;Lk:q"Qpf?O0c1)<8gAKP_;U?'F2@Zg9.enEg?s[le51!#i.&'h\jbTEcHV8JVFm#]hK`f+A1t %?<)$jL]X-/Ys/b3IL"%2J7TKXRn[5ip5i/a#G`7*9rglO@4@JueUSh.d`,eRJ0g$1P@V'eYcIkjr,)MS'W!C""Z_Bea(6KSD/IM9 %c;V_d,og1C&-/A,#H0m&JXbst65/aAZX0e,Q5=,7EB+#s.PYcXYG;AJ)`\8J!-"@Vp&hKfT$P!(e>3EO=]]$:)u32o%!"5=>n>P@ %>M8I2W1&"1B,'.Q>U?hWSSqW__ZhNDnhMJ1<1n*Ieo_g83s&#b0N+j)&EGa%,&7MYgB#rZ!>QlCJ>LOoKNi7j!9Yuk[;s<6*77;d %F"W#>6l8^\V9r;rcCB8^;^!(C"dC9DQZeG&6p(n<,"#!E[>qn=k-mbW/JWYPm!0gbC:uc"^2mBAK*1Om4 %=b7e)1D#(p7L-``V8q?-(hR'jaRTLB(*>H_L)0j'P=UF'/q@[>:WD*FKhdajm(q:Qs()dPe3c4#Ye2o,;dY0GdfZhdbM?e$JUD*B %CeiF0HNnd7=3is81p4+hL.ufmXR%WeO=-q67hqgmePM4)B[>8C^?YqiNFYlJ)M$egDAuR72ZUg4N\h,+(jh0Kbqcm3>bn>I:(R;# %D-]'IK>YiCFln=J6Ja)ls?$hgJPiN"V0VTYu624sL2l %ZR4h"3<#!A)J*'l?o.mYDdfLSJd,hj"pjgOHWD<@-6l!"->6U`8#1i\KSRf<-[:t/!nIonE@q=sT)]^5'SWo=!P4B36Bl.KK49q) %9a`^i4m[\e&THH*!oO-O=7:\_&2Q1AJ;t4\<%!hj83jQI=Rh3n$t-NN3u>f9qfRl"nl'o=N0*36FSn!6D;M8&PNr %T=MI>]jS'\:RXjYBq(+S**`8)X-:oF7dfehIVjLhXFaHl*)XK%1Lj#!PnLT#="Tn_%#9O1SK:$)0Ec82@XNk\L<8i99omj+Q %m&K>,Fs=gtBs7_^'%I=af@7l=GA*FF1e$3X7:'h(fQ!2NH,LG4?01oE,mJuQSEJ@KqH7*63k`cX'h=QZ3EIh5M.M<6L!jA(MW;!#69dY %ks6?&?td$tH1qDkRC>SY3q<:#GNkt5Jt:sZ/f`IE?kg&*lA<)$%m#-b*8DFKG7gH;YurK0MJ\2Sr&$H^^I(27ImJbQsF(g'#PtY&C#(rTN)4>7FSO0R7L!m %h\B0m?3&S4/$K@dfpHKDEra!PAC,%+M])d@aD@LC=o,OF8A5+7OJPB..mU*XhC'F=K6EsKbrJ0q`CRq<'*9L\$:_fM!YlT0:GuT> %T/i`)Kn]6[WN/4,$B3<&MeJUBnRco]Eteqg.<]13!G4kBUGYj2>pr(@:aAo)Qe$>B8@doV:]gpHcjQ(u#f%96j.M>*!YJ&Pq:Q,W %\1X4?=#>*lUg5Hu)!Ujj0d/>^`L&V%D'bjneHV0#U%1`d-inB%Kojb')(:t_N;tVL41*N]_jD^a/BKHHZKORiSTq8"9S*OL;pDrl %2"PpRiRKqpBSW7Z@j)+ME3emkm;D>h?:k>gZD%OEA4IHjk-ta35[,UUAr3.tX#-i;5XJ:A0QiL(\si9TL&=\r3=1Vo/d2\HX7[6u %n@OFVi=tjqiCum+B2CnbI!#44V9R1k1DsbN/:^a(G(]3`1pEeU7+tpY@$F*=dS:&pC:$]+RXJ?atRasII?7:Q(0p?B(6&Ko(.BoY$$g.nrC!S#ShtuOJ\&\E2ai0PC"<`JP*B7Xs2/G,^e3^C>mg[=7?bM9^Bgeg'%7Or %V#b.mk&V3a"#0p9oa;B('F#'I!AI+Q6e=ZP&a'E>Af7eD-RRLrD.'pYKh2T(TT*%$h>9Mg$\1u7CN1F?GYmZA8r==iNNNUsPkYX% %Wk3m5aH.5bN7HH0#.%!u#=LfjZX7AtM8?8S4TZOSmR0N:4sQ_E]#5=2#^T#mBh/XD'Tkc9P(L-4UXf5+%Se;#g-enR/eP"6[/p=KcNu0]j1is1ZJYd%1JtTXS.YkD#`U5`W$=\lao5f/[OIh6#Sd7X?*KHM$ %Xq!p$"3)m[&j6`\^jSFN)Q"X`_"FUl./>L%/>WqFJ!uGB!=*j5b;9nT**VWL$#5r"CI+\HU(q8p2H=^ti"hD[bt$p\]sKf!HdNJ: %K;Qg9C"dTD!ul#&cs-]h:L07qC&hl1(WIQkcP@Fm#]EF+J/4W_-IO*E2$`78`g[O!%^ugkW,-3!Scg1f:iU(,QJ1pJgT#*YY0!l.7QlOg.Eki+2C@2e6irX"B:lSCi!SHa`5qop25Vt?oFt[:C7QlZ\UiKLfVpd,I5k8Dg%n%WXh%bME#A+c_]Z;6XoSR4--/m9F %W!3Oc"isV]R=X%*_cls/HC=sq03C9`_?>,)c`qFuu.o/%a>*N7&h<&Y;n%boCo!lObPIV]5)t5Y@@#k1'YkS7 %H(!_n1n^?lj.gLn;g8S %`!l%$'19e/+aQ"O %g&-&i?jW4#n.r>I`>VEMN_;rq%sT0k&_-5a0=_@jZ3(9%AeP(*F5WPkFB)m1.c*M?6Gk#KP+KMW.G%enp6d:TCCSq,bGu1B(3gR; %F:q%E((qtg\d\mQ&DjGg*c[E-X3KVEDUT.-B*(*7[&b1'_8FA#js1QFI4_6+S0VK-8<6[OR>2DPoD146%"Xf(/B?4YHff)N.ONQ, %H>J.!UI"BiSo'!Ca22jB=iCF%Mpa7QI"IG57t"VQ>m3BH"qJp&nRB_=@'$YJ$$u_iGW>7M!SAC+R'JENl@pC9^E"W/lK05DY)Y&U %g9`E"ET1X>okW8)jg60Hi3..X[IONR6Z5IQ;eNOD*!J93QWIEhV`C'I+^AIf-?qi2Pcg\0fGX$)-fH:f]mbsQcWt5&dXuu5=f+a> %N=d6iF/egUd6d2p*X-Mg'a_MC3A\F]>/qliceqXA6&'I`-Np)pr&#iKGj[*E+@s4uS?$Y:Du#"$fQHU8n)4AQ0OF8W4:L;A9!&.K6(HX+-5en3m,>b]bdZT5r.6`7f5Y85#kXc?p7t][U;s&ujcAmc@&?ojS;'rN"nMR1Z %2^)0=&j6W\.[.d?G#nc<>+_S?SUntiaK3q@1Hc8>;kq(Hlun6DG1hPEHVS?qjG4G#h"6WN);P$4@'`WG-sOOge0.MSrW[V2i*@=p %hFMsIa\eWW!HX>+?ZR?%36G89b5(ji'EF%G'9I>%((PtShO1,[3-G2$$6LfHG&`*CS?(mRkuosjB%fa<(*j-IRo3[Q)NH:Y&Y^O> %%-RhXJ28j[!;'TD^ilk@!p*+;@YSs2eb-\9MG3p#!Q%34cHdA]4tJ=P9g!0fnJ53qa7js%,6e'Rp705lfqAA$=cB\$Gg>KF)OP[LYg1EH$O"AA?I\L)jU,F&8!KnnMTWN%(rHQAZ]('K(e'upJ=Wh-bAH2n[!B$nt73?q];)UFq/MT$X:L_dT*#iprM\KAK"3Yi)T %p9X,16I?4`%[j_J^ln=(e_ChW!A##S/-cRpa?a/jU(BUfAND=F$-GO?@H*Lo30M0=e,G,Qr@I!%2*+W60gb=q8WNk9h1N6We5-b] %;qqr.AdjoX.6bncbQTAe1_/\dc2F"ubZT//*4`(V@r34m9[+3-KfV?+8'/F*70RH*8;P(GWTO]"6qJt8*QZ`6@NC"*;Ulddp!@o_ %%fJPV,D%sFh?4A%k9>Q7'=T[=k$>J1h&\Q*d]":dnqB+]S`&gl@F'%WuD)(@HCr&0OQ2*4g`@,[];CgTc*gQmAR;C5.APj4kD(+Vk\+<_5fl+CH`nEN]T@hIA>imGc1CbtQ- %-G6BMb#$M_ZLE$uoV%k[Nn7!IG@>EnB;V3L]%<^!);=/[i %'C-3C%[rVUa;VKD7q!!HQAt!o9M.^Aqsh4!DKZ?E,&am0rk6na<1GBNJnLtDhUf.D!j2#Tc(":$+FA%O#1$1>-5YM'EtlNV6@!"=*+4tF,>b6i9"[^[YUj*[*Yb[X %r@/*(iW>Dc %iJZboX5Q2FjlO%Ve[/O8;@GQDJ0RC3u3:\'=l!1d*cm1UYN/5ob.+QKt366,NL)Jm"Au"lD7k'p9+TmAH.hR,YG=7KllX)4JS?jMJV2q5WnI>ZXT4f?s#:dkf/pa %[_p06^IV>P9;.gV7l2ujj&%:@AJlL"BdqIf"5u[nUE^(,41O:JXApi\Tr5ij'P*[K8gH$R@FeP5+[&3nPq$KG-+qqtk&nK$$[+tL %DH'pf)O:*mK(oFQ&.@2Q%eq6*)'(Nc;e-N6:o,g-J&KLe%N3KIT-ni(plWU7%LYoP\qB`-0FTR6Vnk-GUc>Zu9sIG9Q-'X\%I#DI %g.^6&#IDgi0j"mH.3Ja#:)rU?YU8"TO"1P?./Jn%[;EIsa\ErpTOkf$GLIV/#!,TccAb$7grl;].\m8>O-bb-]a0QWi7.QRGN>`.2EBjN-2] %^ll2/IQ&d7S0Y$9]^o:u3s'!c3,caB/\%dG#G_RDn!Ck#E2mI;%nPI92\dX,!!1O+.C$=`p<$e!EW?:SXE)C-DEi31nX%U%$l/o! %7!L8G+(24+5IFYSYr$Eq''[PCcD/Rqj)i$;ErcrKQe1]MkO>EgcB2,B5[j(#;$i]""i=+RD),3&O=;:T$;OU,8Ia6Mk[_OqJH1D9 %9W`04rA%Ka4CIT3)/<,b?<#Ik9FfUo8(MUADWjX.7eA>,UM %-DItp[kaqZS15eta(g.A>bC15[`)C/SrLR$J4)ZS.Kl;V4((>-(fn=*QO+4r0UIX(D0r`#ehm\g[i'.XrbS[V?bF;D82uE-@/\*P %@`\D#!'&JVRj#,HGmBnJc%9?A!KO5;YJ=K?,o#WJVIQn5UoqN_=-7iH$SIq[/$'c4'Zf2dMU`Qi%mT3f!M+sddSPbJ/atQ)5P?en-$=i*/go>4Vl?sF`#;QH4 %oaL!Xj+'!Q*E,s09S!1tYtV9:g\YQdDKnF*D3iUD%Vm"CA9$',9,XmOM^5.]^5k]99Ap%3A$e1)-FYO<(+cmB7-J,hs"7,J4Qp%k %ifbK]fZT;dD$uadip/!n$Ub=)JUhCE]"oQo/$1:FR/d6pGF;Rf[Websh/In4$Yqq4mF&op!sNaMo"rIQcamM8J-"O"Uj(t*?:se" %W^Z^mfLa@N!#H"9AZ&NE_9[(r.2XK$8#?qg[Opk8c=M^;".932*U0F!q5@rSicbgD8:PX;5B&n@RrL%d!oB!G.!a]/>YQ(SD0f %PA8kG*Z;&KhI"B)Ucas2qhEjfpF@p#^7:G>bjcI-og4;qKE*'igdTX\[PFe$+'F9d3.(-L#n!Cl% %iPCda7EieD)P>F57g$a]ie4q+nt-Pb\I9ZB/d;[tmP]>n[tQuiRTE<35#0Eg`h#JH1D99W`0dqdm!LG]56Q&W!Ab %9b/Hs,.ejVe+""Op0dLOPAlirWX;S4>D(#"[WQUE_.)ds,!__9W)b0&89%sWfhWnZJAPOtGaZ"R>6aD^"'>%/.6Gb1+Ug47moSFM %@cLGW=5:*mn>,VqOd#jFCTns=]2`'S`e%E'm6IO6Qn)RJ`>1Pd*$i)::`r"iT[_](]jD!0L'M)R/`Q)__BZJlaac)'RBgf=(n:R\ %BpH9KnTk3C@i_U>e(0*J\eWd+A."!p!f"<'AgpY1=G*2k0bc<<(b=+)[_6#Pl\5YrneP).;S%K84MZRK<[#!hn2PmJSeB2a"U9^Bd5H!X#rO:gE(1"6QX@&/Z4'5'lC\:0J'&3Z!F- %O(m@X1C_nF6je$`'J)+b%9B[/kmR_('Egbid'i\l?sW<\W\,O!'Qj+,F6*,Bj34Hq[L[X`7N3( %*@thQ%+8PcaSQjR$;/2^g=CF>Z#1EJ*T_eK)sh?'juAMCo;D'uU%G_Y_AM1XJg2QWHqd&(l't`1ikf`R2:t&IA3/4nVF-m1f[9+lN@JifZM.d[_5lcl%T;lEZLjX3YbElN*E+GG:&>(Rkg2oaNC`Q*>"]=0e&*edc3t<;'s5:9F(>5s!K'eO[L2GLJ+JVJYd& %8%RZp(#28T8lRE*o224Y("h7[*;EQ6O?$'CL-$9QG6TI3jeI@7/X0Y3%XZ %bj#")o.4Z[#CF!g(2qHjdib&s$iQM)C.aF%J\iGA-H*?0lV85h.b5)?$3WX"F7Q0H$>HG&Xsd\NYZIbDg@jqaedW.mju23f,G(f$ %;W5#[4c(+',nifW8^oG$\gfu0gVRYF6X-_c1h,jf<'"qY<:4A^33g31?bN(_o"<=1[-bOV40YN4HNe$oaA#[)dB&Xc"j>R:kQMu$ %)Fls0fntB=NM#7./RKDT@dUsN=t<]i2%?K2d0?8Sa!)]M#=E+>Ui% %/^U>sg-YXefQ*jJ]E#&MB>E??l<"_WBKK$55XM-ng:"\%#4%!K?gbmY=PP3_"4`%l=&,>p$a0"-,g:@"?,FfuL[=$fqDZ %?X`=e,!`XfcrodMlSt**17S"GOIfNWD;3pJNca+WCUnD'eV04'$;aC]IKe:a7^4:0W[u+e.gU'-LrB@nB3DAh[VZgdTFBDjXIDb3 %$r(*QY]/3E2-#WJG7"I$]ObhpgYOT9Fi/iI3H9"&#TYS=>ck$M'dt>M#3N%K;2u5JMKF0GCD71RGUp4fBa]'ZAEQ[KFr7o!FU:F+ %f)@B`B>+>j]Usbl=eXBQm@?"7fDWIQ$O=/?<#IJCn9VhN#:dSVoS+L#EH49RlPl./ks440UFO-hD5Pdj,M!?8$3Yp1?,)AqA"E;h %!(-bhKtB[RC"rMrY\n])/Jl_(I>qdgLb1_SZ2?,7)kH?DF+IQ.js>]kUQ-W\#A[LTc%7Yu/PthB",4Yi>&$TIc?9ATf?\UDl]djH %]h\ju]),QJh`RNt-DRf&"`2!QYW*CJP2ohB$\htFHZ1])i@:Eg=gtMTQJZ0=5c-dXFKjO&@^+-]&):!s5iJb?CYIM1.b5%>Y3*I7 %XDp"U$euYD^B/bi=+ObZ("@b3?Im$%*5rkiE2O<0)EU#G=,e"'>n7%TlB=rMidmFB<7Nf!OiC10@U?\=*p5jiXT^>UlRCD[0bi;[-ji %Sa/YXYT`;O[A6r[gU;M"oSBmWOTItE?nOW-+>$7C]H:PPF=5VGe38#6X7$#bH#Nd.[j;s]jaI@C<*o*1WYW\S97!iee19%n3B\%3 %FV%JW2-L[b@'/qi6+9nJ@Frd!$uAO'*A`Um11#6pn!4@jo,fDM]k@NeKU9PCg0Hm %SDsrl#;QH<0ERP,6PeqA\^CeOqjD\%lte[1hocVg`iPZEH9k;bF6tYi/D%?i/YS91?_csQI(?I]JDIRf.)AsDJgLB_ %$l$,WTr)\qP/4`pr70T3JCf.P$&40-mT3dKDG&u1]uSoH<.KmZP&ggUSE80u[OkNlX=L@ma:nm]!GgLeK,"2f!_) %I*:<'&,!8RM(e1F$bY]'>84PL\EXhG%8b]GWVE$$]0NK[Yeo+F9S"n+K;Db>_:i\3)@GV5jhXi1g=ksEedW.mk.QHi7!*O5lMV%u %J[m7@LhE4M@^!=%f3uPMW7d?Ijl2R`_@GXnbOZa5\##*UH[/BXIKg-l`N@^W[\&Fue+p&TD(rJ!Zk?3]F&BXin=nI8nSXkWcfUoPBn!%ibuBsR`&[m2a[I2b&fr*uk#D;6!X'uoQ!2YFpIhc/G]5OL%B>'b3J``C]D!8KhK5+.)IS<6mq3kA3`gs05MEQV %/fUl4fJf?+Ya"l&Yc=kmn#p$IG"T/JmubGqlM\bJD=12d65^rr@[rb]!>r2d*uLK$6g]7mR,3]e+QPo>gXrkR]`H5XQ_D4R]`;J7up=+.a0iOYEK>cjo.RdX>9T\3d>.FiqO[ie_^7j?Eq`r`&C_,YifK;!-4eOCO`Fs'^+K-9*dQZ'+R %HL%Z+J:YjEVUi+Kk&@V8h=0?+r`OoB,/Y2g.#e8F4pg,'P2S`0.9$oPRZHgB-;KfP/R3!FMbC-gX'@&aZ$%6g+P[^9lW784XKu.s %C'YrLn5a5i/UOB(apeHUjds;J+Bm+9oBBut%bMAkP!H@4g'3(u#g3bRIS\e5@A(m-'n0&aS3[j)tbH.,5PP:#"MXK6>(a %CUn+t\W*2;_Mms\%9R?.>NU7rciW;Y]E'd'XMFbtI_u3K%N. %JBc6Uf4em3HAoJt`.ug)P.4+cfd,"S"Y/c=Z!&8J+?UBX\(;&>IWL19J:YR>U"6U.8XFn.-sZNVer,s67pauA-rH*#MY`O(or:hVLCr7"-pQFe(/\DZ#4f*2$XSUOi3)2h;E_cp3gA)HA[W$-]%+s26.3$m^.\] %DVCs;Kfjc\,p8#bJ26',*B#8[q"r,K+qS$p![Mdj*0&itM( %'J&.&8-bRNd00Y&K/KB8,dhhhZ*$+VfNGe$Q=5'_&cD%2>ud3?`&3QaRW[rm.gU?k"@,:>13f[]SP2Ybq=S>XX`Ib(B$M:Aq>#VW3!r_-'G?^=9qN^uYjW]tb'k)lbROf5UePd/j %YHhVDD(Dn/12Ye&)hW4\o4o*09Q3To>[abU@_l50,poiijZ!uV=b4;P=UL"!72Fe_?c6g9B"=/S@dQEZOi;p[G)F,iX/m-FCdf+, %X_lhiA[$&sE38,j=A)bWPt=C[$eF#\5]>+:bV?9+!e'*U=)CAAi.GB;/aXUAip8p/O2Z^YEf/lC'irQ5D)E(rWGUDeA8lG_J$n"$ %$&X+'L5])AYu%Ql!l.Ep%/(#Y9uGe]6T%nS!A8?bUhuTt[(=61J\;t[KkfEXK="]^0a--D/;@9][Wfo&?$"q-[6#>eAM.F\8-m=h %[;H>-#[\S$/$)"f\>lL0`WBtDrMjEa2>.`^NL4E3((8]n.>kH5=#"X&PunHk[VbJs_7Ah1($]tZlY#ba[1\B$CX!On0"N^XeQ&8H %P"KR@YlImR'@XDSaWs=H'PdrB8h:aZ2,c&,p$jU;-59je2/R]T%+YjuY#`"F(4knJ8L5,[ehRdb+AmfPX/c4s%P+#@/-'"B[_$>[PWna/o.2mBq%bt)lt'Y %C1S,QJZ6LqW?'^dIMo4;*aH0n-celAaspSKAH[(r'VL0K3lk3'"\$SUeqI(3_>a7Q]3]7&+RD@q_(pVQ>"V:F9IG+pWPDub*2cBm %[sR2C!WK!q^_1(>6K`=^'+J&ZV8O4YoQ\,]+F4bnHQ`FFk;C::[LH5S6T/$Ud/&S'XL1q7em;@q:gbfX(i!pBACRhmh=0?\1Htmf %?hfaX"VqKs=t)^N^XIb7W^*aK"182'`#I@)VP^_;]P9p:)mLF5??EM*-W\%1cV#\'N8$^*7W,$c( %[&q+FClmXoVCbJ-g!NZu*S;mH=K%h%2+W'o'gmMR^g`,-D0r`#ehm]8js`d@W/s^iXh\D^=t:o9gL8K!b\T"dTbE[8JPVXf>hZ %0GR\@[AL(nY%$5Vi+h[(%'GnCeI!iCgb/nk."<._ceTqOkRssmS-n6`Zf](MRs1`_k^q]aY%jWl%;O%6.pf\"kQ)S9-U-_;sGIW[hDFV(HQEVhB@Mlm/+1g5`o0P2d0b" %&hM\Y)B7VA`E70dq[dL#5S]\4)4[SkP4Y^@s3KQ6/k9s;lCq(iGN_8KYZL1c.kd0U[Id+fC`f9:>7Sj1>7i5X9Mc^h`%>;-F#]KI %Va's,]N%sqXR>&/h,Gt[fQ;YCjL %6FkiGAVo9liDa!_J5u<.)P$Dp<3;IF.b+gilmpV"l`p\tWn#324I#t\&-A0g8>PT&2)HUO_.]$^CCTf6R_!H./@[jq7^k+O_JD/rcGZVN;R\^^b_ %ZEMd9S0t.N2.omf7o3c(WCL0X/U19t:?LK6e2LlXI[uXMAI+9p;D;AW^k4G![ur!$qdt]gSJYj#%ea_K'#0EFdm5r %Ju.niq?/;nXi_ELW`I1(5Cr+>X?QF[k3OTU39NlL83iKH\XK1.DN2qmiOLT/f*XKo#<_(k;bDDc'+WCKcr %?`?P"Q*&HYX\S$:a](4c)OJ)?F7tkBR'6u2O-4oUq?mD(@bNnLI#E96WcSc?bGn0Bc)n!s'\i:lI53eI[8itOgD6gjW6,TQ%<\51D`;M/\$PY]L]]e]h-W6l2J*8X`3KUZ__K7,3W&(g/?S6@M[.eh'sbY]Cu<`M/Oap?Md-qgVDO73S.7Sm0iu8V`:X#8Ce8)9[9A)@pW=$uR8Sd!L:lZFs-.kA37Lj3i)El"U&T00S(HNF`r`4SkQB\E"P#%nm6(NA!)Jrcgi)NsOcG?FEDd)=WqGlsK)P".0@&/%P/#j*, %`^!YZY-3C3/Nqrg+-fiN[lYl]9%F/*Sl:,hYQ$NF1mZi+&S@,YB$hr"0K]]PY.udjI]+kdrL:NP:1rLa<&Y7@28AI=Ej#nfn`lW6>Ei]_:QLSZ:s72PC)ifl$qc-6U8)F?E<%L_Z3'2? %.KBiVU7<4+bQrsPWA&(p#3H%>2&LK:T,M\%q_G,ZeHg8n?8nFP,YRJ?&8="h,gloi;YZB:sG##ii@%1WnlEY9T]rG*@0IL6GJn#"9@B7)2o\UI6>=P()'o]0d7bGIl!Z&X)>?bk@ %XTGEM.4!Tnjr,LHZ#-,NTQ:_]gBK]/#0C%>6HH=/\[`&JlYR@rVbj@^9USrH^.V4eHB>\5W\$iBaXQ4F'OI^,eu_]=M^^;P%F,hj %0Nun-P0+oX-3kNI2!;M&2)o5FiULM5Zktn7 %goO.ODfT/"bQA//h5ID&YpQSUC:++DH!+m$:81XqR^0'F""8FtlETK7'iLmMkla,6lDE&Z1!8#PRWGE8[)o21Z`O54_D?4E(19&41+[o*a0b0L0_-Mh@dhj/:)%MUV4>9PVfr'CtZInauq5U;m %.ptNEl&;/Ec!Y4dq;?nm-+NFoc%N%%*?G8.%a9`^OFf6LdR9!_90b(X:rpH:jSC6G8/'%1RueF!P7P1/A1aN3857u3Un`Ee#OFk: %n4:j!@qlf.`A/#@HSahO&r-h?Z"FA;G6>Ve^`4`.46g\YX'f]&Y@# %(,&\og_n5+6"QNp917 %1_!_eER?>M8J=+nNq3kR_a.B,P*gjJ9X4`s%_fnBMU.P>fk[&ibP!^I4t$1M7-/tBd#leYe0(PJhe1E6L0!*:9,[d3C7Q-5MntXF %I[0UC'>a58`B)sfT[+&BMm**mhYcp?mEt=A%mLXf^H93D#-\4!c/3EAs%Y]VNnAg0#l*W]a,B9[_nk6ej,7[6e$oAKc[WO9Uh1,9 %OT2+Tjq$ALp#L$MDpRt:i:skW_j@F]rVPmNRQ`ScXP8XT52Mi[^d1=8f^7q%`7[p&QZ$fgYOPo%rpZ9?]CHF2mk,YZ^MCTWKC$59 %M_bF3o;jS>YLC[U;Y&]U(VDrFic5k=O(qbdpiSJ9^V0[hWh#3m0,JQqI[Z6j/A9LLrH*MYj%jm*;+eg"*N-+?o&d!rpHP4_;0+J( %GZtK5pRm-oc]6/m&-)P)m-a*"j7QUpgX47jgUpqdN`d57^&'L1_$5&]`sdoMKh^EP]`$e68bcr]F/MO.cCC6NcO4,<(SBIR%j*If %Dp;8Zfl=U;q=a79md(P"=Rc1&V>kg\Gs7jZ#BcUY6Tl6nl]5-)SDBb8CBMfG.aNU!+1C!1gjV6]E;lcioDeEEp>l,bI8jtl#J(-P %o`-3%)1BIaci7.SPD@3V^a_JIcaTh@J%@V/II:Ce?CZr>*G9p>f8ja]!AaKTY^(&n$SFC,cYlVJDJj)3<%>Q5XuX5, %H@PPFibm&=q&BKEiT8J$s7#^W]@E7+[^R3#/DAtK@^L@5SmA_:hh8H4me2lui6/mk>^TZG]g^h*@=<&Pf@`B:DmoIX;>1iKDc-6E %F`1.O2PH1$^D;NA'23RSjg2-cjkBd14];*9cMr$9q<^tUDf:+#*7oT8UCE&&^`)]X&HdKlr:n?1\:9C_Cc11hh:o-Zs)i==!H"J2 %eWjH7-M[2s`t'7E:9T^h!V]$KT"R%#k9"#K?fL^HO2u]oipBAE'Us&l?0>fYn(rIP^H6`OT(YM*rYrjrO#Hn^j;)tHT&!Z.h#@%$ %5D^:7P<](n3T$&<9.q7J'6*d:rqu,PO,rX_rK@M!9lHMuQiF^k%.a.^MsJ!=BYJ]tHfY"3OUn!NiF7l/TaafM>o+?d15YAoD8Ifg %JsK&m?"4)F%u-\uO^P5d5gec0^n-P,,Bf7h&\H5T_TugX,%d_bREpkBIL0Wa.W=3'Q3)7.aLo0qqtoWsA#eVhSXHCb(O&)LS_q[> %^#/20\Nd[rk%TV^mTDrQtmqY&q/r$b4VpVX;IgKNcf7,H3Zjp/.D %b]]/;26Y$oG([T<*Tpk#Ki@=n':Y!'ic2TKm!nHJZKp7o[T'g/gO?ka]I1S>ld):ZgOj\LZ7*qo`[-P[ZQDi$prcVuC,:qT5*lY>cdb)q4_4G^;HI]k>1*^dOO\UkqsEDI5;*K,E2H%M1+^u^Y=:EG(#FqeaWFSPtgh)uqu<-Lop3Eat#8KV;+A3[d*jn.0XpsA0742'H`1J7K![I`51bSP/8?:)#i]f877S[&"Q4=RtUD"?EL %c..2:^kJC$RG2d`!U$2!qY'A.`prJ`RX42V2!gX4EO;"S7-S7ZTELkc7niEff/6;iXE.d\=5s]F+-V+-ke)@WR/'>Fp7hJ?g"RO&O4cOi%l=LKS[MdBs-n0LlU_U1:"B^W@R %d:OWmb0=riq#U@9)@@;-R?nR[4QDlK5AD#5a\O7Es/nYp=[AWFRZc/k=&VIAttdlKJ8W/,J.i %oJcaG-%/n/2OEfhlaQ'G0!6ki?_,uD^HXrrqg*n^R6,lhnJ!A)jE:N;r<=_>c]1pnmdanML>>UQ_I %RcQU+=ll.73dEB]PF7Kk6=g99\+QT)\'`UEBWa.Me`HZOp@Z*R:I=52O7N!+NUF%tBbomG2Xe9cYHGkR!enb*u;?^hYlYgR!&HVpQmnmLoq %FF0Esil`E^cd/f0,RNi8bDY\q(I-c(q6,^GbO`>!DpRHDg[0Uj_+/ge,qOfDBJIHtqMT8Tb2GE4jJ %=>*5qs6PF%B&du^8O#*gq,B4Xbhhsa"Y1rJfO$;n&bECY8geV>mh*[V-F;0VpZH12E8A5p%t>3[?bJ=,2P3V-]WjV0>4DdGgeG'O %j1jK8^paK5V;a+;\_(a7]?M71(;%h#[s?>H!@s/^D5%Vtbr:R:B!L)AG+6HgZe0+1]?Qr%D(_SLj&NTc/4QSaDhRsFl@""mL"aCa %Zg5W0BMCiX;%ZOb($M+oO/3,P1%?V[WTeRXO*RCGL.\?^oe9uT)O3Pk+]>'tE4D)If^GBrQS[#5.kE:=0f?VYfs4"3BSXXZ] %>sC6V;PNpIkZm[RrH?N4rOLeBQs,iH;k"3RKfX\u3N'Uil1FAf0=P$R"r8--'g^F^&ncb>*F5HO\SfD0<$^V0]1bt$h, %?nmX=,&pMc!tJ,)]<.&(KLUW3NtTmUF\u78CQ6 %2&;t,Xr90jRbaNPi7kOcAp^YI]t6Ej]0!_(gWd]b[r&*,F(S %hVI$nc/1Zhp]X!cQeY#&m\7+V/\-d'#A+oG*5Ao27A%P4oZrT-5CC,>akeC,iWbk1NpF3Y1?HZAQfN4*g&3k;cM-YXD==b*GjMs/ %=SqB2a#:_HO*o!j@K#>NM#6;&ioYAg;r#pofrMZZ!*5"LI,@WWZi-6acQ_66Sk4<,pU9#rMo)qOHr%Cf(56[(p_JR%;>=HuiL;El %/.19@.t"PN05XS^p/0q@SVL_T<5.%a/K3N@nLY[:U@BfRh^40p?Am+<#pqa:ol[H5le;E74js3+f?MRp+)asb7Pcr8gO92t'Pg0(+2emgY4p+7=TkS8[GJ %V4U!!GEW8uGC=+@KZq/IVE]VCpLl3s7Tk(X!c=9pB@e`1+#q$qnS;S:06"i^k3Nm>BC?2BHVFS9VN6At3'5b(SUKGt)a;mImFg_. %!pQB'Z_R6;:E'G@?c);,hjYK-7top3g?u+NUu14%lOS3;O1hAl)]m#lSfPf'5%#HrhS)'745YudLR%STJ?I,_R5t5l`RW!Iur:g)Xlgl^4 %//8tHBh-hCl]mePEG6#Ok)SETaIr6'?J7`cfQFI`iIr(+qE.8][4@rl6,nqd0.NJQD)6lnq_;^5ap)Njh8 %q<+)PaKO%BG16>bSNf"c0CJggHL[B*\US8Sl0ZIG:KQ]u4>O44S\)o"QEtYaUmW5f46Iioj(IPP>^=q^c)m5ojlfs0c5f,1pW!cW %][FLV"iTuXjkR\N-7A)8+UJYHp!<1lBuYgAkA&gX"7Xg+m8OHg@!k!^bQGErqQoM=>-h) %]>bbtTse7i%Q."f_]&8\$`WsJShItSEmd.j?MaZWq@:@C>\sZqJCZc$D7lj2h/HMr]'fZhjRA?dZ=EtrqP@RqIO5>IpN#fs'NBa9@3/-ER5c8D;0i2mLFZS\iONS@;D.o,,!?U]i/gFm:"Ta%mY&<:Z'Ejl()1P*Aln4 %.'$EQ+.8X^_qlglj/C_8o*d;nIV\C=T70]RJ#G]clWbVRC)\_5C92EUVFQnP!T6]1 %Of9+M4GK_tVJ[*-WM:UJ_"PGf!$Q$&T)klYGkg2%AXiqH">HBY5k*V:`^Xqo6dsRhm[U-f^\_D`r2*K$50=p]9XG.^TbE@jA6#g6 %arbd6P<]+\3k&#T:hbiJ3Xg]liSVB49)(m>8m+!0g6r%C"Ul21FT%)DUrlYn8"mP[N/B+,0J*8GkVnHXegj3a-BCF+mhjPhYIKPZ %#]6#1VgE-rGe`6pmhmFQ3N\-&m/,)%fp7L,7deWKV@BC'@oo<0m;/f_I985WQo#Rs=.h(mU(V,K"GE%Y%9I"bUa7/[`*tZu=%c): %Hs;kc[p3&7DT^"%].)9?(JW^U*(Np3&r52*Yss,.aGJ^4CbsL6NA[T0.J"1MY,US::2c?Z2_I?oY5kb(;_M'Sb,27O>jWjkG9Vm@ %U5;8s9d-#t>"t\BVS>=od>(9#c<(g>gXH5cM4;FoE&X.3mT>dC#GY9;?AJ[n5#Gfk2O5J#FPMnhqjMj.nHNhW]==iL0lQ`f/eto_ %PM#u_^5De2ndOY2SRn0B(?%B,7m:R!*07+C%Eq_67PC3RKigZju&h2M3N_^ %]2`>+n`SR+PCDC:!#i)/n^IQFFl"%I(M%s=8C]0`8d`jT]h`7O#jj*mB!gAu)5`i68]Yan+MpD)meG*>Le %c`BV5Z]gTJ;tVK'6,\a87kP6K-XNJ,KYjTrbT'B>+]mp\XI$hrQB)?Z %ZV(':1(81kL6]ujPp=fSeb#(H+2m3'H?ts>chjt4OEbqWfjh9r/9acVPHja8P&>qIqb$i*Mb%=qC+gbXK/C5d_srSF%bjQEPKF8$\M,H2"Ar2@tuU;IN=Ipm?<0t*tFI3qpA38M#?\QCFBX1 %=$PQ5^:cKRX[LkApXBVRN=,Hi-TX1cg`0L95QR/1]NQ?SG[S/$ee&8kTLh?d,#,(rN=ALS,pr1D\iP(+h&dZ"]'s&K0t,FrR]"h9 %I7kjC*\-JIoG9?ga=YpN6!K:9FY9T-9X5fIcOb-F1XUSGQ&%>Xk*(nRn3d@H]V^C5Nr9G/KYtfo %hoC,q(h`.ISa5@8;*EPn9`XB!J2TBi@h(RPk8m1u:3_@(r,h7L)tkg#@])QP3l^#W/.9j;VB\de6AmXZG'f/+'-EmP4$IF:(@q5C %I;5R^>tJTf=ql-nmnK8t)nut1+ZZqiHO1XMuhZfB%JK$Wo#bS;X"dY`Q&6kKr`bB=cCVfHol^]r^:Cf`;?ipYjd2&kC %U`k/IGI7*XKLs$gW,Y)E!n5"2bhgm+Xt %Gm$g^C6aRqFM+W8!2?h+'qRSY$d8]M^.ag"X(nMV2OS\K&hR1)[X#/qT9k13_t5]YM6K%p_lJZtGXW9KPsqmT9'CjU`sS*ZJ\X*r %,G`[%J*C%&r0TaWOI1$UL:Jtl$/%,&o4Y4/`GL\AMrAH`WqHbmP$rZ8^#Q5*N;QG?dIq!Kp*+ZqRfV0db4.d7lm#5WpZRF5(kU:b)3RE^t@Rk]=M %isd,Y=0$(s1uiH&N;$I2h29I%[l3:6g^JA=Q:aN9^dZ%qR:fWe:,u`5NYrZ!@D-lc`.8li9Vud+B#;1@R.c6G2)Y9;giLFXK`O+i %'CA2085,?1O\c$_\Dkg(CTQCRqQYsUmN[ae.0GDoD9>raSYjCSU>46Xo;jc[>2n$hp"[O^D9<"5B4+npE_Moc:1&.0Wihlfq7+Zp %;4YgsDONmOOgLQD>ESoRRK.'eplsn"d5%M2Y=;FVU@lU8")1+,A(>OC>pZi@(kW'nP_lVF/FTB/6ihNh3t]>G)omN48msTg?L[UI-1mT3$Op-J][jq@>_iM9)b0Nmii,3_l-)1^4@*0> %`S0.*&Wl./%;u*!F)a.#4JZKWTOJ"o1_=bnfK0gGa)Uhc%?'<_F!>cjcd_>jThLd0cLs#k?Mh6HM*SGl]Oa/o-aG8R-.i8i$k.WP %Dm=Vf*W)eIL]]*dLdTE3NTemE7%?'UBj1)/Uq*P(%c>2O&Eak;4$F)5&st5fkB2Z_ac!-0oCI9h8YVr882[A7U8nUjVH4DDC$!K6)qQ:65N"EQ=],:I25_uD"d%AP.BEADPXPt]?emQ-aj)pkE %op?Z;j>*R1&^<%)L.mb/Wo`IiU_@0t#f#(,j8G-UjrMF#]23FXfR;VF_p.LK_[Wb[4@:GLi=e=8hUTgq9r.+sUK;0,1K3<,m=99J %HGr6Y((BMC9P/faQ1brR"@Y:U8q:Au=#&TK_oP6b<6$@Z"=:'A4?_)/`V1$[kJJI*I8b'?L3LSokK1QPKE(f9i&`htIK0#VXa^$A %ebB)Z$M`V?>(-(8rb:ViqVk4Ujf@r^]Zc".Y"7abaJZ:\)kGI#iDSHcZST*C>BBEsh=e71_p^ght %`WsKX+;pe/Of=:7V"'RIe)3+Mpg7;Op5:mS4E60%_B"DS(g-B#:[s`GoIEe,%0h=7"hDG;iAUbO#A8"`!It2FFN!3Go`F#g)-DGNBXfVeHG1"9Fh/AUK\!'?5Un(+gj2?2[a@^CD!U0IN%JnJSd3I?WZ(EJk]p+" %H$DSnLV2Gf"K^)$Qq/#_):`N5QaR+):QgHH7@bb$(,KW:UDj:aZ%d*\?45/beCg$*dsWt %d;m4%f@1h0ke@Ip"cEJ%.\):CB[c"$mK_t;*:.;IGX@tP0C2E!1aBK\1V'sJal2gaBjEF63&Z`M.=VCZk#-,^t= %\G!%]FP`M.Y/-5gT9HU8C74-r,JCF+>6s$q.'XYQWsb'BNJJ.WO1._j*5WduL'VN).[H+o7+L/!2YA9MJlA?]%Af+QF1ksuD%ML1 %ICp]hihgMU\5.FX^h?SbS]Wpt0cV0!m"1@B1=smm`pRe-Q.bJRpE]a]4M%M[FCaH,k2_BN2jr?s"//:6Jp;gN[hR;#U1)6b+d89P %BUBW-)snF=^%r^Q5Q`W"9b#C=1Yb'q]572U7lLd660%goq*PU\SRPqA1VIUZ=+TCqd6_Dq;k,)@0BmrkHK^PSd[o$G#U3_q/0mJQgf-rY0mHV_5pY-C70V892AR(,ei)Lh?Y-tV! %^^IT]nF4DdXi,uJhJ=K'5gS:lKHSdQ'I&A.[hU,iceh3RTShK\&o8rJ8smC8hb'D(`@V3"WO@LOJVmY<'r\X-=:oBT>6l"mjlW:k %eD!I&O99).nNuS#RgU'0K0T%-N,&!BB$3Vaj4YijE1bG&*+C//1&$FGd$1]I\NRY*nU0qY)*3k8i!oQ?B]?071P9`\Fuh.6:a^X9 %BjJcNC^1YGq/N"?'YkV`%?3iZD7KdPJA/*?@H8f.5`=.RAfTRVBb*4ggK"CT?gN@%)C@nT.V>4uSVFGEQQ) %#b2-E6;s,(G&i\!P6Z>Q0PmW*&;Ill@2VZU&cgC3YJG/%1t:!=5$)*$)l8BNK[NR7!rpaBeS&Mb2+hFhd_(fYBsJOdR@OX-c#O-@ %!A:'_:GRZZ-`1NhqK@QuW*ueKMs7*ol`L5VoE.:N*>kOp8_:eFId.NF9FL$1@U`*KeK,d)X\o_$>M0ZS-"(W@E(i/&/-BHgQM_KPlWMh+;FO!jf)=/q$p8n?"dZf@g5D-50sT^+O=L'(dG*k]S7[naVi!GS?SF$ %4#$nb3Yhi$9nPae-rU5)'-cpccV-)VRO4#Tkt2GALr@C9j/)QB(JM@NA)F*X@+s[%C9,\BCMIA+"S\+l8Rj2:Obr='A\IY#_;S;=B(QZ5-/<^PN*sEOXA":6f=3+@a*2Yr2%c\RiPaAB;/W6"$=]l@)/9_V,3Rmk/ %r@*c%C*@_!qFRH\7jB,A0uk'XTMEW<_#(+m3M`c*Vg/1PB;Vg`o.oQ=0FHc,pnmpDdaWKWAr8,%8"ekG7Kdap%p?ibCW@FLI_ %=@3Na.HTG=0?&@N&>,UfYfVRf:"O@8fEa7$>1UuSo1u%$,(8un\s+$Kj$U]_PQF8G)c;LJ6&rQ]6[*QV4=!_d'GDr32/4lgBe*/T %S)O)j%9.a6Ff0"'U#=<,&XdP6C3#d%gbj*Bep^pm=a?tr!\?u*(u758emc:8Eq8!k=1V?IVY?89&E]@GeX/OT"X'>E:PSt+-c7L8 %=rhlN]7kEa#],3U.Cmm'01r:HfdK&Nj=XFB%'MP*`Pi1.!ae[,rb7J"a#OD12^8+FW<4C4pgd;e[8-@)k_[+??mljf(7#8AgProGABV %Q#3X;RNWr!g25A=#<"2#e;h\_fAh*@>4[LL?K33nq6tUUe(T)$596U%:`?7I09%a0%?.Vhs!.+g2L/4lSA?Pqh8WE\0'+3)Y1CEr__L*!]I-s@4d.4?M9XfhcGU+" %AsDVkP8rXnrZnr6kqoO&;.<9AF")Z8f4c8'Wp$hL<>A),go@+JAk;o"N`C"paV!Dp]t'BkFS4hOoCM9D8hUlVIN]>%Cka(Z5B'_d %4`h\hh\s\;!BB@CdL5`a8F1R?Y8EPlfH8n%+PqJUG:rB?2,]97moh;/r;h0M8Fu.P%)[0eINe\>l%L*tXMjS;6+5dm8Q$Do<,IlZ %9hl\EV!LE?6J/\N=""aqGAV2PfnC8^(.098AR)E!0TA(%hd+19F%b"&F/O[Npu%\gM,TIEUtIW;)G0O5S/;7Fm++d\87*A2ioZrG>1\d %95*NK0MNM]?qRFX,f7'ZBVI#T74&"rQ.L&!B9?p/Pc%63fnCFD6p[>(7c=Kr[iTB*9`CTg %$O&2m.e.#Mh3@30Ml,neNh\$*Oc_P5W"Bsqn?1;]Xr6>u`gLJ9.FSp-Y,US:c>QW.33GMF/HN%Q;_M'SaX/ZP>jE^iFs;a>U522p %9d,us=A>Kj8:C&!d#hpgVd_9Q&7%C[j$P([SOacV[<.pP?*uC%5`?#fGg36TJ@Cf(BZ>J>HVZC,jNU3L;r$nuHap%60lQ\M.HUg( %5)^`@Qso3c)P)kuKp4GM.UjoO("l?p`,g'9Tu<<2j%:U1B598<"*L!f7saF,,SosnUa/!p(FjJ\0$b`L'%O-2W_G,c6Q\e.6_e %m%^rRiaGYAVM$^OC[9e,@nBZ1-rghA%#nPBRj;33o"U?@&)]\6"`mXGEE^iKh %L+Z5djU=,GAbIHl.7[mk;qlrDn4ZIn_U7U/3e,35oCN;1TCQJH?mmI;TuMX;0pX_p!m+PV)%/<#\L4ZF`17Q_:0_um?B$88Z%saV %B-K?N8LpUa4/#8:?/)DGI+0\(nV?6-?O;;r:"7b6i/[Fsd*eUt3VK$d"MbKV%@nqi)XTsNFU??l7)@.@=PCu9(3il%a-YWRJ %3[@"T>r=(5fJ0;.1O[Cn(n@D+lZ*=o'3!Q&1I?)+SYWKJ&c[ZnG?#];h@.j?naBu0@@+:u),;]X7:8jJLYV[`2MoUeM.:H>7\K+D'8(H2$.bL,Pf9r#=Y)g]j<(pU7<34!*!$s^3OKnF- %-EjaaPe<%>9dl,kX2I5FR(Z7]lug@$lufGUq/pc'3p!=tcKXZM.eb%VPiVpdqDu/;;RFWY1\'A=Cqbd2N %4%`ir1CEZ00p/Rn5r"'r=`Lpn$XSsZq.X:KUCCI<9r%mQZA;IVJAk]PUR&HK/eMj7/d7j:/1Yu<[B^7P9XkA[-DDU)%c#iD5"8:[7<3!62jp]YS@c/GNS2*J_k!C]>!lLWf %EiEZna5jJuL"/%j?9$";N@U"KPTdVWe7QX=Ru/(B@n,9RPanc*)5:%OM3c=M"rX/I;"/16KHe]9Kf%+J;l1j9>uT'TSDU2;nDHG$ %VQ<]p03!A&G3.Y#nmY+0-tdTW3ke[`UmaVK_sG`u,Z\Poe4Fd!X0[R?-\qE#O"*)qm##HdW(,odW2,_+1%2!r9l\32op1drNONb' %mG#.W9:<<2d_^U %/]i^4j?cL[Td@72?BP[Jab@`&\g/(qOp`k`KpLdd]96D);3AtaGMp[!iIV47^/H'Z%B^$H0qMiCiYYJB7rVNRi>&.lYsP3gB9R+\ %U**LYH/QY'++.$Xe)IE>@q$O"\G@-ji&@a-MG0T(b6(6%M %\hmq_;_E3)q!'Krd;_De2n[_,0lmJ/co#8b7VZSs?1NnIo)jo3MJlJ4.J.Fn2['o246"17^)XR!ZS*Sg>%X:Y9nF.V-`h[Dh/6-/7f/f^F!ZMmM!Wdfm<\Y=f?V?\u.mp@X7!CAdIuIs*>S^=k %kTG)_@Ok%s>(4V(,sE',fY:&s^Z>V:,;,FRSQ[;B^P[JNbcY3Xhe)rHpdQ']#XVR*cI`O@O1YrO[m'u11dO3/Rr870*%Zj?>Jr.k %ph-Ig;Xg;uNdpS$Wfm91nW@(?')Lsf)<=AMH2aqL=r=*-Q6\g7MFlE[/k?n&+'if0YbO&,N);AsRhKD7GipO"*n*'8.qU;8-WVIg %!\Vn^?^:_,]R:ToMs4L!)!APneJ*ACC#ef8\r5j'#jJfP9Ogb%5e&mSPg:fYCT>QrQS>DEB/*U3@jVT/i%doX*`G2+Hb:3Hlb*8W %\e\5Ea*d4K&+QjIq:;Urs%Y,A-!I14=7e=8IE\8-g!m]-h9j@n8TsPH]^Sq8-PuQ@'g5GtH3PZoHqr+>(*;VNTFrBsZOW-nu1EVffqc[=mDS(IE*YKm,ZIi*';)`-aNtBiRV+SMcp9-^?eTS(ZlM]hN[i_u%n^:$2512'Eo;J[0lF-;m#c,?dPoG%9JdWbksrQ`.H[J18;G>DSXZ>Hs0LaTdI$`(Z0sfj)m_OdhhuR_Wo9@1sNT2A\:a6b7ueq8Ji* %6r%FF>6pc>:pddcah^Q<$D(42$dhmfD(9r(!&kTVfiGSM!/`2*/OWF:nJ4G>-u*fX'\'ag+NfPf[Lbm\E3tTYb046DEKh?fH$[(e7$=]hm@X=(]06K*=VY6 %=B1+^*H*1#>L%"1U,%QQZ>6ZliET"\@i#iJO3uY9iOE]*'g"IGh;Zi\/S_eK&21&Q]jF`GZa.uk]ZJlJJr)'[Qg`ij?XKhAbmH[# %i%/WD9!H%hHk!3XUi0^W,(rJ^*2t%1:l*^`c)!^NQ98(a@N]X9Y*_8`WG#]qI6=FH3EG0uf+H%'n!b,X[eblT[O-77T&5g>eBpJuUG8$kZ`^Xk4`A<%-TaC.s&I&YWCg"c:]mV>T'n]`%1HN2i+6Z`s8^F@i-p>Co:O13N6UrV8!F5iCj1`MW#:X\A.aON0r_$:fF2A74@ %)!ho;-.>oQ-uonKg"bL1;bBLcNcaQLIE]QjU4Z%19>[J+^ihmGo(A)pJL5DWLqa\#BL7P3;=JL3:3BP$Qj&fJ%V=p]eUf!pYi$0s %e20TXl=8i?ZgnC`n.!OuDHRnMLSTMR2AWO\l_2>Ti %$5.4G8nmI&BS]-oq]H* %QT0,ri?Cq&NL+oBSKhPVD>ef[2Eb1MR1DNShu-m@5pb^V2[;mB%OhVV&j8"2i/\Z!W3@eQ.$:p#9mC8VDXA!08/($G)O:nW@[EZe %cbM4QeSOUa2?HqH`Tkr6D_lp?Ga:Z/KJQ>;S/hpQXchV.>,C,H!'U%&N2)!r[s.20.2feMJ;1s)@:K2(KD].2@0=7&Yh'$*@`jJa %e4Ys>$1VHA=-nDXbWijHS4iWS$\$``"QjD)kMd@@.1d?D2"r24]mD2jH;2.C`PB!(#s2n<%f85Wi/0NKXT.MlrsXaZVb5+CJhIF` %nic#oQWmik:(T@!_2\;Ae#P_@rYZ>=T@uukV-XQ/Bj:sR7P1!)+3,%"JU]Hsgf4N8j)6Ug8G*;*Ai^/+L=9.5dZ!@o)0[_gHD>b! %`b=Ff';!Hj.e3R\RIPAWiu((Q@a:Z#.PSk(q-$b,pF[O7aapF,C)6I.dAI-Z!HfsC(+YXQ)H@D/B[tN@['^Y?5^0T-2>\`msicZh#5>)$Ig)/a %'a48a:WkMjnm'B.>7:eVis^l[f^kZ,l8^l*M>D>;Om(?ZNXr/;T,\,S/jh,$WsPIleRY3jeFT&2ai?%5:QV+'J^/:269mVmr?)FM/dQ9?REX&u4IbkmPm%(H(a4j6Z_KZ:Tg]C,9+P&[)]\Rm/lRP+p`0X`FQC4.T^G@`>`CK?P'C"T=fNN6GjRZ4c.*\ %EA)s,$qdD5*u@'%5?kFF)afNW75S/c2=\-*1j(FAQ1-;3r@\&!OV]pAcB;p1"TEM(Q,keafG#=sUB/o1jQPZP;L*T:jK-e=<(B,d %PG]_),aOKc[A97KZ!OLUs/3EPJ(!qAQb)NqjEDI`[8s&XZghWu_8`gUgm/*aacj+$ZQa;ABU(%QLqi4,K^l501Xp %19c\+\r)"!/d/9I'fZ-)9\E9E&`n$OibK22)ClOr'Ve/71pC8 %Usj\>&FOX4*R=n3%$9rT>W;[Xp2l=\;<`?>q3B*g8nsW;BXg8Q5h9u9Rt]YnT^.EG9_SXD16N_MO6=16IENV/1YnMo;WA]mD=eS' %S9pRN4fmWZ2Io)`@QRWS,^fXhX'?B[Hl'+s5#Be7V:/S7^p`Y'B]EFhNg)n_iaR.:cN,*4'2BJP4Q1)Gj=:$R3Cp$<,AED*m[[l1 %Nk;4c3HtMaiT)#t%u\`g6[[]jG#@r(Z$dn$l*Z5sFE+$!_c3%45.JS[oYjNODQ:%O=`Ut8o@Q"TC6>p(PkP%6m?[6%\K>[_.5ckN %e\<[EG!0osX!&T@&]b&4Y]I$26=AHji@qk8Gg<#b`Dc<_9Ul(!7J$!f=8%'F>dk0k@cF*38Epf+JNrA9JdW*33 %@P@!]0#h!9m\prq"JDehoXMaCH=?_k%Vo/n.fX2mW5NZhdJiN %CnmBI=:9FGU&H&sf@p)XrI4EpDb6;/rc.VCUSG$S5j5uAeiJOW_8#Mj_CITq=h0J@`9H!etHAMrXdRaC7f3J:5D`;n?3'Y$c %I7noT$j:UiM!$2*n]M+?7i=Hel&WUUbFdgtqWGJ[gFN(6kC_F&gRB;1hgG*`rXB>2gUgfGcejtif=ig%_F+'&cYqXlg)TYUbqE9` %c1'IE6l=3t9C/M@fiY/af(,S"$LUHp,FE_2EXGRT9$)#I#B$&YA4j[h'q@6W/67VH.^;?A$1FU-*:lN+oWBPXQaMWgPsjE4TeH@% %Pa4E$'9(?pf6qsR)01R2n/t?_ZLp3E*YOg!7ik*AO":Zo%Md"3(:M4Gir:goBesXm4sWp$,q2@G7fnXH@*<;u5*WG6ai;B"DjDURdsk+0qWA1bpH:Fp4hr.ZGT8USiu1_W6.V>IQ^$C:*5!UD'd]PH`U %>#;flX=Deop0LS,@B2Y-YX1f!D;,Oi=ULsk#1H]M'!L\j:MSQmD*=cg]`OH"+;_.=lQChjlIO2:\Gu6+#;qC::ai2n@teBL70+07 %X6OJO"V8.Ec*LXK>8LI%1,<2h+A %i]9n551mQtM!gBhf;crso4QQKX<;VWI_q`%,?8S2R7_)W-^.RNP&*?rt#R"A@V) %FeD?l8JR#Rp-)<8Bd[TTbDF_XXd2@O?!SJJ1b06l!7Ph;4%"nLc*`L)ZT%5u/a0OA,SPi0pt-GF$oe3mHGFan(F%C7TK[T%M8c`![bI[5a#Bd%ap=[*9%d$>nhj4TNnZ%V8euMKiXi\ %Bi]e^fOTtLn0peSV9l!;n']StP_5:mHqY]o)Rj!21EF:p^^!jAij`$O_"1-t_&1:p!O.I+RNbqO128OuCB.Lq=UYZW:AL@K4JX(C %SUa4YWZ&u]Tq.PBY)8!/sigt`Pd`1g;ign%jPGeR1ZQRBYc_]<]QNX([oE%j#s)Sg76Z;H3IPNf7Ap0nPaRX>BFO&=k'E@8Lf" %P'`9(?QKK#YVnR&,<,HIFCN/%O1+9lJs#?+*?Ti9>@TWqZZCk,C6^SGM^tnH*-bjU(2'=r<[J@NiDelC6^\]`&r1-\2*\('5d]Ap %&qfd\2Y,T6JJV\)a"k8L#DH-d;bkFO'BjtoZ[N^uKF]lC&\#(Gej_&A>*a%-9Z/-l/%L^Z-> %^fPtY.^b?WW4']ZPbm8WV/`PYQ]hGE`]QQ\G`)NLqIOXi*Y"Ubcj=_+o]qW;L%=i2r`LRh3oQ963Ne-9+n3I?2-:H0gu>-Yi>@>; %*$[;[GeA4?eM-8c9]keM@''ZXB"aA"NZRBYGpqa,Dip$Lp+e!kO?84aVZbjXY"$.Gk%Qj-76L*`A33'MWc=69cm.X`=qQX:>jqOB>;2R %JG>V$7p!)Z/A)2Vq4eHI"/[):%5A[?6^h!M%0R\nG5Y0cWn31tJCU8?^lfgK'HYXnZ>2t)#c(8KOms_/GLZ-U9E5Z/j,YO::3@LT %:u:gA0h\2OG_oV6!Nj]eb-2=j^iEnrMG*JMjF\/@6UeGV4f;)H0f2FAaM7n@4-ZV\-d %?O[$5jHR,0*087_IeuL/phB:%O^Z3Npr^pGVWq'k&Goj/Z*-IHED(F5Pg'ba#(eDJDY%"9%UW:Ad2cg2KmW/OOYDbpCI(\@jX"ck0Wgn;pbZb\VVMd#U^VcVQW7YPP9A85fo=u %i8b=kNgmG)go9i24NBd,hHm]bZ(.g::+;7InuM84D:aVf'4:U%kNU1RjS/2\?mc"TmIUNr-LE_WH>b-?CaNgP;cF^-%_UVcVPCtd %fs[@$ktEf.9LhOl<#GE/S:9'k`GYOa7s!blF6?33qGN=WYs5[oabPghqs<)pLepXWK'*rLpkZV%;>+ig.F>\Z2;XV"9a/ %fYPEm9rHm1A^]HFJ#)m2PoH^i=/l-,3'6WJ$*:-l,eNbpBfiee!%]bA^Jjm+FrK3SJ`@&/o.rPg.^R1Z>JtdpP-c!uf5$$g4;s(^ %)^XNCr6aGSj:I.M!i]2jU3LZ\pdDM@:Sj4b-Wc[-^:)0,ROoJVdWi:t,QJpHnaH*%eP;D$`5UWSTEVu:QQ`/C(nh+I+oOmrl0mDM %Wn`QfDb:73;lkd#@cm/"4/]'9+[G,ef07-p:[%rgu1OJ((X8.4Z@c^W4D.N_UNB&%i63gbHcQS&Jk&>eEf %lIWOYBiL-)PBZ2FaQqB$Elu=ooOM8U1I&\YeL"3KRA>GUpjLrCk>OUV9op8FcQ\MTg>ZLJd^B0P3+b'pP)H#umV>@rpb_dtE!\rp %e_Bk6Y\CH6>APs;A6W@o-Ue.&_T5VCVZRmB*j"_>N\hC1USVYA*fmbn&IqfWQ%%`YC;-^3A\`ZO/<"`BZ?%i?n`TQ;A6PZ^)Ys0% %2m1V=2Y'aM+=EhAde:2f>0sSmO%Q=aNP\I2BMHFA-kEMh"'D`*)_`T"r"]1Zg5u;UqQHCCMnq`pZ_84d"H[37;YDk"^5+I$Tr)gCsJGb.&#ZBH".f](J])[8bS14^FP]`RPkj63huiD2&Zrlg\R#c %OB5EVd8;]kO9@QZkJ5ITjWZSu_.2A'&fPa?OIG]CXcTs_,`geOdW^7:Tfc;>LW]%WTuE57#ogn:WZ#H?'jG8RoB(HYDj+`rT!&"i^9'KV %/qOmDEFHf]%M9q9f7AX0.n!,.r7<1V3HJ4o8J<=*7<_O8akR,guM`9Z3WB@eZ;#,VdNd4+t %1`=0,RF.:7WTbb=SN:Y50Fa'I0[1!c=-FQ%[AqnOhrE*Y&`ComJ-T_?9j2%3I+#%V8YBo'T-]t)k6GH%Cr>5LUOr",cS>*%K&QCr %J7R&@LX&e0TKRWdZMDR%R1=Yt=sL0lUkiaa6gjmfp5"l-?K#^ne'$[Tj-X2T2pCr&7Nr^R4*EhokoYFF40U'*uWg9LBmQq>lSEQEbNQh@<5RN_'ouC'l*%8`r\I64XR70+rCgah;G83pnAb&XsKO6""[=,B[1H %@m&fSSO!gdI+NF?5_H,a!l2[[6'`pNBGNj.\ImP^<[=!\;hP]h+gQb7G_0%NdXl1+)u4.7,t$ZWEW^$O'6MOPT"Z^!KKK:]pX.;7 %0AKg'7sRCK5'ausB]^[':a*Ws6q=+07e.o#ZlqCG-A"oAG@)_jgNu5JD-Np%cT!HNi]:dU;!(N,5'7fHFN,?R$eH!gLZh^'=:@s\cE-4@-NO]\.@Yq"0.jgJJuU+CSKIR`AE"V]Nc;

K1Zc,,e'B,=--=7&a*(*2p9H2 %0Cp27HMe!]P%r[K_Z6k(9$Qog\"5j!"K@)3`%q>P$C=msN%fHJoSp8+%ns:8O[itReFm[p#`*s\qEL[eoRe6N+cZY3hl_U>*uV&u %@F6>b'$H&t7u2FDTmG&uMbaN61+I5q3brH!\R#bEIPq3F,P#.G'7\KLFFiV4)Mc;2!93&[+!_C*6_.S\(8-Q\1jT#N0u'(&4!-5V %iS%S:SH2_$'eBh>[j/da_b\iFr-"4R1d<3^F"@lS$II!(V!9JI?n7E7!b*)Q&i8R3r]1gdeWk'08#=AmGBI(C33&)DY %c#C:nl:J0\'eg)64S45k"C7(mT$GMio!mOuP3Xt:]Jou)_Kms`'*Q=AM8$X!^t2S?eNft[I)0[p!Y\o'"\-?fN3qr_bJ>d`!"ERV %kT1$QV]G=+Wf2["['!S(JCN-&5mBXRoJXZjUD\`(.onl5?Q$^aM@?GJb(D<@9lQSC"tKK]pP)Q]H&_`Y0N[=Ecq^+Tj`-U1Be\A: %RVi9LP9[_2a),[>d6#4HW*GFRPr@#g73^(he&mAAD"l$Yh9J>r*V3MTQTg'?=<\)<(5P1CKr`i]e]X8Gn%,Ki*CGeqi@R_0]u]S;LKqO%4f(1Ba1JPB>cO+UQ,m^!4PVq@%mCbX=t,#blb$#:67o %fM2U-S[#:H_^sP-CQM^jtB22["aFN'jfg10<"A5%]C./O` %id/B\A\ctb)G::rn]+*GpcJR3e"NGo0c0l0UH4\N<.e[5[.#rVR5&K:,Gp$^ATl&d!6`tsFCL!]Z#uK%ko)JgI>eWT8Y\We5;B:e %'TV]S-[TCd]63M)V`YP4`(^=.`$.1(r!_F",&uO!fQ3'18W_EAlOOH*1=h72b!)JpE0+'7V]N,&,_u764>4X6f*6J'3=jRS`sVCF %UuffNr5Ou_4$t6iHmdcTI#7't!1T%1q=$>V>T'DUc#ID*&%Eu)cJ.$cEC?A-a0iT^Hj?PXkO`G7>fkBp)'a-e0)Ykp]4Kto!j81A %.U(0tXGA^mloQhsiUJ(siLja(E9o+".NYJPA=o:(P;"69"o.'9NeRHVeZ`7Z[tT#T4Oe[d$\tkM,lo8<+us,b[dWC%$WfBj>pta,)+F_4RjpZ&tM&-h>:q0fmf="+Kk5-<,CHE:5nVdT,=I_LD@=X@t %(6Q5HT!-7XLsB!;/[GuI"ArQ#krL\F]SkbU:h9837"fQY%02PQ*$6kYJ5iJBInUK2?u_kie!\sN?Q^E;oh9%In+^8@'B!1(#3m3M %JUl]Nd=KiARAq:*t5N;aIIVe>FK1p'u_0ot=VY*JsFJjn=G+)7Lp` %q?@2gO>VkJ2YjI7\$g&7jmZhe]!k_-a4t)n?/1:aO1e.-AKQeD_s:*H6"$2Tgda!VCMO,k-a2PFFYDnB:6I3f?oD?@/j@S6#u\%S %S0V!`1C3RiG+:W&]jfRr:;"NA,iPNW'FjW9,X5kl(m][)/>6Lsi/9oTWha[pbm873!1OXM&_2Zh:C(8ZfSMT@acq5B0gJ:U\5)4r %?*V(6mR]J6)+:3,8E,B:_n$X>#`@i_*X2=ud6k:]$6S,3N4(V!HEu!dCI4+n)i>(*M!>Le@gU(CYti5&<3J/gIG#r\%JEo=VRFKeZoIM&gVU9,'7.SU#K0l2f%Tih3^.#N[2+5` %<5,^0.`o1A;PMkd\W/ebKp_,K*&!.1%X5!*[!3'aH]2mYmXti65FlY-]Rp%$2258hj^t#WAaj?kS:*$^,*;Y/=ANqD#?_4?;_Id- %m_sLl[Ue'#WY7fE?No'9@i %bgtFCTRg(s0b]1H,/:&!:;0V%DK9P%72L-g.LQ'FM:j.]YBY?(F#oB:l7KiVTIZ(Ra+#=uaNnlT-=jO@$SioZF:((*(bLP/R8GKX %^@a>M9$FX\%NVo[[$?KI_W_h&#VcG%$$^-d!YeLW:i'+c3mn#\O0>I&KDq$HK39$KSn##^+geE;\rMj4Csp=Kn_$f0YH?NhPVS!G %:Y1Kj12k5T6;,pO/nU.YYtE9'5E(64hC#?WS$(ecYn?bgM-ltm+!1,Ll<(:+O\\&@Jl!%;+m1rUH8rQ)NKNNsAhS6EoAd>+S\f1D %T4#Ch$=[q)?49%GiYQF8AUBZL@]*\)L]hE/p?:gRV],@4mnMQBUF:d/'ecdNlIiscZB[!\/^F>L]);XFR@k?7!i5:LPVNk(dN[\( %I=R#1*OBO_R(*Q(I):Ja.g,m'Mn%'<0kNCb>72C94Wb1\.fBqQ1;o-5&R;rp77.tf!qg>F-q94<3OY%4"R]T8.qmm$!cJ1Y8jDPQ %%Y6L3oRe06c^3#pk;6-mgW-8J8`bs^*jVo42A'ddDdBYgo\`=G)E_?+R+(A`>SZKuZ+?i;V:JZ5H<,@2/B7t'UUe8g%RF*&7-#kK %[Vs(D*!.2!XZ'#uC2C>-qo0boh`3PZUT,\Ni-XdbT6f*XX(CI((fr(+];.U`i_6')42mfOS[c`BL83WMf8Z(_Ck75uPa[%+E?DFC5[Hk"^[@F#=Ccq*',#PEl[@=YPf+0>(lrZR %dB]"Qrdo""m"O=U3#Qe.nK`!(=)cfi*D\3IM\aA`gL"?+906ctSVP+6+B3Xb"EV %:DYWA>g.WV*@&`s+(]]\+5#R,@,l@*IDRVU`lj1,LfBf0KJKb^na;[u"p/,gd%`VA!f>sL4#m,pR^losO#%.HRAd@Tk %@S(BpR=P-hIsY;2%#6\0_9]SDVM"g+3R'V?^Dr#IP2tD>-nAY@.),]]^G.GmMAVug#;N>+*,cA@U<+I'RaZUTe-q,\Z?_eH)421H %VSK8:F"HjnnU,1*@J!jKOcCH[]qdj*"PP8[CRW0ZP3W]=cj(SNdULPa8GQ<9JC^4`?H`!KebP#IZ*X.pD/N.3S`991O&YbPG#M49\hc>(fdagYHj %I:F&GJ%U6NUhA;L)!ett>o,sN&aq2+E?j6 %p2ZQA1F[&F4UE9,#\cdcVf\k+'oW91NGq-G8[6>D(@IP@SS7@V?G.FHHmjNKnO^nKc5B"a?Tr5;]?SpBqeCK;9u/jsXrle.gp$+> %9tLln?JJbq4,M_\]:hB(lC'a[U^B)6Pi.P4"Yn,?6("rCdeQ8qK9+#u=B;km:h3',#SU/O%5H4ZW6Wp;?"7JfYu$@rE/.;390 %S`6Qp.K0LF;fm,;:-Wc@9)"S/.H1k$Le`6$fT]fT$MUq-JLRQl)Vj5B:.(pFLkE %N:S_WV4RmfoP4M,qJfo#bhfK,VXu?X?hofc;k/0N^_Gj7hj(tkcN!kn!`PIs*Y(m61BLMIN51Z+2<;Oe2%!$.:W/HMVBBU&VhV(Y %+:%k*`hn!;emS=I_:Xu)7saBAF^o6FIlK>4bj4trfT\\H#@g[R)=\?/o"qX\?3I\7LeXR(Rtmkco4VeEAdI\VPqa,;u0@)8jji';NEB_^IuC\!%XD5D`5lCLU9>8W69/'r.KE %O&6e=isWf(k\0B\P,rkUW/(p4<$]sJ.U4>*I,#gItNAU*N"CV>q>m#o-ftd1P^e)Xm3!D11H[ZCDl"Y8!<2?O:CZrhYN=KQb&.0s%0;KAZ=2]9^N^n;?k %LjK7?G=_)jEcHdE""7#I]N9D,00f/L6]+X15SJ&A[aC%$b7)E)-,03=TN-@P3>^ag/:neYmiZ2W$+nJ2G+*#eR#YMh`(R/@4#I%' %LF=-q@^'$)YW``]_^_+? %[hL8KNt:KIr30SQk/Pt`=0CkX[2MDlZ^]NNJklb('=#@J[L5/J\W*R?o&b85*>_JHV&0Y;&-rWZVMo7Fcm&QSo7pOX0s8gjub>g(_(/@]LJm$+"US@/gg&raGIr"_61n.L=#.#,a- %(m@'LiG@(*QQ[,I9f9,.2V:%LKr3KodYmA>8D:ZIL+U03X#O%Tl:mAU.*I&ir5,u,#W]bVG73W@D\5$">(]q?Bi:#uF$+@i/hb%D %\tX4H#70[+X).nM$$Fh=<;NDKnHSPnc,MQ;6(W>?lu3S8'$;MJa71s[nfX_R3Q)*.,c\mi8Ne:oZGN@QQf^/\LFXg2UC/Ze3nMVX[ip*"G^@)[8r?ZT29+3nut2U;gZL2=3,.jN]k)%;'(6C0PrjctTq` %FR`5lNar2%:.^lu.JGRZ)B'8?iE$9Rp[9D%e?H(PKL!7]hneku[?';Q5cr8&jp/9g3m[K/TB@X\GYa26"c"V!Yd"U!%$+EoIUO#K %J;V85i9k*/%u:rA*hS!*PI6Efi)gQSB=nk554tJZaufFU9H%WB*Au %pEuqcl0rfMX9';p_jaGHj.sVdRboQ3fr8-_00`T=stk:M!*9!+uE#!?!.."I(jN %G[un+hVof#KE,Fup[*ah^s(!GI,DT]7V4aRo^?6E1<]Or:>^-\jmTr[UT1E:bfIkE)%h]BST1Ul:[:s[W]j@UA6W7`/O5KM:7Ufj %)K$j1:AO$?7GS&@QNX33>V?W0fs4LW!c:#'M5na<;c@MhsqB#rt6NYQnPXM,u:shc$X5=k?S'g1bXl#"bopRIbSG.2o44#j)JQjOl4LEHn[RMX?RkFCd$N$*BggZ3bAgZC0*Oo(4%?AYY_t7(+\gEq@H;4E1GClQ>/IS#,t[a)GdCub3fT^l_Ql+87)T*P#i>gbBmS" %P1:7;H"%5G-ASQU=FsYS',e=M)(P?Q^#W"p#CDt#[A\`KLQWf6e%"rK%CW3CcloVJF_&o@)+@=.Y<0$l>D&S" %=5B".L$m"cdN+=B$)9Rck+?`\;pk]9_/r1M%]MOq?Fh@#6?b9\**.:U;AIH`i52IPN(mk?@Vj%6,6[*@j\65c1;^cDar;-fA#+7,EIpr,!B^8''h1!%LK580ds+uKd7fu]$DSiRHRb%VYi-_(<\35kld?tkc@ %O,/=\,`(AHg)VJ9\>9>m:DG;P6ZXf]Bd.Q8gVtHY60gl5Ok44e80+L?biTb1KB*rt>pu<-LdjqY^^Y+-:&q:l0:GrAJ:)6j+i6?r %,1TM+:.9^;>nc-gP!idUA6MkTZA>uP6)KX#-RVJ2)[\YAeN/_0/kQI2nrt8r&'2LWodT^#,(Zs$l*ta699R3BS3:++J]1X#A/`)Z %BcJKdj7RtN/&41tJ98f]%k&K7Z]LiTG!Z+oe:'7HXc1FY9F;liR)bZ&[7RAPol?+P6H*\>8b-6K!s0CmHP^WJOno^*A)VZg_WEY7 %3/16p)U@kT#`6@K"d7%riGTJJBnRiX.B.rlco=Id+b.u%DirTL`2L];@Kg"+$MY1P)9i2Ij!\FR=/<_/g<,52ZbZU!oD=n'R<)]& %q]pVVQ;NE-* %!CD4K'Z]7Rl@T9rO(jbIX?;%hJRBrmfF=X)QaYRnb,ih0U@%2d8b&YS=_]CN\-k9sR4KnsW %=N+*Y&Ei(FjPig(^id[hP(E[fll^3Sir="h22=s2L1K78+qBDQ2r.0[B.dSa4H5Qo'-,(kIi+%s_3tg-R^&3``(:eA %;">lQ#tpp`XBnF1[;:[2h[9'V$*Qm#PJ8u085OueY^KAg?Nn-n7qAr0K)`'mjK6kqi$Id@D(;UVhD'oQA*15VF:@=CTb_e#nQlq=5+^D/U9 %h^XNW0;M,H1(nB^tj'a4+ %6\3,5GZO3"rT3;`j9-HXK7M(Rm^jU8V86#s1?HHJJAMF?;B\pC=$g==O&*^d_;k)]&EuLGP+aH\O]mjU\2:/*#\6k+E:2oa.>r+Y %L,*WT7P'`0puSH`*OGW%t83J60MbH4[aC[0Y7U? %@G+07R<9lcrQDj?i/r.@iFeJT"O@/;."/M*k8>K(``&uH*A0g?UGiAV,S?C_p!#(-&Ta/$n:#_f %$BrHj$DHt$+t<]e$;snK`DVuMFsG6:-?:WeKO=&5&[@1#\QL!P<,6VE$g,XiJtMrH4PXqli3F@;\2=DclidMjcqpsT@X,hT)H9N_ %)IKPAHj'\X8H\NB;UV\.i+1R4U3@-lH6E)iL43WQY+oBMYd'(Yse]omK+N?pF3WULG%:>6^6H=)V(#q*1'JE2gO;op:gJK@'JhQdC=.\It[RY$(XP[I5 %\;^PnJ"ZG+O5N4tVK:FR55#cg8#TH5_[0 %@kS>a*.+*0U=!USRRW@>1kQk&l.Ynd0P*WDKT3D@'baG'_4c"QFp^1O4e=Q:eRf@g@'N&+BSaG#K"\=0VsJ7qa!WKO0@6NQ4t\6p %R]^>]aJ--C/70]?E0KFu3F&Jt34_`#r0OcY[3>6,?H %=].A2MOnnJHcC8JR4l6-XHB1B=R-VO0$)Hh:Va4ZP=s2')VNpBMLh_h"%VHo<]Yg5?hU7KeOhQ($D8qTOa`R %>R=G=I&W*galSLoC(&^;DN_/J&W2LO["�,jI9DS[qO"eSRUXkq.KtiU0g`(q@1#g%6&t,?8Y:I$M(T:"hX0obQ8]C=0lIm3FY$ %,oR$(P9O0eA35,%01h1ZS7p,3n3%&/Gt"E#%Z>8\T_7&'$t"Y=OCN[Qjsf:.2&FFCgNROu^-QCeCY[-[\%Z='b4nBq(X\A1q^\GA %RXiQ_5rj(h9Q2uZPf@UkI)MgfJ:Up]Quiln9MJ]J8joDLrG<`&ah[IdR2klsdKVXElTDA<$cXp$lE>1O(cX12Bq"PM_s:DQ%EiN4 %L+IQ%K>`S`Qm-+@=jDS4YQgC05.d0JFZ=#,dV. %W0\/0UT3dPa\gShK+at+%SUtc3>/9tc(:4@ltORRJOh[,7)a_nSi/c1ltO:NI0^&(q?+&emE+F=ICWn-oZsb>bq,D&W.G`%Sp((\ %hk'Q-eD6^>rGlq*RWbp5c*V;5)N'suSn"X1`L[Q4d>C(]g[/_/DGQbdgA!?UAXdq/eXd7Fh5WV444r#hT05?2@`uZ]0.4I$fq@.A %[CbnL,^G@!?/pja8KZe[jNUh?N;buBs(F8Z&Q3@!RN%3@+49+P\+\Ys+P"!T)Q\5!MisG&>,Th=415+9DC@*;]j<(G<:'T4'g;Mj %L47mH+uK@O0Xg.-gH.72+A/3YaL=[WskOoKHJm?n&DDLe(2i5%D`T5n'J!,?!Gq^_6Q07?RW0^QdA\YL-+p\P@U`CBjb_7.GC9Ug*\nFN[jbbbG-9'/oc$ %k5>ol_f!9Gnki_YR.ASk<5UG49&gBn09!1>an/Bm=0s)QN/D&+4Nec'ZmX82e=DVV0ca9%)U\gIO##suqB*LP3f;*O:L8KW)%s6AT.ONb^bI4^mO6.:X- %7+,3`ci7[XDprtXZ`qsITUA)m6mM):PVaLYM5HbCRVX*X+ar/f[d+qBcjo#T<,6kV"X34LEj]>fTmofQR?1D?o^-MJ63<-is'GNZ %T[Oe=iYT9oWn-V`UliV^+12gT`D&Um'nj_Cna-df*GSksO"`NtpX;,C.#lUS^$9a<8MapLcmUOkpbEdk^t3,G1*qGX:3.UJn]"6c %8PRQaN'.K6"/B!2M3QbrZgSG_Y^6mpU8+UoXgdLp1_PceL'(#EnUp86%Jcl)D7r8_f^I>h,_)s/AXnF&008@nTb2d:@\hY[0^oJp,`SgTZggsN98"@Y7O/u&r<%dd;TAC%j(re %Xuh:E-V/OG\HMa:,nT[79-A4,JUSsQF>7W,AJ_DiJ`c)QeQe(3Ou?>(lK*)Dp7"i,PXL_t'('n,BS+;j8.+,^KB*kFC:m0]22eR$ %R-$t3)@FnYi%d<:%j<`Q&^JAKmip%fM3Q?3#j9Y"=-c<.5!hjR06:Rn:DPi"3&qd5 %F($j@2KNc6^.kqL0rU;,J!_Z+^,5S%Tn=5H<6W:(FM_.L5>--WL&8=IsrHD\[ %ZpY7\;fM11XtrXO0@)`8dhS&;rdC/Q."9.[q:0t]?VPbQgmh7?:5hdoPX)Jd$^DD\T7T,!!pqoWXP_*Kq"q?8SaX2i+TK9E/VW1WF)L!T9n)X%k._]kR2]nU4u8XMoFalmE=ikJ-%(Oi0?P8'0)hXl %Gm5^Xs%puu%qGQno=fLEqtql:pQG\7-ODbq>83ca+Ki7*1V?'mb4uqDDa(!oibT[TqHJf_hoIPAS*cZT2#=bNmYo)i*5:nW.`Y1; %)oWWMEV%Q.Is.^$5lKCLkjKf^k,tA,TIYgE('#r'[:4*'#=B.p&BAb,4P"14^,6GCs/e9<>]ZKPcG)+JGLr?f7Y])g.&`5g`%m!" %UdQKF*GuF[CX2r*c'atmT\iGb2_1>G>i"PE^Z-1:]>IWu&[j2H&e\_TRA!-K9?2e@SQd>e+i'7c/Be9j!@S70dTJo5i^=6:(S]]=2*`t)Lg&+_DAbY5F6&)M=rliBS',+ %Pa)Ci(WbS]R\=[J.>ahaq0f[NE.Kpnn,09jgAG&_/rkBlETDSM_6HOX`eWhAPG/m$R8PJgQC59Tp!5hV&Eik[EsX[i5][8('."2; %_"eHl;7);N8J%[3/PC&)-W>'eo[aEmqQIX6:n4Foc:-)b"O0B^:DZ;2/Vkr/*@%F@;53SLLt)?)BeG!dnI3[Q!1?>hr=s.4AMDm" %)+h[gZ((fJbM&tm4k$nXe^.O@]G-lW5pE'cOiEC$7S,Za7*MadTh[bY2"rbhpO$1jlp^T[e< %f?:L-r8,`04I[-Hq(4M\>akVX9%?:*QGi;KMrTeBMGp:/Bm'^"lMiJ9q"L1S-u&C5kF2GLAC3Nqn"8Z?;UYM3M<;XR7'ccQ3C1o. %$(6l`rRCaU7s8qS0VeI#T\C@nB>na>5W'>;\JC#$>dS7%G(I3>()G]n=06`ir5BDiojsYjJmEb!FDp\JZY<,)* %l7jnHdJi?LmC1N#k*tt7^,_&"W=E\!iEL6i2(K8M!#SBlC%rRF?_5e;BFJ/tWT7ah!,4jkmeAGm7#-CBGBW&AD(>TeT%,P.SY=*Vg`0;VhS@1g8J.*K6/etCF= %qDLVj[,)%iQ35'@%uAh,LjqX!4:p7",IDTQCqHbsgBOg/g&C8\1p.igSL8_GksG[ahr,JF/m>kr"JD/*8ol*N5o46^d;l/ZY:,G7 %c!8K:`QW6JAonsbQ8=4\YCH@FC\IamWjSB$N"K#S$'Q!IPp,kt[0B;kHVj0i1"0YIctfUYb_YQqnt4Z?=?h=rl1[Y[TQ#*; %SG1F'rI6NU5&5@,UUeqtXj0/`JEQr:#5h96UuEC`C>FY(*_A_4kIUObY_&R0dkna5q[;_H)DcUWNf8>b7s>YhZi(WQ7@i6?!\L^f %ne''$U0qLsO?p)FJH_1rVrL7]c8!U?8HX(*a'/^4#c+")95X*YQqR-[75=duQMG%m*#okoFe;@lq(TVlBKBnf]3-)# %@Rcua"ngOjXhi'mYgKY^Z1fDUD"Z0pr5\#aBFG+TQZZ\\%O+ae8RQOs,Zt0ClZ7*b%i(FeP8d"WerJ:E6X)Z5F-^ZNfam:\D;6!l %H2G:mmALE*2ACd<;c+;U@XTFpRDC0XBRs*[)^[VsffL7u*lp"RDDO?ki?8`j#nKg<:3ta&!G^HY^*f-*ENY!Rd93U#)N@pfc(^*N1;fH\Z1X=TjUV6t75J(E+F-&EApP)5DOaL'l]$+_;V?L,mMV=?2i)iHT4Qd&-qO0:KeDW=a*(jYR(1e\t'H:4BdRXGTgfG*iQ1MD91KE=H!Cm/<#las!"t; %%[Y#^9Nb-M,QK3K1e[?c\8&u]5pk\i;@H4)BI+A&4:E$\TA/=9'e[PUXEen=/`?c2#.WS-_lr"uoTH4(>R%FU@e?rU0h;qG-@s%V %,)3t=-BaN+j@Z-jm`\LA]Okin%8_<4>@rcMNpjS"US(aA+A]g1AQbg.*68,>@=SO^X@cn0 %5r&Jaj!BnN&[Wd'l:)Ii#q%d\+qGu6EHo_,nsCsS&Q9Gi:]_,JKOWM_7!M2=%Pn0] %!/%qqRm`']26mfNH*gpQ$U^u9Tl_+r_l-d<&pY8QNFrBSG@?FZNe9IsP@r8En]!lC/.>F>e9-o$"sf.[kK&Zl3LM+j7,&eq8jd1uSr/J-7b!c1 %)5i#CEd7!l)((IVN.MVKQ9\Dm$f3GMM.hdpW1%Ag+:ndPQ+\RZU1P5KBRnS-0BDD+Y,#kI$MH;[//XedaILKG %GRS9JB^Pj,Th:F\BOf13QDUr]Sh-/&p+4qj*.nCuP3SQ/[h)Q.&P-4BqOra9):Q!H2iEDS %`:n96,_nXKZn'(X0a,#\":GTg4N75Q,<%``BEX/"$(%le7?,V+?%YX\_n#tS!jX*a)jITYcpXWY**AtLK_!#O1r;:H)GlPD>*M9l %1^8i'7)H0igrJ+_X0G'(Po.B_#uCq_MDqrYCY0=h_Ajk&k"(lS5a4Q*32;IR5_/C`mMNhcJLEbhi!$c;<1_:]]SoX<316'NVjZ)TW^7#t2o@I^169,?c;Ge!qeEI06P=gSc/9]\B+ %!iOo?_5K1ZS!OL2'l+#B-A0m*`SVll"LVnf&9SI+L$)%1Xt!qH?&J?BN`m;S5S1!UPnQU'+[m[#"L9gP)XgZ,D0V"/7NJO1\=Fg+ %R6\`9AMG\^HchY>FkM*\7BsuW]l(T/_15Ze)S#So*PYI%,D`1"ZP=MRU;ZotK4Sr&6ZI4GHGHeof-*/n96gNC1j;>+QiU&(G1R$1 %f^U0d+2@^pf*8?c1u!HI+?R^KLA^5N!:qYn[s^T=+W>^p:E2cAp)_B;,;L3_Y9jbtT'Uqm&26==2OmGSNf+;l,%[#P$e?C,6O2%L %J=ncr6BF4EdmsKE!O2XK-$])H!r]lkV?j1.%nqp`7=r=]+r3p9Z:2!*7^?.7r"';P%uaN/A,S]DMKo#M_'!LJ(@c1APjgO!&,8GI %+pQ%0Z&PS(Vu>3qp#099KdtR2+W1XQFGM$\]^Z4Qnr!"2%a4]9!R+puM_-Wm"(#C?N5/mDJ9ZW53%,D!4;$@>Bb;)r,oB&nO]OY\ %(4jQB`4VI'(8=&.-a_GO.n8%2B39U2J41YdZ"bJ_r!E`W!=t:sKhfQNGh-]!9A4k1.\Si'9E=6Gs/P>eUkE2E$`thS3i'EHK%]co %7qV^nl4Xji[",(mh(hAJ(bo54Sd9]ALB^kC.H^Y1btY;:E3SQi?Zk8^[-H%dYLg./YG;R4FnI^)/JM%cq5\)="(cetNE$>9"QoJk# %@*#hlI=)I[$)5"6)8o0m9X'IHisWP6=qCkDJk8AF6FB9!!A2DSl-]^s=\a#OWnp"s5A5-Y7S`fu`JEEM:<#nQ3&3Nj]`O)!F"pVg %RQZ\_1RH&Bk/8M6olN7=KVlhkbnblLdO8^M.)h,6b^lbF1I&qngrr\5fjF7$dne.CM3s]WLAFuVpr\6/l %..mMh2?$lq]p[_aoo4I_ODaWmg7W/-jkoUG;$quNmc %p;$;$H,QFIMLuQOY2/kh_>(6m@P;Hgn)u+tbAb)oJ);;K:3t0Nj3+QJ)A6tKlW5AIFrUb+\N]!R+6>gc:`ajB\3Di1*' %YM0D7Qqe/hGV]V;ei#"NpZllJ:\$6(-B6e&Hf`O6<)iX'qg7T=# %@#$?TX_X]_,)("QlCakg1lC"cKc7D(?0Ku:Z\rYPhKT`_-_=e[W-\@mN8V3'r.n\/L83=mr?!$WB:(.DJnI&4EZ[+OIE1cN?u6.K %8^1/1EL`%ZI5FdhOY@!\\?n`?L5;Yq-Q5:_)'(2<0ER2"8$hZA(aeG#LYBEqI4t(,l'q<8qTOt[8*jY:r^g!eje>G0T5OB%?MU4OIU*iiUkZclM]*KoYI.[o_q+Hkn#4$W:IqP %@RZQ"S,INQ4B+^/6>khc>&_=pI%_ %d*RD<>L[.uE)86s1qp,E"Ur'oge,P0\^NqgZDp/ %kEZ!niO5@u;aQl9CU0(D`f4d7HWg6@FZf)Ng'D.gTsnI=dm;&C+):`).+ANIp$=/3lS]J,B9=Mf#t-t/^lBi.#(M<:[Ip:;qEXT; %1+:RA@s>$-gR]l7kL.FkN6!N@;aFSSb6at;&Qb+A%q#:\XR1ijXPRjL/*<(&OE(!;]h##]9C8`I0H*jQ'd.D&$5dJO\aD]m3WnB*YM.<%6\?=,7Fm<\.lBjCLV\\q` %Ce]uL%TD37HVKjc/r^d_mKaoMH*lh_+Fe,N)aCU7)h-;*X[,VCJ<$`s#OAgQK+#n#^lSj,R%>Gp&`snJI %:\itSK@/l=)gK^L+EWRSNBdmQ\#skcWqr[;/mBhj0&64W0TPp.l7s)#_+D^5D+5W-QT(gTO)*--)"/%[0`=<+XfZIm`jmUq_mJ./ %=g#]F5`b0qG-f^l+8:jseagO';H6#&5\N(_A6Xi,;+Hm*=FFon!aum/;qgPq(?Cs6'2[BH"s-aY)&P>98*p[fEYF&l04\fAY44CW %eoIRl0ja"T>7PbK#,sALD2Ebg=.&]GWqS^:eC)Qq;\5&8f!,,0Xk=^(eqSE`l\^2<$R0J%TP;_q[:-a3qPnfp+_XL%$nMaORm]aT %),d0E9_9/O[+$X[3mP\io,$6DA8_H_@a/NcW8S]TWbRt[[W*9k@<9A)H.mR5A*h_ArX+J0c9l<]!k?ba'.hcG+qbi5+alM+I"N2O %":=J<=AfRr8GCIFD/)o4bmL3:d/.L7[(C5t!XUe)(t*^.Zk9P!la=RU"1c!XKD(L[6Nh)(0o5mI#:A9YYI0Ki#d2FeC4jj+H.2j5 %CSMBdehH@hXc9*!eB1I+*"Q8-?3>O`4SCL,)>f&q?Iru)@n#bmGf\7$/h:bO\"ea/.aWW+<,#KCr,TF_7EY&T"tkToaA[mgkGq-Bh``sR7ZME=$Js/h`rTE,pc2m=]*>l %HW5.YbWG[TPU4S^e`G"]jpqqI^L>rXAeCrBEHm9487/6qQYKr*&<+gY8el %Xr_3[j+^Yc1<3:Ok-u^p)A[c9hD_CpbgpiM4gViL+(g6t9KEg0CfM5TC88[DmX>,JP1eu$Qhg@#DPN:cXhNQ$W\j0@Iq#)cgD'," %UV`(i*-\%1aFcdGa(F-uer'K0F\;S:e4Mo7E),>'7R>&`OEtpl'\Es20$uLIbeg)+#\22f\U@`OE_aX/!hdD0`JK(f&6iQM$[D< %:RQ%.Z20a<3SmAd?]R9c;+qMXf^,8N5US`SBanOHE&bL"BG4HOiXJO<2r".M%BV:3]G"(YZuR2Wm#0*GXqJq=&%7gBMAn<87-em_ %+=uo;q;-1%qmuU-ajcZK0\G3!$r(D:=C6Y+Gn=kHo.Js82tsj0,Seb?DH81^C$E-hY&1A,()$?DkXC"es/?:f"L#i99U=*d/lEei?KT(E'1nXU&$eDfsRIN_d;_5JunL3oG0 %0DoKoX+_XP_HZm#]3),90'@oaebSofiFOrPrFh.qB?Q:pCmAGVl=\)6]-[)Do=fJH\i82:7&\[)VU(kq0WC6Sm&LM;f.4L>(7Q/5 %#]+?0Z+IAc6WOI87q0FR3cU_ND@]C._;O@2gH<4'_^d#&m=Bd'Dg`jmlcIIR<151P`4LmSZEO0WBG?i;**D.#o<,\)nc*'4Q4<<"GSEgGB2`bb`<8"h.m8oWt/7XLScmW(qL%8lXKt9>&"qJOKDjr[K %qe3,!)X,emW8(a]bAq'M40`1(lMtY(q=_$E!4PWcS2"eP#Q+A![T].N?YCfoX9!Se-[$!g&(OG4*t]T)dG%c7]f^UTIUt0>%h%Xq %::rsp]WLT0g$3SG4`amlW0N7gq/%CcSY1k$)ER`6NSXt?=qNZ2d@--X@cR',7I:(nOA:chk*:_P$=]&XX4`D+J)/cF]XEmRh^U![ %I&s6O7sCPQ"_5WlO16N>M^j1&+;_WP/d"a3YJ1K??)aofKiV%^Hih9 %Fuch>,I$r[p2VD,c*uHj9pjW91oV7NNqS[5m39<"hniXTf=Ri(of*W.FUkAbXOVs;5=K:i^!:2llY5m*s7h2K5@8!TQ%e3d:AcZ0.fFurh>q./H9H+EA[Tuf4Paj;"K'XY#` %d-*lQ6[F=_bTJ[pi-2R^[Vulsbo%Q8*Wqi=Js.KJJSC8R_k6ukk3iQ4qQH[;_=Z^/kGiu$hf[8jjNDH+,C>#SKR"iHub;;)HCmmZVR7EIm^Vt+CSLWs"mlLWjQr\?2Vcqjm0TgM1.U1U4cSXcM %Lm6,[K^jl7#">q.%%d*C;3"[!n>O1[F^flGC>.5I7tu7S#fa<\%e(;g"GpGH`P2]T[@)ohFm9Djn/n_UpZ--LHh$fYK[e/>^-uF? %X4Kkt;MM&5,I)9jr.8n)`s%*51-Ub9QX3/3oun1o[ZDnDhsRP>/@fRn-Oa10eOhU*6^/"6!E!3c#Yl411Km6Pb>D#:7B58!K<$jU %DV\Ad=nZq4&DI6J#plg,&;`ul6V8mV2r;']dG?fplm;DSb$K0CYHjLiKc+rqXc<55c;q:lkO0T(&H %AO6g25X>c6cd\V(Lg,k>Mlc\uGYpF?XM6dsaR]4(#MGlAF/106,J/h]QA;B?JYN!@KH(WZO"&=s89rr"hNe7W(TQj&^dR_XWHD0b %4!.e+gLh$fD(^;#M8%4iM\"[aOK0fEM&D9rSO-b"`5975SsB^JDI2o$+$t;>m47mjo\4L,iFM[_'JGn<(gKtjNtaJFZLalE6BnPO %q`@pr_Rek?fIa``fCh`?kNqUe;UeHCo,ih$I(I#'Z`1$k),DS`j:dZ(Wh^TNLlWG*i+sL1+*G^YJb,O$BO2iW_a*"9MNqI6kP>BB %g[?9.6J)7^g:q'hnn78:O!`@MP[7^?j[/cV#n!S/7_$p2tHWb7c5jP^3?ntdk10t'q!^#p,dfP\Z__2QCg\VhJnKQ0>LoI+TSneU"F]a%Ue+Lr9eS<7b,\-VpimulZlpnL1L0nlCfnY4mRGho>uOeCH50"t0($J'H:\U22f&AGUD$"ib0^!.&O;1'U6Y=d`^33g=e!^qu)3aq0aFX^"6r^04:p%=fL8-:XT'k)p0tD`(1uN/q56AnXdC*Gt$=8 %#E0-ZPHQ\$P@Uif7D0jjq7IMe2c<>CDpJ:JGO.l[i;tdWoce[1R;^fkf6Sm7m#4"\QL9/`g3RB=;!BdCG:E,Z2e*G<<-VHJZIG %B&G@$.`1?^4,qu"ZtTlffo'V-gJ/\\_gHgoGaR/8`G38]E*LV[jKJ;7m8-X?Mn%S\4;88D(M>brL)JY[rcr2Y#nQ=6'L1BRaSV$p %OeMt/MjQhaFsLlE/SErJmn6HH\Cj[>OZLuuf0NiI%tM@$H9cQ_gM>!;(SL3`@Qf.s2f%8\A5[P]q6KXuZuV#cQZt&FrtmVt&(6Ka %nu,[:XiDZkc/Ss(OKDPb(/2ZPGG'Mh-seBW/1Q;ZX+*=E&BOiK.A)#T.UqPu(@eZH7[;SQL1I[^B=:peMXn`ube%e^7fc]3Dr%hg+9`"3jeBo1W!?WCQ16oABb>+2sgkh[,\l8AdgaFe\-klk2&c %mU[EW-7pg>Ph,9J7bOOu\nUAMkNJ^EBR\&B`\RuZZ8gk7Vk^1TJpl/q5\4=5oG\c?'*.NBH[jq5'ZDkQKWn_%1s %6]GF!RWn(oL%PlnRBTc^NQe&d_nMj^\0i00ZKq$<@U+djnQA#eb/DN%D[I,c9(@P;XShU5Z;`8OE(?3Ar]8@%@[(`sl+>nt5+]BT6 %_2""S/\,l'*5Mr:=2pXH>nD?VBkq3U2rJO0Aea"uk$'9`<0iN%N+qt_E=#rnZBt7`)mL.r@IZMT[4(u-BM+]krjTHhFnpW9\RDs_ %in!qi=nZl``Rk>$5:@M![>.:hjhB(I4$r3)3$u7Q!2l=@(C4Nkc,u4@EiV6T,ko`H@_K7CoP%B4\To[U?4Rfm?\oMMJ6$C.TRH0t %gnL'O)p[h&^^U+c2H(/8D_Gc9pXd0Bb'e6'8n?.0Igf@r9.T%O/'!WjaO3A?[_X<29toU"ih38[-hlZA=f-/b4&htu'fC0=e-*A;aI]TM+:6`rU5eO7E'0j)*m36'rLh@<.4C2@k/h[m>^Cg1^4=4*m4eLq84ktG %S:UXV4stT2;4e@i38DL]D_(c4@<=?[.D9h$Bi?\PkDa\m)T-];o/Udsk3=9P,6B;X(jhW'C\I7*(DdocB)_b,X3R(haY/(03'*PokO2t %nBS5E/_j!/>Un(JUbq%/b*!J/>!uip#VuGMYl.kMSn7bfa2;oCll$RkDd'k'2"J'#_Kn<"&@^>%X*FYf:t49a\B-&NnWtP`9sq'e %9J(iLUl$h`9*"kmh*qrPGVr[[,ng@fO&]XBeWT5Y#in766)(nC'` %+k:39Z07tO*$1:.*G*OV,G:rOiTdM47Q^9XUD0K`aDO2VfI.^l]MZZdON)]P\Ggs#@ZaKECooFdZ-penHaiLqHV.\tOe_q[ %@J)Ve]Yi:j37WN(*o4"kqW^ER^hU,/?@8&6pXuRjDB*PZ?8erF/T]=t:)+6P9>%Omc=jc@W,FL9MVs6B;kAFiY/%ohJA/I/noR*QRB>Z53*XXc0?EEp %+q$K'XCZb3<_4AQJ\oF5>FDUkX.7g*W7$BtpEp0uqj3jI=p9ZU`E$-oj"lZ]a/l\$YpIoQMR7i$O %S$ci'rFUT"\do+k3BH&(GZ[E3-*c>%/l9a5/f*pR6@PIp2Smuh;YRB[Aal50B_Hr3e\^_k#e5eap*#T=(bOZ,(-$e0PUU+tS["HO %hBU:H3OVB_H,/#`T_huOlkLK<0#etk)% %h6f*mCRd1MU:W<`nnY:\.M2$qS*ELII&&-;N5DW($V+K'%=*Npl3CM7.-`BFqIi<_9UmNJ+-\o/7fW//l:-Q:5@A&gf`c0`. %JDhMj$TJkj#=Eh',%;CadIlAgmb>YC\GHRkZ),)X?r`F8d#C?'D[7'O+rn[pY)QW8O7?;b>AJGqZ4fp-=nTq>1lMS]'2SV/LX=tA %!i]%%D)f+@eZ[`0Ig$k8Kq9.>2-7)Yg1mFhhgW`6+oFjtY",XoqU%atr>;gNr'u>t+B1QP"Ukju?*[P]&$(?V(\D_-PJ+'+5HDK, %%^[c&T+Ga[d7B66\.u@kOGFIVS7^f@!eT7p?kd?p#)YJ39\7cj5gfc9CK8&?RjU/6b@>%[5"(boVpdX,pTE6D5VmV#+moJjJO-^FhGf\N$K#XF;nmB/^-B582W-="^2O/VAtJs6.)(f8=>"s-;mqOaY(^o#/,>"<2oiL;KX@AED!c` %ArHtVG+5W.rWpjsVI)8cnF"dmqUSuj/WWc9H>Y_6<,L`%-B^Ir9sdgc\$h_[jNFkMW?VEA]9gRt4=:S[l&P#t2$ba:2gS8YRUEd7 %5li7s=ne_c(6+Sp"fP*2Nb!7$`G;\1S'a3/a'o^ubEP#";L3?Y0YCTN#mOg=,aLg3egGV'g+"I=L+`=U/7i-$AJ:Q*8;$:<8#\sr %XHdMpTE"lirLa,Ws8;orYQ+KLrf@*BOeR4Q5pu_pRIE-02Q+>2F?cO>jb+fSD9G*mr@2CM]b:Btgd_C%DsFq?7[ik(@t%WkQ[]_` %He7pIPti;H&pk]F(4J!npA$&9]f=Vmi>5_b??MJ_]ZK9]rM>FC>2bIPgXY.9M4G%5@ptAQhcWLI="bc&KuEEBp;5P*]tCT>d71\i=rIV[E0p]4u%-]@3G)ZXR##Kj?l$[Y@f3ai4@Hjt=b4oT9TZdcd-FmX;67bnV/k %I&_f>4Aj"5"BuclG0:7DGiEsR$D4U6b"eaHJ-k":nZ7i@XgKr*o!?uCFJ],$M&ac"hWX./@kq>Cg\Dm&M&>@imc()X2Xk:,2Drorlk0g65,^qXEcYf9hW8mOu)0^taMTp3*ptj6m(n=hjG'^^,b.;bBe@K0>VbVg7Et:k-nmu[cBs,@mU]&cc %_u3coZ.&$hm^m84QQPCoFkg[VAtSBVRCYTID``/H@:NB8bk@'%(^M_EE>:J["A5+R-D %i(cSPf]W+kNc14g3dm6kVdUt;h!)qArn",#Z'J-^0=]c'K4NRK_1UBO@_nni].9o6r]5/f<'!T'/nCQ:Ll\6I2nuJaB)Eje.%SrB %29Vk"](dgGcZ^6s[M2tTjB0@29Pqr:mlqE"dpVo.B6Nmd0*04C:5_IMtANHCW$UCYS-E+:T7g0HtWTPd\OY&ocD %F%)Y^7lFDX^F51=pZSBJKb84p\J$0$f]CBU&ag?Z;7^$S$u5GfF@o[s*=T6USk?bQ$^OPCr/XQ@dd1hBV_%%;m'U0#&Oh1_eQrCM %T_>QiSpQs0Dsu!9YA:VV+4U:*CokCZEIPiP$S*i:UGZ.FjOJ8KBSi%aJjd@QQ++Y,agN^Rm`4=hdsJqGcOMiT?/J8uqSfN4gQ1([ %WeN8*q^f'ohf+!J8Ta\DpUBhE[k7Q\"_5Etl!gF%n+*ln^TtV&2'd+toXtJ)Bqdk;AGbfAG:9p@VSBI'f-6lcJgS-fgVNO"mD@ %YV>YVa[>k6=dVFRn4sC_Pnl7b(O>f=#2='bogX1ZdU2AcVfKMf,."k%Q;mW>Z?&_9f^9BaMEX0#_KuA9*Q*n.+%tEiAB3Pe&SkZW %62eK6C],i]gH5'c`17[%KCN_,Bk"Q(`CN\k(0`gPSI/"tk<(_Jb2(mcG)Cso&)*b=)G^RD&PLt/s2-0kZ0ad>oGb@ %Y,j2=SRUtnl]efM(US[/VWEi;e$V8R]c+L?+aAc^m1a@9/tF25&8D0jHDbF/VR\n$G)?hnjP2g#mGG>#8.>.j&*LK@0;Q6AXfT*W %MKK9>;qUuEDF(2!RT?gBX52fCb!N2kn!dZMg-(M3]#MGPdSa3DIP0KKqR5EK'Y75Q71RT<0ro7dgX^e4IfX^F$kA`^dWhE=Q^,aQ %A]L_'9:Hms5J-l=Z`4l.^lsSf^Y<,MmV^0.cc0s#kl@d"C'U2FM8k/p\'l^a/D_bm %p2]DY<["@=F(*Rc'qa0Yf!k^nP`=.?4krt`8nc:IBB[/=.(&#W'g"g%=4jcd#%oGu"DSu:1goU1O2^TQ5`dp:/'2L%TCRPE:rXZl %N)pVU*cEa5H^)@-AoS&bPTt)A1sR-;+W^PZ##C2'L\\&)\BZ`?dq=L:EEtkQr@Sobf5F6Mhmu-a:,<%7ou[A>_:PlGl'#3b!KlIE %N*aj/.(Oqe"> %HCT!L%!>KJ"M9V*UUWVh;/f$LST/k?>]=9/DS$PYiuFt?[hsR/7Oa'>a6m57O8Vm\5$CfY'-%6T86BJ8P[]AM[E_Z#b3Ia6BG'oX %h$Z(`-OtcB#FTYB#2EtJcq*?'CFa(;[fr+0g?@"njjecA#3+g<&2BE]PlBkmG#ZU=:bm4Z3!5q.!cnEsnSU>J=h!k3=t/[LoA4U\ %X)t;0@_Ji%S,7DF-j2E7@j[9qA/h$XM(0#KGgM$c7:ZI%Mg,M7IL1D,;T$+)!33q@\IHWT2%+Q8%V/9D'I7\O= %ml4`_P%NBC$Ob+'5,D[5jsl`"k_sgtUs2#\>fBSd(1r!=90"V/(Km%#M?tOs&G$O;=Y6+(9gP?.l8Y]N82oc?rr*>,-Em=CIN6.shT[U-W\C0*:W'K`),.;SNeWUQ %9o`oeK+1GFb))B"Z@["7=0i-98I/HB\$u`Ig(q_9YflTMg:Y64@4p[omV$b?H^>;o/dCp/Gkhs^XY*cBBY7TU((\O=Tim"l0oGp8 %Z=n!-NMNY)BK+$V`$HB.F`1qoP'&1.A$]:/^qG<_i1%Or_N?=VeI'#k(7FIIpPHi$]Q@M9E4C6RQ/;hnipZ3>YQ>Vj%:D[37*,YI %C%=+Q9r`;ICfR;D*On0E'qQC1R0G2a=]O]?\0Lhe*`(",4@/oRm,"#;C7toc/J^qi*a(>YanEp?q3q'-r9=,M(Ohg#RD9ct5I/Ep %[g>K4*gD[ua"6j0@/tXU0g=U'k.qW#io?hq=@A^L/rQrD&TiJBaJd:F\JR^T**00[j#:@HkZJI+E8>X@9$X4%Ur)r?m;p"TFHUh0 %;piBNO9h5C^X^H\8h:?/Ik(L,Z2'pW-+%A1Z)J6_n>^bl]LZEiAj\?/BTt;m!b^4-8>'YIU^fiTa^O+\Q[>17mf:RR65WIRRa24? %Z$*Aa2f"q/*2Yc\'8AUZ5)mMY[p]8V"2L:'kVGC=,m%O9f)q_pTRLd-S"uF:UCBH>C`I^sO,b,^G'1bkJIam7QPTWq@9" %K=:pRk@g!.-V+5REfR$ab49[n"rn)SRM/1!^%W,/n/T^8]-)'ik@aj,qQj63a5]!i,8\s,ERKu:P;Ze]6J"@R$efiIkcCOu)].X] %94[YHI,j^4g&kGW^:^Rs]K6]7[@p@YtM,0k=TN/<$27[]DIlP8DHWMWJ%tie6 %*s."e\/<_]PBVL9k!SlnmU_1&":T)^^1ni"dJOWM?'qH3HCC?n^Gc^C2!K&33-^S9ZNe;fL"@]Y'?/PjNL>K3IF-t#:WMQ:[(;,DAgF-"f!4Mp9(g;@tH+Ip_4,Zfl=Ys#hUb:8nMWME6>j\tD/G%CZi %`8kg*Y2OZoO_ZTC4rP:P%$^iO=+*'?BNkV`p$>0>p&ki'8&3d=XeX#i49TXD"]s:i*/M[&_2@QBM\^Y %_R?uhM+k@\0$e_:j0#.gTr\:?Q5dOk,eN4m$QI^D`J%igI>5DFU/M7:WSB7W%\6ISfe_hC)+,)#d!99N@@fT6*'2\HE<_tL[U@/r %3q!J %R*YQ?J9f1D"e-V-p%`1E"eM@*F(O5^_3(7j$'Bf8uM#k$Tg8P3)i9R\D)B7$$^AQU!D8'ad:e`uK^t_Rkf`SAr&of[To# %]B#.Le04pWh9;"C@3'ufP0o^olS0a.DJmKQAh1tVg!/>:ofd=KE6Ftg0Smh-oeSsmQ>?b(VJn4sjq')tIUZ5g^ATf/KCQ$H.?r5< %GGUe^>A)bZ-pshuinV$'J6@[,[QQsCdoj,VO4dfOIZDujhE@7 %S6#'WC$nPHhQ!qG#(C^XA'&Ra)5e0Cp\"-;JI`_hcn(YLR#^JK1lkENpuTG=&VE))*IuBc6ZeunI(3abKE:42n\HqlG-F_u1#Fh8 %i02YNg-cc64+4Rn*;9:eOfk*:]C\N.$No#D-5!%9drN4;aU %O+VE>*=#N"BM(GpE]1)\Se3i#Pi`YC@g>;D]Y<9/os#SDiS6N_Bm/&5U5AF4iT"X.=A0$F&d[`a0b)6hO,O:^KQ2Kd%*?/MrM>kd %T"(F\nr?K>:Q"`^P-oI'b2WFI.7pl8a;eZMb`kt>8/#;>i/pm^pjCM2Q8__mds,C3pAX%C*Z5)'mLe7e+q8B%O,PYpE-dQRUnLpR %12[u/Iee7o.$'=^TDpT1H;:^**(\IE)pY(=K8&d1a4&6o)QhCn@iU`i4Tf[Megrkm`f1 %2IQTt;8,9sRCdYl7/E9&FMT\9cnDRTFO7*P65A\6"cV()bc4Cq`@*)4H!/VG-=3@oF083Ze$CTB)r1,Re,q_Qo(:[39Xoh %0m2@KE8g,D9p-%M'ErpY2=#Y".Io#VjMQ73W6R";%_U@km_AhuE"3UK?'DcFc\F;F6`JtEc8m&m>'H3A`6C=bcF6E]9_..9-WU77 %/LS_sZeAl+a#]=(2LT1##e6;A6m>IiQq00l1.Y+"FY?QbUu1#NI^7G&!I5Y-0mtr@L9ojJq>oOXaWkeZ6M)u@B*Oc/[R^U`(jLOj %#0D#U%P`=CZf*MUQ\ahTmr_jQoBNg26X1.9cHCA]L-Qa5=$I`l["O+3RN"[:Sh1g.PT\Ig\)[CbGZPOVgD)Tpp0q<4;a!DfF %_3mk=A0DseMYY,/0+$uC:!5-4g0PEE[i1^g.rD9:\@,i?@6sIPG@;k+K3HGhV&(Kg&2DD^Xg%:_)6($_QN0UQ!AdVhMtJ2F".rQ- %R:N*WIa9>c(#[:[,8KdgVU%d:.'puc"ZX?@5[-okVmnq'6/7BqL(5tB9.Hu\hM/LfK;59Uceffp8!5.gDa%#q@(=?kIhn`dqK<]*E:GdBT":\gAH5m+e#'HbDk(r1\2^t^HpL"'OI %7>A]$/f:nAmfFQhGZEoqXP?D.l+Qj>2d>n=*aQtJ=G;Cm*H_o(qYl1Qj#3G(;q:qUEOgGm;=7]@?4'?MENpcBB:_cHh6UQ;4^(:E %^Js#&n*maDL&9jPPMI%TL\Y2TOjE4=7rSdNAR?0BZ&X+NMTE!nT]?r'#VrmmO[_CNPm"D43DdiS-QL+>' %Q$)f^SRL-N7iZ9D"Rn$hh'ejeL5>0e]0sq#XtMatq-5-,3^9Nhhu#)G#s(9^G3[[!S %]o-rTflR8\_b?ImkN!;`m[*1El'ueY-I$p[c_bdS`B-TD:QnLc6Ar-^*UAb\PtgEEb,7SI"PIl19&u)eFAD!UR]N#^2P!c!S^H;d %9fAP@/mZ9FSe0Rs:;1oE#56_]dlFN_p@QWeS'FU90uf7!-C`:0Qd2G]r"Rh+p*"i8\SFg$B%&ahVifW5Zg""2cQO3Ab%s*h.,R+? %-d&<-dk,4%9c"dZdq$p15ZuFOB9Z+L&]R.Mds:(Xbq,@03Oa=cflTsh:bOWMVK4dMZ,FH(+IgZ54d`A'=_-"WS;bZ?S'GBTbT`P% %#oU<45-&K\^J(f`ACER#d^e5T2n]6Ze."[lrF2Hm2n@i*A\Y%hH8e@0`2@OGc#,j3f5nRIB(GiaCdT,W.F5*aX)llFK&LF.=:Ks3 %*D<8#8BApF<@e37J13e#-0s6$:mN7.AH`*PQST^W9f+-2(`HAPCR;_hj;^_Lb$dF%jDjRm$d/Z%B"!Nhkej+5,%?o#^=8ZaneGMe#cXGXrl_j),DncTPNtC4c4cWWNWj2F;XJ)m\[_\No[qM %>]^eKn>LgnK0!*#hA,FkLr&[6Nb0'$e3DA]mYQ"1d+!6iK$pR_.1]Z[k8Sd38"Yl4#KF*J#U`mX6\)*ah_lI69R!(>4[TDBWCBgSSCO&SbJAp^*ZP?1.=PRU@O-G%ic%45l&E\"ce=(^k,!sHNuH=hD^YBa6AXOi'p"nEo>+* %#DJndTA>X%*O2G`GG#4IoXDT^[0@&@6L]d,EX&'U4]:H[P`G8DQ'*O1o<+cIr1T1pOi(SK%21_K>6Dk0GiF5fj![KOS %3oO@;3,HLu!/V,84Rc;,E2Vi"]%q?b_)?B@Y*ekS];H"B07Ymj]";f9+7a\@&pq*`&:b*t;]8@p*6N#aiAq'+(DsOLsa(9<>O554[2!O!)4,fJ+C->b*ARB*,e]Wt>AOAs1Jc9EhcAI,VTu %_HN+i,(=)Mn9'"bo%T^uR#Ir;^`YOOgMet:ZOd08>3`e>!RmEf,VcFh4BjC7K+n]V`ak[:O7-$QV6,Yk)Ui:\:klQZfrj_bc$5=U %)'k&QFf0"T^*g<=DaPg*\O@(Gh2*292On6$X5rbF;3g%5(ci@(]Y,P65g1Fm24nom.L,T>fGj'A?2'.Pl)p*'c)rnR*Bnn@;3't5 %CMBg<3_Xr=lFKKXS+=u@k-u?'V\Y"!+R@]F`t]%4E7V?sNiV.?Jq^.ZcMa`B+(W%Omo0[eF?Z$T]I>D>%PK'3_Hu6-]$l"##j>@c %$to="DBDMGAp&=&5QhU@m2CAmatH[+hT=W.Yu=Rb>U244]"U6^\uO!KY\iMEmTf>Af%cJY#34<0g<>a4BmA.=>OJ,fZn,Ob4h2lt %4h>fok-A+SZ`\AGb"MJ1Y+;^:e5PgRpTUdaSSFQt3eG\&`mC\F2/TiQY3>TcZBd&^[J8TGa/RNY$kR6gF3^#)=0q&g):H %ZXAQkccn",4\5hGcmmYDZe`iW3k,S3K'&>@B;n!R795jQKI4N5,T&[I%_]a):\.aWhHf>L(0uZ*Mpep!QbZ-&'r;Y%Gn%pP:E47C %Lg.I2Bdr)I`Pu*6 %ZYZe=$!Df-(85lN($D#=k%D*SCW1Q95gK?`F3o`\&SnK_lFm@E%TAgaNEdu:3R":shB_(*D'.dO/-"r$9I$n?q>FTBpc^lCY6t#@ %$R#5/#3#4eQb/kjGAc4I#K%jNg$Z26i`\8/9VtWH!+V`3Kek?*G_!XtM"W7f6*AHB@Nad*$B4Kg$j %Z-R1]9DO1sa>V;>I"o30`(ToB?A5"l.A,LWCpU[:O5u7E.k&kRaqompBSA7WPg=eCo$=h1ci&=R=^K5ekl/_=0'+m8'jsjil_"q+ %q5";-hs*.KD5b?Te:nIbps)@hePGWQ!b`P$_d %D+L(5oeY.b$D!bTC%qK"k.T($?9dm_rA7-FhIVgY'Ar-=*U%&(.8>2nT>S5WDYSB3\3@qFqqmBXua@P9LDoJ4t0jT=89#XOl_*p%(q7<@%.bp\L:*Bt`'CVN1n`A7`L,cWpi$3fHi\X+9s@"k$hV^=U]@ %P4ettO6g&:rTS^5T=8e/bi5-q67e8rBa=0In"leM?9(N'=R/S-OG\o2=c&`]]6:jimk&73->HFm9L$5Dh<>mMi/)AEkpgS6OK"h3 %(b0sf&i>!-Qn6Y-&9?ZqiPu!4.u+LhLQc:!IJYIV7Z^ERY^F*1OJ:ajKll@Kc`r+`_pWGc8NsmmO!h]ndAn&E[[D-a0qL-20J)3=V@NqH()U`Wi2Kb6&/F\o[pR8_h]\7.-*XanKe^b',I3!7>/`1*j\AF5rTh;kj**%<#*)^?Vj^KogQ/dLjFE9-@H5>d5 %^A`)KAQi+J46bg#MQOsXC>.F'oO.IHK?NE<-'DJLk96`#d]NDhMk"XSpVENRY(23&,fd*1Cd"8e*',5N,6LYaZi]R1/ha`['k]AC %CTFSDV\HGNl(OOc\hN")F!hO&G21%sM$r!7[2Pg+>>8r&TCpDXHf),Wd+$A3L86icheJMs@V\_`fG.lk %E?nSp0=>]f"e,7bo#7_MP&Dsbd\H3I9R>@H.`k>UB>p>Kqi&nQh(M:6ieaUDGuu_i*5hIO>b,?fdLJ.jfIi[AU_u8F%q]jfS9YqVmaj %?9B`O/+9S*ch*epm[*MaL0)a2!iM!#4i5,uRd";`k.J\r%=]K)=MCHuTBtFb!rDL>UVhPs/f=5^ZiNnPOnIPQ.@sl(JeIQ5#pJmS %iU6%h%I&uoW.T$,Fj@-DfQD@?rYhIp+5$5UjX5u#XX4[m9A<,/2M!n[I8T7@MN4!(]pDfRIc3In8Ct:O2W)-I=/G:.pVW0_$.@be %W7n9JXi?3/1r=.e$!nl8+[e2G*(*smPg3j>>fs.VWeH)fc@Wp2mqui56[14`7].GBXn/io:siR.e#$G=]o6K5_0]6;L^jqe5f'pY-P*ek-N/JG%I4i?=7,405D(gbj4eC %%*umD%J*$u(/_22JCV@[!/!MGW(+gk-Du$QJ"%;gJ;E/D)eo3NV%P%Uq5UET3!0tP!,f0Gj7,j7_ef>Pm9T$]gH)[TK%r_,9+(l],g2?E>>F,g(+I;P2O$N7UsN!F"SgPSJ%f^U %1HO7s'\^P*[@]X@A\YK?UX@;!%Z[<^0gZ]NVqsCL.hBJ!Sigpb\Tcp".Q-caXiJP3bZ$pCRZ0;Y4$,=M/2tVLVL=rF0OQr$Gb\^Q]_0KH*"]'/HaoaG7WN-KB#`&)daF*F7?*X8]Z9(.[4p3G)RZ %7osKoN=Lq$ag3'NH7lTO(fotO7^rD/&+5ph>%4I95I7U9];6[pdm&tS#6)hd'>=3Nb]ZI7&3fj:G4b&/08.aLmd\")!cSfCL\`N4 %7KiUn#h[7r(W!9RI:P!adSG&D9OHU)"=o)A2et\^l0"[<76fc'jgq!b*OEZ;JG74hKocGns"r5r>VmYgc*okIdK?_?dViaP=41m' %^)[PI(uj/>?[jn3d[#VP@^K/h>qkeB0$W.f_]2oVX[st7eXb08Pl2?;]mNp80M!$;V@V$1C4%/nKeGoZK`nkk"lM$OCFnT4^ %G5FoSM2U+QS450;$mW#I:ZM0%UNh="Pe#Fprqojkafb@>M4mum::F^7m<3acn2p2.-.2-c4e@G'[qq]9Ti/;EY5d!U[P)NIF!'u0 %Z@2D8-@TYbbaY"=Q5`4@F=3WK=d)qdFWSu<.$H`?EP?(ko2fj7R7VA\598r=E5GW$p9*q-dNukl=8lmO,[?GcRQ*=MDKW6HDGTST %:5'@2j.rm0mA2T\0'?O>a[]SjD07[13:iV,?1l[AXOcBlR^FAA&6tc^5M2;Cs1r5#HTgm@d--,Dq($h;`u<5L6?boQmdddUpk'6/ %lp"%J,Tu!8-!H250sQBhX>Oaugr@PRSo0Pl!g(1(:MM;F9d;$3oKl;UN>R/s2;.QJ`*5nl2CEW&7#tq\:cb)3m*XsU-C[JiZGo1K %fW=/t"J7A*V2;^Z1c,"I7sU)$'%r`RR@K5\D&dt>0!)2aD20mLJ!@BJI+Vr=plNKX)f7.>0q/$D;;?!%p%7CQRuJ5TjY+72UbY'A %IJ`%$ipQ=@_-,/^o4;9HpLgTD[J!Os7IPLYnV%kFc8i&rWs>"e>!aM?cXt %iHMuKlk4e9j421'PCIfSm%tAi?Ph!ofW*i^O3GheAGJk'f`UM>Tj";MV"[:MW_fh7ki/T, %<`LKQlK$O`cVADlp\j1M7Ak#g^6-ug<"RM?d:h6YMj7+026\S,An\\A=,4dkIr+00P1aeJPAsuK9,FePShG23o='XcGKl0p_q(*(lt:\8C12<8 %COhg2Z711-lKRL-YA7rYY0P=O_s,R%.oB_^WR/VD9!NTjH:sN0R*1Bk2*!KW3]h-"]X?!sCI>M)A8Q:ff,'XQmBE&Le58SFl/9>p %j@2'BA[a>WprUXbmRl0dqUr2Chefq?A0dSmVeALDp?5!OGE^%F25"#Pper5nRQ[2[RBQkuVtc?lP?S[WG,B/Yon/*SAku\'ImcpY %1mDBRSueb&)G6mrs"l=(cqr/O7m0^B.5ZQ)_b\l^$M;Q/'qV;!'H0T/iMUH?Kn`K=0)bZ!V9i%j\NF"f.C^8U/YAo:o`&.fs32>X %fToG8b<2AjV%ah?pV`O^PUp?,>+kjJ/k %YId'.+Z$@UF8L)9;.,$+Blgh3J:Fc1$W`2m_e+X(F?6p"A!4;`0K=K^t-CV$"\R3D'LM %?u(';M*?`q.auWj(Q!oSSf0K`rb+Ffnn]o#';CVUQU8?u%g(Meo3#tBE,^)n@-COqU&4Bjduh9Q:ApqknL.$a8Q0uZ4CF"QZa6\s%NE@lP;\)E(=LF6 %JWDUr5aC3-kTd^Hm?>b)"=C<7gIZq215/_,qT#rjJG.cS3@[-0H$79'Fj^E%(==kH3qI17+3 %pmO#*rG.VcIt+osdc^MZ/k&QBl*k0VF>`*RqR7Psq,ohNWjuc8^oY6B6Hh[oklWZkN=WY]qhPk*?t1b"CQ*]2([W-ZQV_$Y?^cUl %H:176FIZT`NJci;\^9mCYV0Lig3WAc%S]W>Hs1u4eA%U"*>duB:#][U;Onf".mI0\`U`7o=/uEuI;dD"lh,7MR7Cqq#%6a\fDhDE %]EZnkU%Da_i]+*"KWoi";\Ft&f"LoT[E&E#"MWM>Mi:*C+WcR[eVn_3:HA*V",7K<%KZo-Bo`8K[_*Lhpi0lO5r3/L=T5a?3SGWNq %561Plb,b!2T>-KQ0p:@mLf>]T%o*kdB%:r&<5J^`oDkYC&1U0Q%cor$gr*-D$RbuDQ$:+['; %aFoc4d*P20$k>*k4fg`I42r8e$p^8WBpOPM%79te7S#7q-;#[("pUa9R6.mjf..X,o4nQK8\br2+tB`i^%/9L7%U22blEDCV[=56 %6S'ah#D^3UnA1L*'d9/Z6@(%5@Pm%l=:,*7fc[#F&:j6q(BfjO@4W("]&gOdinu_V;MN/%#%7#>bG!1 %)A&PW+a+HKI_`g0Qdr&A3X4UjWQ8f7'G+.se;U&n@MtC'c9YRB'qd=i'[;eO*m.A_>H4m3jq/cn7u.O&XO]+="3H>foTu"6'd* %c$1Aln$AaH&1:aJ]NZXo&-;:o&/GnKO'0Q.gAOie$PDO"472V!3t7/2n+8H.LkiihS_=Uor2n8!+t*W(^nhB3!)Vf60?pA#nQ;m, %!V=<>M0nV=)_pb%A%*#e/6"A.!b/u;Jpg;EnH]S>Q:VXL9I&+N&3*#_1eV>hPs&S%04>8o,W@WDEZlGm^u8rgTkR]M'Ogi!i$Xd> %n&&NEN=i/^(`E*%Y%1sG^`:N"+C9fX9EE3o%j!ld/P8FjJsc]R$a:Kl5q=_[e!qqH8V=i%_I?J@l7f'"1YaLJ*HD/@`tYW!"\];O %6&8L%G+MX0B1I#S&=)Fh('.,qg+XO5N!T`q&jRk?BadRbX"Et'9Tdh6&eC*e@h9+m_Pdf8AYMtFU/-$.?[._\9WEOm:\02-,^>rU %5>qHS;gkB=A^PpYE-O[pRrR_;NuW!=U]^3(!CMl)#=9A;C=NsjjX_4LLm@5`87Oa,&n)CZZD#(@@(KEWN'L6:sm!jb=C]BF80-@QJ]E&HCDiJutgcc[-oQK>YLt&?nnTUd4R$.XeYR8P>FDYUVlt %?rRn.S^Q:NKCB58*ReMi/I78#H!&"4_*f:73QR?D*10_4iE?YUM1/4c"acLG&Wf\C?n^[X8MKY.Kj6-9hII2?9+\C%=>2:_kuC?N %S1m9WaQKgA(Q0uZNHsO/a*@l@/,!c`58Z]CBI)X+c9[i4'qd<>#/4#(JJSA64p0dhMh$*7pQCT1']K*9K8E,/>?$UuP/FL22@,@3 %H4Vdn.QnriK)P$%bh7B,qqV5e+E6XE%M)!SlgKce#Y_eM<59ap!=X0QQNIlF_-og"_&/7H&Fu8om:3N!afs&;DoLL5kt6OQi6rN4 %-Ou?u4@BYc7*<>F(^*9`k_'Q\n1C&/,7Z>t_bf-5o;7'e_7c<$S^p&]M*l;?A0+6NYm_RN<8O36CGAVLf=Hh9>k=h %<=,`-k>Vc9;]7[Uqjp%\Z?Fqn47 %![U#4&dfKt$X4E<5%Q8s5d98^9M&)q#S@gs3H"hh>`aBM"p94('WDKj.<9$90FiXGF&l3$ArL^CEuX;>m`,1# %`[GlS_))V@BJ^5Il]!W^P4KS5MQ8o.c2n1^&Z9U`c*IR[6g5&nO3o1^rtaGjAqU"!"'HdaJZ@IIpJH/nfNH&'!@m%e0#=R94_2!95![^/WZUCLu9+3aZ*[/V`3H1S %0GA=H`\I>/HSIEFN4L\LFBq5i+@Qdh^`!5`G+6s^L3),i_:"$\OmX,A'g"^O.<=^881ks8Pp@78*0*i&P)eT9cAsf?1tC9`]*9S` %\s;O\;s(q>_UNrg=EGQ(+N,Z;.bsCBC,FT*]P6=U?6&9#bJZe[G^/9 %AI9_PrivateDataEnd \ No newline at end of file diff --git a/website/public/logo/habitrpg_pixel.png b/website/public/logo/habitrpg_pixel.png new file mode 100644 index 0000000000..4397448bb2 Binary files /dev/null and b/website/public/logo/habitrpg_pixel.png differ diff --git a/website/public/manifest.json b/website/public/manifest.json new file mode 100644 index 0000000000..92bdbd9373 --- /dev/null +++ b/website/public/manifest.json @@ -0,0 +1,109 @@ +{ + "app": { + "js": [ + "bower_components/jquery/dist/jquery.min.js", + "bower_components/jquery.cookie/jquery.cookie.js", + "bower_components/pnotify/jquery.pnotify.min.js", + "bower_components/bootstrap-growl/jquery.bootstrap-growl.js", + "bower_components/bootstrap-tour/build/js/bootstrap-tour.js", + "bower_components/angular/angular.js", + "bower_components/angular-sanitize/angular-sanitize.js", + "bower_components/marked/lib/marked.js", + "bower_components/angular-ui-router/release/angular-ui-router.js", + "bower_components/angular-resource/angular-resource.min.js", + "bower_components/angular-ui-utils/ui-utils.min.js", + "bower_components/angular-loading-bar/build/loading-bar.js", + "bower_components/Angular-At-Directive/src/at.js", + "bower_components/Angular-At-Directive/src/caret.js", + "bower_components/js-emoji/emoji.js", + "bower_components/sticky/jquery.sticky.js", + "bower_components/ngInfiniteScroll/build/ng-infinite-scroll.min.js", + "bower_components/select2/select2.js", + "bower_components/angular-ui-select2/src/select2.js", + "bower_components/hello/dist/hello.all.min.js", + "bower_components/angular-filter/dist/angular-filter.min.js", + + "bower_components/angular-bootstrap/ui-bootstrap.js", + "bower_components/angular-bootstrap/ui-bootstrap-tpls.js", + "bower_components/bootstrap/dist/js/bootstrap.js", + + "bower_components/jquery-ui/ui/minified/jquery.ui.core.min.js", + "bower_components/jquery-ui/ui/minified/jquery.ui.widget.min.js", + "bower_components/jquery-ui/ui/minified/jquery.ui.mouse.min.js", + "bower_components/jquery-ui/ui/minified/jquery.ui.sortable.min.js", + + "common/dist/scripts/habitrpg-shared.js", + + "js/env.js", + + "js/app.js", + "common/script/public/config.js", + "js/services/sharedServices.js", + "js/services/notificationServices.js", + "common/script/public/userServices.js", + "common/script/public/directives.js", + "js/services/groupServices.js", + "js/services/memberServices.js", + "js/services/guideServices.js", + "js/services/challengeServices.js", + "js/services/paymentServices.js", + + "js/filters/filters.js", + + "js/directives/directives.js", + + "js/controllers/authCtrl.js", + "js/controllers/notificationCtrl.js", + "js/controllers/rootCtrl.js", + "js/controllers/settingsCtrl.js", + "js/controllers/headerCtrl.js", + "js/controllers/tasksCtrl.js", + "js/controllers/filtersCtrl.js", + "js/controllers/userCtrl.js", + "js/controllers/groupsCtrl.js", + "js/controllers/inventoryCtrl.js", + "js/controllers/footerCtrl.js", + "js/controllers/challengesCtrl.js", + "js/controllers/hallCtrl.js" + ], + "css": [ + "bower_components/bootstrap/dist/css/bootstrap.css", + "bower_components/css-social-buttons/css/zocial.css", + "app.css", + "bower_components/pnotify/jquery.pnotify.default.css", + "bower_components/pnotify/jquery.pnotify.default.icons.css", + "common/dist/sprites/habitrpg-shared.css", + "bower_components/bootstrap-tour/build/css/bootstrap-tour.css", + "fontello/css/fontelico.css" + ] + }, + "static": { + "js": [ + "bower_components/jquery/dist/jquery.min.js", + "common/dist/scripts/habitrpg-shared.js", + "bower_components/angular/angular.js", + "bower_components/angular-ui/build/angular-ui.js", + "bower_components/angular-bootstrap/ui-bootstrap.js", + "bower_components/angular-bootstrap/ui-bootstrap-tpls.js", + "bower_components/bootstrap/dist/js/bootstrap.js", + "bower_components/jquery-colorbox/jquery.colorbox-min.js", + "bower_components/hello/dist/hello.all.min.js", + + "bower_components/angular-loading-bar/build/loading-bar.js", + "js/env.js", + "js/static.js", + "js/services/notificationServices.js", + "common/script/public/userServices.js", + "js/controllers/authCtrl.js", + "js/controllers/footerCtrl.js" + ], + "css": [ + "bower_components/bootstrap/dist/css/bootstrap.css", + "bower_components/css-social-buttons/css/zocial.css", + "bower_components/jquery-colorbox/example1/colorbox.css", + "app.css", + "common/dist/sprites/habitrpg-shared.css", + "static.css" + ] + } +} diff --git a/website/public/marketing/android_iphone.png b/website/public/marketing/android_iphone.png new file mode 100644 index 0000000000..3256b4a668 Binary files /dev/null and b/website/public/marketing/android_iphone.png differ diff --git a/website/public/marketing/animals.png b/website/public/marketing/animals.png new file mode 100644 index 0000000000..ecae556c6e Binary files /dev/null and b/website/public/marketing/animals.png differ diff --git a/website/public/marketing/challenge.png b/website/public/marketing/challenge.png new file mode 100644 index 0000000000..1b83b16b0f Binary files /dev/null and b/website/public/marketing/challenge.png differ diff --git a/website/public/marketing/devices.png b/website/public/marketing/devices.png new file mode 100644 index 0000000000..8981ff8875 Binary files /dev/null and b/website/public/marketing/devices.png differ diff --git a/website/public/marketing/drops.png b/website/public/marketing/drops.png new file mode 100644 index 0000000000..2324e26eac Binary files /dev/null and b/website/public/marketing/drops.png differ diff --git a/website/public/marketing/education.png b/website/public/marketing/education.png new file mode 100644 index 0000000000..b8c330419f Binary files /dev/null and b/website/public/marketing/education.png differ diff --git a/website/public/marketing/gear.png b/website/public/marketing/gear.png new file mode 100644 index 0000000000..ef6bad6beb Binary files /dev/null and b/website/public/marketing/gear.png differ diff --git a/website/public/marketing/guild.png b/website/public/marketing/guild.png new file mode 100644 index 0000000000..bbd66b3020 Binary files /dev/null and b/website/public/marketing/guild.png differ diff --git a/website/public/marketing/guild_small.png b/website/public/marketing/guild_small.png new file mode 100644 index 0000000000..28ab8fbea3 Binary files /dev/null and b/website/public/marketing/guild_small.png differ diff --git a/website/public/marketing/integration.png b/website/public/marketing/integration.png new file mode 100644 index 0000000000..290c5dc302 Binary files /dev/null and b/website/public/marketing/integration.png differ diff --git a/website/public/marketing/lefnire.png b/website/public/marketing/lefnire.png new file mode 100644 index 0000000000..657dc1eaf5 Binary files /dev/null and b/website/public/marketing/lefnire.png differ diff --git a/website/public/marketing/promos/201403_Forest_Walker.png b/website/public/marketing/promos/201403_Forest_Walker.png new file mode 100644 index 0000000000..af6b929725 Binary files /dev/null and b/website/public/marketing/promos/201403_Forest_Walker.png differ diff --git a/website/public/marketing/promos/April14SAMPLE2.png b/website/public/marketing/promos/April14SAMPLE2.png new file mode 100644 index 0000000000..7423eabdb7 Binary files /dev/null and b/website/public/marketing/promos/April14SAMPLE2.png differ diff --git a/website/public/marketing/screenshot.png b/website/public/marketing/screenshot.png new file mode 100644 index 0000000000..eb3ba9a385 Binary files /dev/null and b/website/public/marketing/screenshot.png differ diff --git a/website/public/marketing/social_competitve.png b/website/public/marketing/social_competitve.png new file mode 100644 index 0000000000..3243a9d762 Binary files /dev/null and b/website/public/marketing/social_competitve.png differ diff --git a/website/public/marketing/wellness.png b/website/public/marketing/wellness.png new file mode 100644 index 0000000000..c91d295fc2 Binary files /dev/null and b/website/public/marketing/wellness.png differ diff --git a/website/public/page-loader.gif b/website/public/page-loader.gif new file mode 100644 index 0000000000..8be8ba338d Binary files /dev/null and b/website/public/page-loader.gif differ diff --git a/website/public/presskit/Challenges Sample Screen.png b/website/public/presskit/Challenges Sample Screen.png new file mode 100755 index 0000000000..672b209ed5 Binary files /dev/null and b/website/public/presskit/Challenges Sample Screen.png differ diff --git a/website/public/presskit/Equipment Sample Screen.png b/website/public/presskit/Equipment Sample Screen.png new file mode 100755 index 0000000000..67635ac508 Binary files /dev/null and b/website/public/presskit/Equipment Sample Screen.png differ diff --git a/website/public/presskit/Guilds Sample Screen.png b/website/public/presskit/Guilds Sample Screen.png new file mode 100755 index 0000000000..af4dea8378 Binary files /dev/null and b/website/public/presskit/Guilds Sample Screen.png differ diff --git a/website/public/presskit/HabitRPGPromoPostCard6.png b/website/public/presskit/HabitRPGPromoPostCard6.png new file mode 100755 index 0000000000..a81708db03 Binary files /dev/null and b/website/public/presskit/HabitRPGPromoPostCard6.png differ diff --git a/website/public/presskit/HabitRPGPromoThin.png b/website/public/presskit/HabitRPGPromoThin.png new file mode 100755 index 0000000000..695b8c34cf Binary files /dev/null and b/website/public/presskit/HabitRPGPromoThin.png differ diff --git a/website/public/presskit/HabitRPGdevices.png b/website/public/presskit/HabitRPGdevices.png new file mode 100755 index 0000000000..8981ff8875 Binary files /dev/null and b/website/public/presskit/HabitRPGdevices.png differ diff --git a/website/public/presskit/Laundromancer_by_Arcosine.png b/website/public/presskit/Laundromancer_by_Arcosine.png new file mode 100755 index 0000000000..b80a9b2beb Binary files /dev/null and b/website/public/presskit/Laundromancer_by_Arcosine.png differ diff --git a/website/public/presskit/Market Sample Screen.png b/website/public/presskit/Market Sample Screen.png new file mode 100755 index 0000000000..dafd5cac8f Binary files /dev/null and b/website/public/presskit/Market Sample Screen.png differ diff --git a/website/public/presskit/PROMOdevices2.png b/website/public/presskit/PROMOdevices2.png new file mode 100755 index 0000000000..0737ed4f95 Binary files /dev/null and b/website/public/presskit/PROMOdevices2.png differ diff --git a/website/public/presskit/SnackLessMonster_by_Arcosine.png b/website/public/presskit/SnackLessMonster_by_Arcosine.png new file mode 100755 index 0000000000..23a27b4b55 Binary files /dev/null and b/website/public/presskit/SnackLessMonster_by_Arcosine.png differ diff --git a/website/public/presskit/habitrpg_pixel.png b/website/public/presskit/habitrpg_pixel.png new file mode 100755 index 0000000000..4bd1c43f3b Binary files /dev/null and b/website/public/presskit/habitrpg_pixel.png differ diff --git a/website/public/presskit/presskit.zip b/website/public/presskit/presskit.zip new file mode 100644 index 0000000000..e14b647dff Binary files /dev/null and b/website/public/presskit/presskit.zip differ diff --git a/website/public/presskit/promo mobile inventorySTILL1.png b/website/public/presskit/promo mobile inventorySTILL1.png new file mode 100755 index 0000000000..08ea4352e2 Binary files /dev/null and b/website/public/presskit/promo mobile inventorySTILL1.png differ diff --git a/website/public/presskit/promo mobile inventorySTILL2.png b/website/public/presskit/promo mobile inventorySTILL2.png new file mode 100755 index 0000000000..4cc2d85925 Binary files /dev/null and b/website/public/presskit/promo mobile inventorySTILL2.png differ diff --git a/website/public/presskit/stag_battle_by_leephon.png b/website/public/presskit/stag_battle_by_leephon.png new file mode 100755 index 0000000000..8c1288ab82 Binary files /dev/null and b/website/public/presskit/stag_battle_by_leephon.png differ diff --git a/website/public/presskit/stagnant_dishes_by_kiwibot.png b/website/public/presskit/stagnant_dishes_by_kiwibot.png new file mode 100755 index 0000000000..a23a0c68bb Binary files /dev/null and b/website/public/presskit/stagnant_dishes_by_kiwibot.png differ diff --git a/website/public/presskit/vice_reborn_by_baconsaur.png b/website/public/presskit/vice_reborn_by_baconsaur.png new file mode 100755 index 0000000000..97558406e5 Binary files /dev/null and b/website/public/presskit/vice_reborn_by_baconsaur.png differ diff --git a/website/public/refresh.png b/website/public/refresh.png new file mode 100644 index 0000000000..6884f0bcf5 Binary files /dev/null and b/website/public/refresh.png differ diff --git a/website/src/controllers/auth.js b/website/src/controllers/auth.js new file mode 100644 index 0000000000..64dc988a0d --- /dev/null +++ b/website/src/controllers/auth.js @@ -0,0 +1,308 @@ +var _ = require('lodash'); +var validator = require('validator'); +var passport = require('passport'); +var shared = require('../../../common'); +var async = require('async'); +var utils = require('../utils'); +var nconf = require('nconf'); +var request = require('request'); +var User = require('../models/user').model; +var ga = require('./../utils').ga; +var i18n = require('./../i18n'); + +var isProd = nconf.get('NODE_ENV') === 'production'; + +var api = module.exports; + +var NO_TOKEN_OR_UID = { err: "You must include a token and uid (user id) in your request"}; +var NO_USER_FOUND = {err: "No user found."}; +var NO_SESSION_FOUND = { err: "You must be logged in." }; +var accountSuspended = function(uuid){ + return { + err: 'Account has been suspended, please contact leslie@habitrpg.com with your UUID ('+uuid+') for assistance.', + code: 'ACCOUNT_SUSPENDED' + }; +} +// Allow case-insensitive regex searching for Mongo queries. See http://stackoverflow.com/a/3561711/362790 +var RegexEscape = function(s){ + return new RegExp('^' + s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i'); +} + +api.auth = function(req, res, next) { + var uid = req.headers['x-api-user']; + var token = req.headers['x-api-key']; + if (!(uid && token)) return res.json(401, NO_TOKEN_OR_UID); + User.findOne({_id: uid,apiToken: token}, function(err, user) { + if (err) return next(err); + if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND); + if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); + + res.locals.wasModified = req.query._v ? +user._v !== +req.query._v : true; + res.locals.user = user; + req.session.userId = user._id; + return next(); + }); +}; + +api.authWithSession = function(req, res, next) { //[todo] there is probably a more elegant way of doing this... + if (!(req.session && req.session.userId)) + return res.json(401, NO_SESSION_FOUND); + User.findOne({_id: req.session.userId}, function(err, user) { + if (err) return next(err); + if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND); + res.locals.user = user; + next(); + }); +}; + +api.authWithUrl = function(req, res, next) { + User.findOne({_id:req.query._id, apiToken:req.query.apiToken}, function(err,user){ + if (err) return next(err); + if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND); + res.locals.user = user; + next(); + }) +} + +api.registerUser = function(req, res, next) { + var regEmail = RegexEscape(req.body.email), + regUname = RegexEscape(req.body.username); + async.auto({ + validate: function(cb) { + if (!(req.body.username && req.body.password && req.body.email)) + return cb({code:401, err: ":username, :email, :password, :confirmPassword required"}); + if (req.body.password !== req.body.confirmPassword) + return cb({code:401, err: ":password and :confirmPassword don't match"}); + if (!validator.isEmail(req.body.email)) + return cb({code:401, err: ":email invalid"}); + cb(); + }, + findReg: function(cb) { + User.findOne({$or:[{'auth.local.email': regEmail}, {'auth.local.username': regUname}]}, {'auth.local':1}, cb); + }, + findFacebook: function(cb){ + User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb); + }, + register: ['validate', 'findReg', 'findFacebook', function(cb, data) { + if (data.findReg) { + if (regEmail.test(data.findReg.auth.local.email)) return cb({code:401, err:"Email already taken"}); + if (regUname.test(data.findReg.auth.local.username)) return cb({code:401, err:"Username already taken"}); + } + var salt = utils.makeSalt(); + var newUser = { + auth: { + local: { + username: req.body.username, + email: req.body.email, + salt: salt, + hashed_password: utils.encryptPassword(req.body.password, salt) + }, + timestamps: {created: +new Date(), loggedIn: +new Date()} + } + }; + // existing user, allow them to add local authentication + if (data.findFacebook) { + data.findFacebook.auth.local = newUser.auth.local; + data.findFacebook.save(cb); + // new user, register them + } else { + newUser.preferences = newUser.preferences || {}; + newUser.preferences.language = req.language; // User language detected from browser, not saved + var user = new User(newUser); + utils.txnEmail(user, 'welcome'); + ga.event('register', 'Local').send(); + user.save(cb); + } + }] + }, function(err, data) { + if (err) return err.code ? res.json(err.code, err) : next(err); + res.json(200, data.register[0]); + }); +}; + +/* + Register new user with uname / password + */ + + +api.loginLocal = function(req, res, next) { + var username = req.body.username; + var password = req.body.password; + if (!(username && password)) return res.json(401, {err:'Missing :username or :password in request body, please provide both'}); + var login = validator.isEmail(username) ? {'auth.local.email':username} : {'auth.local.username':username}; + User.findOne(login, {auth:1}, function(err, user){ + if (err) return next(err); + if (!user) return res.json(401, {err:"Username or password incorrect. Click 'Forgot Password' for help with either. (Note: usernames are case-sensitive)"}); + if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); + // We needed the whole user object first so we can get his salt to encrypt password comparison + User.findOne( + {$and: [login, {'auth.local.hashed_password': utils.encryptPassword(password, user.auth.local.salt)}]} + , {_id:1, apiToken:1} + , function(err, user){ + if (err) return next(err); + if (!user) return res.json(401,{err:"Username or password incorrect. Click 'Forgot Password' for help with either. (Note: usernames are case-sensitive)"}); + res.json({id: user._id,token: user.apiToken}); + password = null; + }); + }); +}; + +/* + POST /user/auth/social + */ +api.loginSocial = function(req, res, next) { + var access_token = req.body.authResponse.access_token, + network = req.body.network; + if (network!=='facebook') + return res.json(401, {err:"Only Facebook supported currently."}); + async.auto({ + profile: function (cb) { + passport._strategies[network].userProfile(access_token, cb); + }, + user: ['profile', function (cb, results) { + var q = {}; + q['auth.' + network + '.id'] = results.profile.id; + User.findOne(q, {_id: 1, apiToken: 1, auth: 1}, cb); + }], + register: ['profile', 'user', function (cb, results) { + if (results.user) return cb(null, results.user); + // Create new user + var prof = results.profile; + var user = { + preferences: { + language: req.language // User language detected from browser, not saved + }, + auth: { + timestamps: {created: +new Date(), loggedIn: +new Date()} + } + }; + user.auth[network] = prof; + user = new User(user); + user.save(cb); + + utils.txnEmail(user, 'welcome'); + ga.event('register', network).send(); + }] + }, function(err, results){ + if (err) return res.json(401, {err: err.toString ? err.toString() : err}); + var acct = results.register[0] ? results.register[0] : results.register; + if (acct.auth.blocked) return res.json(401, accountSuspended(acct._id)); + return res.json(200, {id:acct._id, token:acct.apiToken}); + }) +}; + +/** + * DELETE /user/auth/social + */ +api.deleteSocial = function(req,res,next){ + if (!res.locals.user.auth.local.username) + return res.json(401, {err:"Account lacks another authentication method, can't detach Facebook"}); + //FIXME for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123 + //res.locals.user.auth.facebook = null; + //res.locals.user.auth.save(function(err, saved){ + User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){ + if (err) return next(err); + res.send(200); + }) +} + +api.resetPassword = function(req, res, next){ + var email = req.body.email, + salt = utils.makeSalt(), + newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later) + hashed_password = utils.encryptPassword(newPassword, salt); + + User.findOne({'auth.local.email': RegexEscape(email)}, function(err, user){ + if (err) return next(err); + if (!user) return res.send(500, {err:"Couldn't find a user registered for email " + email}); + user.auth.local.salt = salt; + user.auth.local.hashed_password = hashed_password; + utils.txnEmail(user, 'reset-password', [ + {name: "NEW_PASSWORD", content: newPassword}, + {name: "USERNAME", content: user.auth.local.username} + ]); + user.save(function(err){ + if(err) return next(err); + res.send('New password sent to '+ email); + email = salt = newPassword = hashed_password = null; + }); + }); +}; + +var invalidPassword = function(user, password){ + var hashed_password = utils.encryptPassword(password, user.auth.local.salt); + if (hashed_password !== user.auth.local.hashed_password) + return {code:401, err:"Incorrect password"}; + return false; +} + +api.changeUsername = function(req, res, next) { + async.waterfall([ + function(cb){ + User.findOne({'auth.local.username': RegexEscape(req.body.username)}, {auth:1}, cb); + }, + function(found, cb){ + if (found) return cb({code:401, err: "Username already taken"}); + if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password)); + res.locals.user.auth.local.username = req.body.username; + res.locals.user.save(cb); + } + ], function(err){ + if (err) return err.code ? res.json(err.code, err) : next(err); + res.send(200); + }) +} + +api.changeEmail = function(req, res, next){ + async.waterfall([ + function(cb){ + User.findOne({'auth.local.email': RegexEscape(req.body.email)}, {auth:1}, cb); + }, + function(found, cb){ + if(found) return cb({code:401, err: "Email already taken"}); + if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password)); + res.locals.user.auth.local.email = req.body.email; + res.locals.user.save(cb); + } + ], function(err){ + if (err) return err.code ? res.json(err.code,err) : next(err); + res.send(200); + }) +} + +api.changePassword = function(req, res, next) { + var user = res.locals.user, + oldPassword = req.body.oldPassword, + newPassword = req.body.newPassword, + confirmNewPassword = req.body.confirmNewPassword; + + if (newPassword != confirmNewPassword) + return res.json(401, {err: "Password & Confirm don't match"}); + + var salt = user.auth.local.salt, + hashed_old_password = utils.encryptPassword(oldPassword, salt), + hashed_new_password = utils.encryptPassword(newPassword, salt); + + if (hashed_old_password !== user.auth.local.hashed_password) + return res.json(401, {err:"Old password doesn't match"}); + + user.auth.local.hashed_password = hashed_new_password; + user.save(function(err, saved){ + if (err) next(err); + res.send(200); + }) +} + +/* + Registers a new user. Only accepting username/password registrations, no Facebook + */ + +api.setupPassport = function(router) { + + router.get('/logout', i18n.getUserLanguage, function(req, res) { + req.logout(); + delete req.session.userId; + res.redirect('/'); + }) + +}; diff --git a/website/src/controllers/challenges.js b/website/src/controllers/challenges.js new file mode 100644 index 0000000000..5fabd0fb1c --- /dev/null +++ b/website/src/controllers/challenges.js @@ -0,0 +1,431 @@ +// @see ../routes for routing + +var _ = require('lodash'); +var nconf = require('nconf'); +var async = require('async'); +var shared = require('../../../common'); +var User = require('./../models/user').model; +var Group = require('./../models/group').model; +var Challenge = require('./../models/challenge').model; +var logging = require('./../logging'); +var csv = require('express-csv'); +var utils = require('../utils'); +var api = module.exports; + + +/* + ------------------------------------------------------------------------ + Challenges + ------------------------------------------------------------------------ +*/ + +api.list = function(req, res, next) { + var user = res.locals.user; + async.waterfall([ + function(cb){ + // Get all available groups I belong to + Group.find({members: {$in: [user._id]}}).select('_id').exec(cb); + }, + function(gids, cb){ + // and their challenges + Challenge.find({ + $or:[ + {leader: user._id}, + {members:{$in:[user._id]}}, // all challenges I belong to (is this necessary? thought is a left a group, but not its challenge) + {group:{$in:gids}}, // all challenges in my groups + {group: 'habitrpg'} // public group + ], + _id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'} // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug + }) + .select('name leader description group memberCount prize official') + .select({members:{$elemMatch:{$in:[user._id]}}}) + .sort('-official -timestamp') + .populate('group', '_id name') + .populate('leader', 'profile.name') + .exec(cb); + } + ], function(err, challenges){ + if (err) return next(err); + _.each(challenges, function(c){ + c._isMember = c.members.length > 0; + }) + res.json(challenges); + user = null; + }); +} + +// GET +api.get = function(req, res, next) { + // TODO use mapReduce() or aggregate() here to + // 1) Find the sum of users.tasks.values within the challnege (eg, {'profile.name':'tyler', 'sum': 100}) + // 2) Sort by the sum + // 3) Limit 30 (only show the 30 users currently in the lead) + Challenge.findById(req.params.cid) + .populate('members', 'profile.name _id') + .exec(function(err, challenge){ + if(err) return next(err); + if (!challenge) return res.json(404, {err: 'Challenge ' + req.params.cid + ' not found'}); + res.json(challenge); + }) +} + +api.csv = function(req, res, next) { + var cid = req.params.cid; + var challenge; + async.waterfall([ + function(cb){ + Challenge.findById(cid,cb) + }, + function(_challenge,cb) { + challenge = _challenge; + if (!challenge) return cb('Challenge ' + cid + ' not found'); + User.aggregate([ + {$match:{'_id':{ '$in': challenge.members}}}, //yes, we want members + {$project:{'profile.name':1,tasks:{$setUnion:["$habits","$dailys","$todos","$rewards"]}}}, + {$unwind:"$tasks"}, + {$match:{"tasks.challenge.id":cid}}, + {$sort:{'tasks.type':1,'tasks.id':1}}, + {$group:{_id:"$_id", "tasks":{$push:"$tasks"},"name":{$first:"$profile.name"}}} + ], cb); + } + ],function(err,users){ + if(err) return next(err); + var output = ['UUID','name']; + _.each(challenge.tasks,function(t){ + //output.push(t.type+':'+t.text); + //not the right order yet + output.push('Task'); + output.push('Value'); + output.push('Notes'); + }) + output = [output]; + _.each(users, function(u){ + var uData = [u._id,u.name]; + _.each(u.tasks,function(t){ + uData = uData.concat([t.type+':'+t.text, t.value, t.notes]); + }) + output.push(uData); + }); + res.header('Content-disposition', 'attachment; filename='+cid+'.csv'); + res.csv(output); + challenge = cid = null; + }) +} + +api.getMember = function(req, res, next) { + var cid = req.params.cid; + var uid = req.params.uid; + + // We need to start using the aggregation framework instead of in-app filtering, see http://docs.mongodb.org/manual/aggregation/ + // See code at 32c0e75 for unwind/group example + + //http://stackoverflow.com/questions/24027213/how-to-match-multiple-array-elements-without-using-unwind + var proj = {'profile.name':'$profile.name'}; + _.each(['habits','dailys','todos','rewards'], function(type){ + proj[type] = { + $setDifference: [{ + $map: { + input: '$'+type, + as: "el", + in: { + $cond: [{$eq: ["$$el.challenge.id", cid]}, '$$el', false] + } + } + }, [false]] + } + }); + User.aggregate() + .match({_id: uid}) + .project(proj) + .exec(function(err, member){ + if (err) return next(err); + if (!member) return res.json(404, {err: 'Member '+uid+' for challenge '+cid+' not found'}); + res.json(member[0]); + uid = cid = null; + }); +} + +// CREATE +api.create = function(req, res, next){ + var user = res.locals.user; + + async.auto({ + get_group: function(cb){ + var q = {_id:req.body.group}; + if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group + Group.findOne(q, cb); + }, + save_chal: ['get_group', function(cb, results){ + var group = results.get_group, + prize = +req.body.prize; + if (!group) + return cb({code:404, err:"Group." + req.body.group + " not found"}); + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) + return cb({code:401, err: "Only the group leader can create challenges"}); + // If they're adding a prize, do some validation + if (prize < 0) + return cb({code:401, err: 'Challenge prize must be >= 0'}); + if (req.body.group=='habitrpg' && prize < 1) + return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'}); + if (prize > 0) { + var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0); + var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid... + if (prizeCost > user.balance + groupBalance) + return cb("You can't afford this prize. Purchase more gems or lower the prize amount.") + + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + var remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; + } + } + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + var chal = new Challenge(req.body); // FIXME sanitize + chal.members.push(user._id); + chal.save(cb); + }], + save_group: ['save_chal', function(cb, results){ + results.get_group.challenges.push(results.save_chal[0]._id); + results.get_group.save(cb); + }], + sync_user: ['save_group', function(cb, results){ + // Auto-join creator to challenge (see members.push above) + results.save_chal[0].syncToUser(user, cb); + }] + }, function(err, results){ + if (err) return err.code? res.json(err.code, err) : next(err); + return res.json(results.save_chal[0]); + user = null; + }) +} + +// UPDATE +api.update = function(req, res, next){ + var cid = req.params.cid; + var user = res.locals.user; + var before; + async.waterfall([ + function(cb){ + // We first need the original challenge data, since we're going to compare against new & decide to sync users + Challenge.findById(cid, cb); + }, + function(_before, cb) { + if (!_before) return cb('Challenge ' + cid + ' not found'); + if (_before.leader != user._id) return cb("You don't have permissions to edit this challenge"); + // Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some + // before-save / after-save comparison to determine if we need to sync to users + before = _before; + var attrs = _.pick(req.body, 'name shortName description habits dailys todos rewards date'.split(' ')); + Challenge.findByIdAndUpdate(cid, {$set:attrs}, cb); + }, + function(saved, cb) { + + // Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers + if (before.isOutdated(req.body)) { + User.find({_id: {$in: saved.members}}, function(err, users){ + logging.info('Challenge updated, sync to subscribers'); + if (err) throw err; + _.each(users, function(user){ + saved.syncToUser(user); + }) + }) + } + + // after saving, we're done as far as the client's concerned. We kick off syncing (heavy task) in the background + cb(null, saved); + } + ], function(err, saved){ + if(err) next(err); + res.json(saved); + cid = user = before = null; + }) +} + +/** + * Called by either delete() or selectWinner(). Will delete the challenge and set the "broken" property on all users' subscribed tasks + * @param {cid} the challenge id + * @param {broken} the object representing the broken status of the challenge. Eg: + * {broken: 'CHALLENGE_DELETED', id: CHALLENGE_ID} + * {broken: 'CHALLENGE_CLOSED', id: CHALLENGE_ID, winner: USER_NAME} + */ +function closeChal(cid, broken, cb) { + var removed; + async.waterfall([ + function(cb2){ + Challenge.findOneAndRemove({_id:cid}, cb2) + }, + function(_removed, cb2) { + removed = _removed; + var pull = {'$pull':{}}; pull['$pull'][_removed._id] = 1; + Group.findByIdAndUpdate(_removed.group, pull); + User.find({_id:{$in: removed.members}}, cb2); + }, + function(users, cb2) { + var parallel = []; + _.each(users, function(user){ + var tag = _.find(user.tags, {id:cid}); + if (tag) tag.challenge = undefined; + _.each(user.tasks, function(task){ + if (task.challenge && task.challenge.id == removed._id) { + _.merge(task.challenge, broken); + } + }) + parallel.push(function(cb3){ + user.save(cb3); + }) + }) + async.parallel(parallel, cb2); + removed = null; + } + ], cb); +} + +/** + * Delete & close + */ +api['delete'] = function(req, res, next){ + var user = res.locals.user; + var cid = req.params.cid; + async.waterfall([ + function(cb){ + Challenge.findById(cid, cb); + }, + function(chal, cb){ + if (!chal) return cb('Challenge ' + cid + ' not found'); + if (chal.leader != user._id) return cb("You don't have permissions to edit this challenge"); + closeChal(req.params.cid, {broken: 'CHALLENGE_DELETED'}, cb); + } + ], function(err){ + if (err) return next(err); + res.send(200); + user = cid = null; + }); +} + +/** + * Select Winner & Close + */ +api.selectWinner = function(req, res, next) { + if (!req.query.uid) return res.json(401, {err: 'Must select a winner'}); + var user = res.locals.user; + var cid = req.params.cid; + var chal; + async.waterfall([ + function(cb){ + Challenge.findById(cid, cb); + }, + function(_chal, cb){ + chal = _chal; + if (!chal) return cb('Challenge ' + cid + ' not found'); + if (chal.leader != user._id) return cb("You don't have permissions to edit this challenge"); + User.findById(req.query.uid, cb) + }, + function(winner, cb){ + if (!winner) return cb('Winner ' + req.query.uid + ' not found.'); + _.defaults(winner.achievements, {challenges: []}); + winner.achievements.challenges.push(chal.name); + winner.balance += chal.prize/4; + winner.save(cb); + }, + function(saved, num, cb) { + if(saved.preferences.emailNotifications.wonChallenge !== false){ + utils.txnEmail(saved, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: chal.name} + ]); + } + closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb); + } + ], function(err){ + if (err) return next(err); + res.send(200); + user = cid = chal = null; + }) +} + +api.join = function(req, res, next){ + var user = res.locals.user; + var cid = req.params.cid; + + async.waterfall([ + function(cb) { + Challenge.findByIdAndUpdate(cid, {$addToSet:{members:user._id}}, cb); + }, + function(chal, cb) { + + // Trigger updating challenge member count in the background. We can't do it above because we don't have + // _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above. + Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec(); + + if (!~user.challenges.indexOf(cid)) + user.challenges.unshift(cid); + // Add all challenge's tasks to user's tasks + chal.syncToUser(user, function(err){ + if (err) return cb(err); + cb(null, chal); // we want the saved challenge in the return results, due to ng-resource + }); + } + ], function(err, chal){ + if(err) return next(err); + chal._isMember = true; + res.json(chal); + user = cid = null; + }); +} + + +api.leave = function(req, res, next){ + var user = res.locals.user; + var cid = req.params.cid; + // whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided + var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; + + async.waterfall([ + function(cb){ + Challenge.findByIdAndUpdate(cid, {$pull:{members:user._id}}, cb); + }, + function(chal, cb){ + + // Trigger updating challenge member count in the background. We can't do it above because we don't have + // _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above. + if (chal) + Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec(); + + var i = user.challenges.indexOf(cid) + if (~i) user.challenges.splice(i,1); + user.unlink({cid:cid, keep:keep}, function(err){ + if (err) return cb(err); + cb(null, chal); + }) + } + ], function(err, chal){ + if(err) return next(err); + if (chal) chal._isMember = false; + res.json(chal); + user = cid = keep = null; + }); +} + +api.unlink = function(req, res, next) { + // they're scoring the task - commented out, we probably don't need it due to route ordering in api.js + //var urlParts = req.originalUrl.split('/'); + //if (_.contains(['up','down'], urlParts[urlParts.length -1])) return next(); + + var user = res.locals.user; + var tid = req.params.id; + var cid = user.tasks[tid].challenge.id; + if (!req.query.keep) + return res.json(400, {err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'}); + user.unlink({cid:cid, keep:req.query.keep, tid:tid}, function(err, saved){ + if (err) return next(err); + res.send(200); + user = tid = cid = null; + }); +} diff --git a/website/src/controllers/coupon.js b/website/src/controllers/coupon.js new file mode 100644 index 0000000000..b8450d34f3 --- /dev/null +++ b/website/src/controllers/coupon.js @@ -0,0 +1,36 @@ +var _ = require('lodash'); +var Coupon = require('./../models/coupon').model; +var api = module.exports; +var csv = require('express-csv'); +var async = require('async'); + +api.ensureAdmin = function(req, res, next) { + if (!res.locals.user.contributor.sudo) return res.json(401, {err:"You don't have admin access"}); + next(); +} + +api.generateCoupons = function(req,res,next) { + Coupon.generate(req.params.event, req.query.count, function(err){ + if(err) return next(err); + res.send(200); + }); +} + +api.getCoupons = function(req,res,next) { + var options = {sort:'seq'}; + if (req.query.limit) options.limit = req.query.limit; + if (req.query.skip) options.skip = req.query.skip; + Coupon.find({},{}, options, function(err,coupons){ + //res.header('Content-disposition', 'attachment; filename=coupons.csv'); + res.csv([['code']].concat(_.map(coupons, function(c){ + return [c._id]; + }))); + }); +} + +api.enterCode = function(req,res,next) { + Coupon.apply(res.locals.user,req.params.code,function(err,user){ + if (err) return res.json(400,{err:err}); + res.json(user); + }); +} diff --git a/website/src/controllers/dataexport.js b/website/src/controllers/dataexport.js new file mode 100644 index 0000000000..8a48ee1638 --- /dev/null +++ b/website/src/controllers/dataexport.js @@ -0,0 +1,138 @@ +var _ = require('lodash'); +var csv = require('express-csv'); +var express = require('express'); +var nconf = require('nconf'); +var moment = require('moment'); +var dataexport = module.exports; +var js2xmlparser = require("js2xmlparser"); +var pd = require('pretty-data').pd; +var User = require('../models/user').model; + +// Avatar screenshot/static-page includes +var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres +var AWS = require('aws-sdk'); +AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); +var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream +var bucket = nconf.get("S3:bucket"); +var request = require('request'); + +/* + ------------------------------------------------------------------------ + Data export + ------------------------------------------------------------------------ +*/ + +dataexport.history = function(req, res) { + var user = res.locals.user; + var output = [ + ["Task Name", "Task ID", "Task Type", "Date", "Value"] + ]; + _.each(user.tasks, function(task) { + _.each(task.history, function(history) { + output.push( + [task.text, task.id, task.type, moment(history.date).format("MM-DD-YYYY HH:mm:ss"), history.value] + ); + }); + }); + return res.csv(output); +} + +var userdata = function(user) { + if(user.auth && user.auth.local) { + delete user.auth.local.salt; + delete user.auth.local.hashed_password; + } + return user; +} + +dataexport.leanuser = function(req, res, next) { + User.findOne({_id: res.locals.user._id}).lean().exec(function(err, user) { + if (err) return res.json(500, {err: err}); + if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND); + res.locals.user = user; + return next(); + }); +}; + +dataexport.userdata = { + xml: function(req, res) { + var user = userdata(res.locals.user); + return res.xml({data: JSON.stringify(user), rootname: 'user'}); + }, + json: function(req, res) { + var user = userdata(res.locals.user); + return res.jsonstring(user); + } +} + +/* + ------------------------------------------------------------------------ + Express Extensions (should be refactored into a module) + ------------------------------------------------------------------------ +*/ + +var expressres = express.response || http.ServerResponse.prototype; + +expressres.xml = function(obj, headers, status) { + var body = ''; + this.charset = this.charset || 'utf-8'; + this.header('Content-Type', 'text/xml'); + this.header('Content-Disposition', 'attachment'); + body = pd.xml(js2xmlparser(obj.rootname,obj.data)); + return this.send(body, headers, status); +}; + +expressres.jsonstring = function(obj, headers, status) { + var body = ''; + this.charset = this.charset || 'utf-8'; + this.header('Content-Type', 'application/json'); + this.header('Content-Disposition', 'attachment'); + body = pd.json(JSON.stringify(obj)); + return this.send(body, headers, status); +}; + +/* + ------------------------------------------------------------------------ + Static page and image screenshot of avatar + ------------------------------------------------------------------------ + */ + + +dataexport.avatarPage = function(req, res) { + User.findById(req.params.uuid).select('stats profile items achievements preferences backer contributor').exec(function(err, user){ + res.render('avatar-static', { + title: user.profile.name, + env: _.defaults({user:user},res.locals.habitrpg) + }); + }) +}; + +dataexport.avatarImage = function(req, res, next) { + var filename = 'avatar-'+req.params.uuid+'.png'; + request.head('https://'+bucket+'.s3.amazonaws.com/'+filename, function(err,response,body) { + // cache images for 10 minutes on aws, else upload a new one + if (response.statusCode==200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) + return res.redirect(301, 'https://' + bucket + '.s3.amazonaws.com/' + filename); + new Pageres()//{delay:1} + .src(nconf.get('BASE_URL') + '/export/avatar-' + req.params.uuid + '.html', ['140x147'], {crop: true, filename: filename.replace('.png', '')}) + .run(function (err, file) { + if (err) return next(err); + // see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#createMultipartUpload-property + var upload = s3Stream.upload({ + Bucket: bucket, + Key: filename, + ACL: "public-read", + StorageClass: "REDUCED_REDUNDANCY", + ContentType: "image/png", + Expires: +moment().add({minutes: 3}) + }); + upload.on('error', function (err) { + next(err); + }); + upload.on('uploaded', function (details) { + res.redirect(details.Location); + }); + file[0].pipe(upload); + }); + }) +}; diff --git a/website/src/controllers/groups.js b/website/src/controllers/groups.js new file mode 100644 index 0000000000..6883aebef0 --- /dev/null +++ b/website/src/controllers/groups.js @@ -0,0 +1,940 @@ +// @see ../routes for routing + +function clone(a) { + return JSON.parse(JSON.stringify(a)); +} + +var _ = require('lodash'); +var nconf = require('nconf'); +var async = require('async'); +var utils = require('./../utils'); +var shared = require('../../../common'); +var User = require('./../models/user').model; +var Group = require('./../models/group').model; +var Challenge = require('./../models/challenge').model; +var isProd = nconf.get('NODE_ENV') === 'production'; +var api = module.exports; + +/* + ------------------------------------------------------------------------ + Groups + ------------------------------------------------------------------------ +*/ + +var partyFields = api.partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; +var nameFields = 'profile.name'; +var challengeFields = '_id name'; +var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} }; +/** + * For parties, we want a lot of member details so we can show their avatars in the header. For guilds, we want very + * limited fields - and only a sampling of the members, beacuse they can be in the thousands + * @param type: 'party' or otherwise + * @param q: the Mongoose query we're building up + * @param additionalFields: if we want to populate some additional field not fetched normally + * pass it as a string, parties only + */ +var populateQuery = function(type, q, additionalFields){ + if (type == 'party') + q.populate('members', partyFields + (additionalFields ? (' ' + additionalFields) : '')); + else + q.populate(guildPopulate); + q.populate('invites', nameFields); + q.populate({ + path: 'challenges', + match: (type=='habitrpg') ? {_id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'}} : undefined, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug + select: challengeFields, + options: {sort: {official: -1, timestamp: -1}} + }); + return q; +} + +/** + * Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually + * as /groups/party or /groups/tavern + */ +api.list = function(req, res, next) { + var user = res.locals.user; + var groupFields = 'name description memberCount balance leader'; + var sort = '-memberCount'; + var type = req.query.type || 'party,guilds,public,tavern'; + + async.parallel({ + + // unecessary given our ui-router setup + party: function(cb){ + if (!~type.indexOf('party')) return cb(null, {}); + Group.findOne({type: 'party', members: {'$in': [user._id]}}) + .select(groupFields).exec(function(err, party){ + if (err) return cb(err); + cb(null, (party === null ? [] : [party])); // return as an array for consistent ngResource use + }); + }, + + guilds: function(cb) { + if (!~type.indexOf('guilds')) return cb(null, []); + Group.find({members: {'$in': [user._id]}, type:'guild'}) + .select(groupFields).sort(sort).exec(cb); + }, + + 'public': function(cb) { + if (!~type.indexOf('public')) return cb(null, []); + Group.find({privacy: 'public'}) + .select(groupFields + ' members') + .sort(sort) + .lean() + .exec(function(err, groups){ + if (err) return cb(err); + _.each(groups, function(g){ + // To save some client-side performance, don't send down the full members arr, just send down temp var _isMember + if (~g.members.indexOf(user._id)) g._isMember = true; + g.members = undefined; + }); + cb(null, groups); + }); + }, + + // unecessary given our ui-router setup + tavern: function(cb) { + if (!~type.indexOf('tavern')) return cb(null, {}); + Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){ + if (err) return cb(err); + cb(null, [tavern]); // return as an array for consistent ngResource use + }); + } + + }, function(err, results){ + if (err) return next(err); + // ngResource expects everything as arrays. We used to send it down as a structured object: {public:[], party:{}, guilds:[], tavern:{}} + // but unfortunately ngResource top-level attrs are considered the ngModels in the list, so we had to do weird stuff and multiple + // requests to get it to work properly. Instead, we're not depending on the client to do filtering / organization, and we're + // just sending down a merged array. Revisit + var arr = _.reduce(results, function(m,v){ + if (_.isEmpty(v)) return m; + return m.concat(_.isArray(v) ? v : [v]); + }, []) + res.json(arr); + + user = groupFields = sort = type = null; + }) +}; + +/** + * Get group + * TODO: implement requesting fields ?fields=chat,members + */ +api.get = function(req, res, next) { + var user = res.locals.user; + var gid = req.params.gid; + + var q = (gid == 'party') + ? Group.findOne({type: 'party', members: {'$in': [user._id]}}) + : Group.findOne({$or:[ + {_id:gid, privacy:'public'}, + {_id:gid, privacy:'private', members: {$in:[user._id]}} // if the group is private, only return if they have access + ]}); + populateQuery(gid, q); + q.exec(function(err, group){ + if (err) return next(err); + if (!group && gid!=='party') return res.json(404,{err: "Group not found or you don't have access."}); + res.json(group); + gid = null; + }); +}; + + +api.create = function(req, res, next) { + var group = new Group(req.body); + var user = res.locals.user; + group.members = [user._id]; + group.leader = user._id; + + if(group.type === 'guild'){ + if(user.balance < 1) return res.json(401, {err: 'Not enough gems!'}); + + group.balance = 1; + user.balance--; + + async.waterfall([ + function(cb){user.save(cb)}, + function(saved,ct,cb){group.save(cb)}, + function(saved,ct,cb){saved.populate('members',nameFields,cb)} + ],function(err,saved){ + if (err) return next(err); + res.json(saved); + group = user = null; + }); + + }else{ + async.waterfall([ + function(cb){ + Group.findOne({type:'party',members:{$in:[user._id]}},cb); + }, + function(found, cb){ + if (found) return cb('Already in a party, try refreshing.'); + group.save(cb); + }, + function(saved, count, cb){ + saved.populate('members', nameFields, cb); + } + ], function(err, populated){ + if (err == 'Already in a party, try refreshing.') return res.json(400,{err:err}); + if (err) return next(err); + return res.json(populated); + group = user = null; + }) + } +} + +api.update = function(req, res, next) { + var group = res.locals.group; + var user = res.locals.user; + + if(group.leader !== user._id) + return res.json(401, {err: "Only the group leader can update the group!"}); + + 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){ + group[attr] = req.body[attr]; + }); + + group.save(function(err, saved){ + if (err) return next(err); + res.send(204); + }); +} + +api.attachGroup = function(req, res, next) { + var gid = req.params.gid; + var q = (gid == 'party') ? Group.findOne({type: 'party', members: {'$in': [res.locals.user._id]}}) : Group.findById(gid); + q.exec(function(err, group){ + if(err) return next(err); + if(!group) return res.json(404, {err: "Group not found"}); + res.locals.group = group; + next(); + }) +} + +api.getChat = function(req, res, next) { + // TODO: This code is duplicated from api.get - pull it out into a function to remove duplication. + var user = res.locals.user; + var gid = req.params.gid; + var q = (gid == 'party') + ? Group.findOne({type: 'party', members: {$in:[user._id]}}) + : Group.findOne({$or:[ + {_id:gid, privacy:'public'}, + {_id:gid, privacy:'private', members: {$in:[user._id]}} + ]}); + populateQuery(gid, q); + q.exec(function(err, group){ + if (err) return next(err); + if (!group && gid!=='party') return res.json(404,{err: "Group not found or you don't have access."}); + res.json(res.locals.group.chat); + gid = null; + }); +}; + +/** + * TODO make this it's own ngResource so we don't have to send down group data with each chat post + */ +api.postChat = function(req, res, next) { + var user = res.locals.user + var group = res.locals.group; + if (group.type!='party' && user.flags.chatRevoked) return res.json(401,{err:'Your chat privileges have been revoked.'}); + var lastClientMsg = req.query.previousMsg; + var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false; + + group.sendChat(req.query.message, user); // FIXME this should be body, but ngResource is funky + + if (group.type === 'party') { + user.party.lastMessageSeen = group.chat[0].id; + user.save(); + } + + group.save(function(err, saved){ + if (err) return next(err); + return chatUpdated ? res.json({chat: group.chat}) : res.json({message: saved.chat[0]}); + group = chatUpdated = null; + }); +} + +api.deleteChatMessage = function(req, res, next){ + var user = res.locals.user + var group = res.locals.group; + var message = _.find(group.chat, {id: req.params.messageId}); + + if(!message) return res.json(404, {err: "Message not found!"}); + + if(user._id !== message.uuid && !(user.backer && user.contributor.admin)) + return res.json(401, {err: "Not authorized to delete this message!"}) + + var lastClientMsg = req.query.previousMsg; + var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false; + + Group.update({_id:group._id}, {$pull:{chat:{id: req.params.messageId}}}, function(err){ + if(err) return next(err); + chatUpdated ? res.json({chat: group.chat}) : res.send(204); + group = chatUpdated = null; + }); +} + +api.flagChatMessage = function(req, res, next){ + var user = res.locals.user + var group = res.locals.group; + var message = _.find(group.chat, {id: req.params.mid}); + + if(!message) return res.json(404, {err: "Message not found!"}); + if(message.uuid == user._id) return res.json(401, {err: "Can't report your own message."}); + + User.findOne({_id: message.uuid}, {auth: 1}, function(err, author){ + if(err) return next(err); + + // Log user ids that have flagged the message + if(!message.flags) message.flags = {}; + if(message.flags[user._id] && !user.contributor.admin) return res.json(401, {err: "You have already reported this message"}); + message.flags[user._id] = true; + + // Log total number of flags (publicly viewable) + if(!message.flagCount) message.flagCount = 0; + if(user.contributor.admin){ + // Arbitraty amount, higher than 2 + message.flagCount = 5; + } else { + message.flagCount++ + } + + group.markModified('chat'); + group.save(function(err,_saved){ + if(err) return next(err); + var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL')); + + if(Array.isArray(addressesToSendTo)){ + addressesToSendTo = addressesToSendTo.map(function(email){ + return {email: email, canSend: true} + }); + }else{ + addressesToSendTo = {email: addressesToSendTo} + } + + utils.txnEmail(addressesToSendTo, 'flag-report-to-mods', [ + {name: "MESSAGE_TIME", content: (new Date(message.timestamp)).toString()}, + {name: "MESSAGE_TEXT", content: message.text}, + + {name: "REPORTER_USERNAME", content: user.profile.name}, + {name: "REPORTER_UUID", content: user._id}, + {name: "REPORTER_EMAIL", content: user.auth.local ? user.auth.local.email : ((user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) ? user.auth.facebook.emails[0].value : null)}, + {name: "REPORTER_MODAL_URL", content: "https://habitrpg.com/static/front/#?memberId=" + user._id}, + + {name: "AUTHOR_USERNAME", content: message.user}, + {name: "AUTHOR_UUID", content: message.uuid}, + {name: "AUTHOR_EMAIL", content: author.auth.local ? author.auth.local.email : ((author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) ? author.auth.facebook.emails[0].value : null)}, + {name: "AUTHOR_MODAL_URL", content: "https://habitrpg.com/static/front/#?memberId=" + message.uuid}, + + {name: "GROUP_NAME", content: group.name}, + {name: "GROUP_TYPE", content: group.type}, + {name: "GROUP_ID", content: group._id}, + {name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')}, + ]); + + return res.send(204); + }); + }); + +} + +api.clearFlagCount = function(req, res, next){ + var user = res.locals.user + var group = res.locals.group; + var message = _.find(group.chat, {id: req.params.mid}); + + if(!message) return res.json(404, {err: "Message not found!"}); + + if(user.contributor.admin){ + message.flagCount = 0; + + group.markModified('chat'); + group.save(function(err,_saved){ + if(err) return next(err); + return res.send(204); + }); + }else{ + return res.json(401, {err: "Only an admin can clear the flag count!"}) + } + +} + +api.seenMessage = function(req,res,next){ + // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save + // Check for req.params.gid to exist + if(req.params.gid){ + var update = {$unset:{}}; + update['$unset']['newMessages.'+req.params.gid] = ''; + User.update({_id:req.headers['x-api-user'], apiToken:req.headers['x-api-key']},update).exec(); + } + res.send(200); +} + +api.likeChatMessage = function(req, res, next) { + var user = res.locals.user; + var group = res.locals.group; + var message = _.find(group.chat, {id: req.params.mid}); + if (!message) return res.json(404, {err: "Message not found!"}); + if (message.uuid == user._id) return res.json(401, {err: "Can't like your own message. Don't be that person."}); + if (!message.likes) message.likes = {}; + if (message.likes[user._id]) { + delete message.likes[user._id]; + } else { + message.likes[user._id] = true; + } + group.markModified('chat'); + group.save(function(err,_saved){ + if (err) return next(err); + return res.send(_saved.chat); + }) +} + +api.join = function(req, res, next) { + var user = res.locals.user, + group = res.locals.group; + + if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) { + User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter + user.invitations.party = undefined; // Clear invite + user.save(); + // invite new user to pending quest + if (group.quest.key && !group.quest.active) { + group.quest.members[user._id] = undefined; + group.markModified('quest.members'); + } + } + else if (group.type == 'guild' && user.invitations && user.invitations.guilds) { + var i = _.findIndex(user.invitations.guilds, {id:group._id}); + if (~i) user.invitations.guilds.splice(i,1); + user.save(); + } + + if (!_.contains(group.members, user._id)){ + group.members.push(user._id); + group.invites.splice(_.indexOf(group.invites, user._id), 1); + } + + async.series([ + function(cb){ + group.save(cb); + }, + function(cb){ + populateQuery(group.type, Group.findById(group._id)).exec(cb); + } + ], function(err, results){ + if (err) return next(err); + + // Return the group? Or not? + res.json(results[1]); + group = null; + }); +} + +api.leave = function(req, res, next) { + var user = res.locals.user, + group = res.locals.group; + // When removing the user from challenges, should we keep the tasks? + var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; + async.parallel([ + // Remove active quest from user if they're leaving the party + function(cb){ + if (group.type != 'party') return cb(null,{},1); + user.party.quest = Group.cleanQuestProgress(); + user.save(cb); + }, + // Remove user from group challenges + function(cb){ + async.waterfall([ + // Find relevant challenges + function(cb2) { + Challenge.find({ + _id: {$in: user.challenges}, // Challenges I am in + group: group._id // that belong to the group I am leaving + }, cb2); + }, + // Update each challenge + function(challenges, cb2) { + Challenge.update( + {_id:{$in: _.pluck(challenges, '_id')}}, + {$pull:{members:user._id}}, + {multi: true}, + function(err) { + cb2(err, challenges); // pass `challenges` above to cb + } + ); + }, + // Unlink the challenge tasks from user + function(challenges, cb2) { + async.waterfall(challenges.map(function(chal) { + return function(cb3) { + var i = user.challenges.indexOf(chal._id) + if (~i) user.challenges.splice(i,1); + user.unlink({cid:chal._id, keep:keep}, cb3); + } + }), cb2); + } + ], cb); + }, + // Update the group + function(cb){ + var update = {$pull:{members:user._id}}; + if (group.type == 'party' && group.quest.key){ + update['$unset'] = {}; + update['$unset']['quest.members.' + user._id] = 1; + } + // FIXME do we want to remove the group `if group.members.length == 0` ? (well, 1 since the update hasn't gone through yet) + if (group.members.length > 1) { + var seniorMember = _.find(group.members, function (m) {return m != user._id}); + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + var leader = group.leader; + if (leader == user._id || !~group.members.indexOf(leader)) { + update['$set'] = update['$set'] || {}; + update['$set'].leader = seniorMember; + } + leader = group.quest && group.quest.leader; + if (leader && (leader == user._id || !~group.members.indexOf(leader))) { + update['$set'] = update['$set'] || {}; + update['$set']['quest.leader'] = seniorMember; + } + } + update['$inc'] = {memberCount: -1}; + Group.update({_id:group._id},update,cb); + } + ],function(err){ + if (err) return next(err); + return res.send(204); + user = group = keep = null; + }) +} + +var inviteByUUIDs = function(uuids, group, req, res, next){ + async.each(uuids, function(uuid, cb){ + User.findById(uuid, function(err,invite){ + if (err) return cb(err); + if (!invite) + return cb({code:400,err:'User with id "' + uuid + '" not found'}); + if (group.type == 'guild') { + if (_.contains(group.members,uuid)) + return cb({code:400,err: "User already in that group"}); + if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) + return cb({code:400,err:"User already invited to that group"}); + sendInvite(); + } else if (group.type == 'party') { + if (invite.invitations && !_.isEmpty(invite.invitations.party)) + return cb({code:400,err:"User already pending invitation."}); + Group.find({type:'party', members:{$in:[uuid]}}, function(err, groups){ + if (err) return cb(err); + if (!_.isEmpty(groups)) + return cb({code:400,err:"User already in a party."}) + sendInvite(); + }); + } + + function sendInvite (){ + if(group.type === 'guild'){ + invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + }else{ + //req.body.type in 'guild', 'party' + invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + } + + group.invites.push(invite._id); + + async.series([ + function(cb){ + invite.save(cb); + }, + function(cb){ + group.save(cb); + } + ], function(err, results){ + if (err) return cb(err); + + if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ + var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); + var emailVars = [ + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} + ]; + + if(group.type == 'guild'){ + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/public'} + ); + }else{ + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} + ) + } + + utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); + } + + cb(); + }); + } + }); + }, function(err){ + if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); + + // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? + populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ + if(err) return next(err); + + res.json(populatedGroup); + }); + }); + +}; + +var inviteByEmails = function(invites, group, req, res, next){ + var usersAlreadyRegistered = []; + + async.each(invites, function(invite, cb){ + if (invite.email) { + User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email} + ]}).select({_id: true, 'preferences.emailNotifications': true}) + .exec(function(err, userToContact){ + if(err) return next(err); + + if(userToContact){ + usersAlreadyRegistered.push(userToContact._id); + return cb(); + } + + // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query + var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:group._id, inviter:res.locals.user._id, name:group.name})); + + var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); + var variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} + ]; + + if(group.type == 'guild'){ + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // TODO implement "users can only be invited once" + invite.canSend = true; // Requested by utils.txnEmail + utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); + + cb(); + }); + }else{ + cb(); + } + }, function(err){ + if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); + + if(usersAlreadyRegistered.length > 0){ + inviteByUUIDs(usersAlreadyRegistered, group, req, res, next); + }else{ + + // Send only status code down the line because it doesn't need + // info on invited users since they are not yet registered + res.send(200); + } + }); +}; + +api.invite = function(req, res, next){ + var group = res.locals.group; + + if(req.body.uuids){ + inviteByUUIDs(req.body.uuids, group, req, res, next); + }else if(req.body.emails){ + inviteByEmails(req.body.emails, group, req, res, next) + }else{ + return res.json(400,{err: "Can invite only by email or uuid"}); + } +} + +api.removeMember = function(req, res, next){ + var group = res.locals.group; + var uuid = req.query.uuid; + var user = res.locals.user; + + if(group.leader !== user._id){ + return res.json(401, {err: "Only group leader can remove a member!"}); + } + + if(_.contains(group.members, uuid)){ + var update = {$pull:{members:uuid}}; + if(group.quest && group.quest.members){ + // remove member from quest + update['$unset'] = {}; + update['$unset']['quest.members.' + uuid] = ""; + // TODO: run cleanQuestProgress and return scroll to member if member was quest owner + } + update['$inc'] = {memberCount: -1}; + Group.update({_id:group._id},update, function(err, saved){ + if (err) return next(err); + + // Sending an empty 204 because Group.update doesn't return the group + // see http://mongoosejs.com/docs/api.html#model_Model.update + return res.send(204); + }); + }else if(_.contains(group.invites, uuid)){ + User.findById(uuid, function(err,invited){ + var invitations = invited.invitations; + if(group.type === 'guild'){ + invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1); + }else{ + invitations.party = undefined; + } + + async.series([ + function(cb){ + invited.save(cb); + }, + function(cb){ + Group.update({_id:group._id},{$pull:{invites:uuid}}, cb); + } + ], function(err, results){ + if (err) return next(err); + + // Sending an empty 204 because Group.update doesn't return the group + // see http://mongoosejs.com/docs/api.html#model_Model.update + return res.send(204); + group = uuid = null; + }); + + }); + }else{ + return res.json(400, {err: "User not found among group's members!"}); + group = uuid = null; + } +} + +// ------------------------------------ +// Quests +// ------------------------------------ + +questStart = function(req, res, next) { + var group = res.locals.group; + var force = req.query.force; + + // if (group.quest.active) return res.json(400,{err:'Quest already began.'}); + // temporarily send error email, until we know more about this issue (then remove below, uncomment above). + if (group.quest.active) return next('Quest already began.'); + + group.markModified('quest'); + + // Not ready yet, wait till everyone's accepted, rejected, or we force-start + var statuses = _.values(group.quest.members); + if (!force && (~statuses.indexOf(undefined) || ~statuses.indexOf(null))) { + return group.save(function(err,saved){ + if (err) return next(err); + res.json(saved); + }) + } + + var parallel = [], + questMembers = {}, + key = group.quest.key, + quest = shared.content.quests[key], + collected = quest.collect ? _.transform(quest.collect, function(m,v,k){m[k]=0}) : {}; + + _.each(group.members, function(m){ + var updates = {$set:{},$inc:{'_v':1}}; + if (m == group.quest.leader) + updates['$inc']['items.quests.'+key] = -1; + if (group.quest.members[m] == true) { + // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 , we need to *not* reset party.quest.progress.up + //updates['$set']['party.quest'] = Group.cleanQuestProgress({key:key,progress:{collect:collected}}); + updates['$set']['party.quest.key'] = key; + updates['$set']['party.quest.progress.down'] = 0; + updates['$set']['party.quest.progress.collect'] = collected; + updates['$set']['party.quest.completed'] = null; + questMembers[m] = true; + } else { + updates['$set']['party.quest'] = Group.cleanQuestProgress(); + } + parallel.push(function(cb2){ + User.update({_id:m},updates,cb2); + }); + }) + + group.quest.active = true; + if (quest.boss) { + group.quest.progress.hp = quest.boss.hp; + if (quest.boss.rage) group.quest.progress.rage = 0; + } else { + group.quest.progress.collect = collected; + } + group.quest.members = questMembers; + group.markModified('quest'); // members & progress.collect are both Mixed types + parallel.push(function(cb2){group.save(cb2)}); + + parallel.push(function(cb){ + // Fetch user.auth to send email, then remove it from data sent to the client + populateQuery(group.type, Group.findById(group._id), 'auth.facebook auth.local').exec(cb); + }); + + async.parallel(parallel,function(err, results){ + if (err) return next(err); + + var lastIndex = results.length -1; + var groupClone = clone(group); + + groupClone.members = results[lastIndex].members; + + // Send quest started email and remove auth information + _.each(groupClone.members, function(user){ + + if(user.preferences.emailNotifications.questStarted !== false && + user._id !== res.locals.user._id && + group.quest.members[user._id] == true + ){ + utils.txnEmail(user, 'quest-started', [ + {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} + ]); + } + + // Remove sensitive data from what is sent to the public + user.auth.facebook = undefined; + user.auth.local = undefined; + }); + + group = null; + + return res.json(groupClone); + }); +} + +api.questAccept = function(req, res, next) { + var group = res.locals.group; + var user = res.locals.user; + var key = req.query.key; + + if (!group) return res.json(400, {err: "Must be in a party to start quests."}); + + // If ?key=xxx is provided, we're starting a new quest and inviting the party. Otherwise, we're a party member accepting the invitation + if (key) { + var quest = shared.content.quests[key]; + if (!quest) return res.json(404,{err:'Quest ' + key + ' not found'}); + if (quest.lvl && user.stats.lvl < quest.lvl) return res.json(400, {err: "You must be level "+quest.lvl+" to begin this quest."}); + if (group.quest.key) return res.json(400, {err: 'Party already on a quest (and only have one quest at a time)'}); + if (!user.items.quests[key]) return res.json(400, {err: "You don't own that quest scroll"}); + group.quest.key = key; + group.quest.members = {}; + // Invite everyone. true means "accepted", false="rejected", undefined="pending". Once we click "start quest" + // or everyone has either accepted/rejected, then we store quest key in user object. + _.each(group.members, function(m){ + if (m == user._id) { + group.quest.members[m] = true; + group.quest.leader = user._id; + } else { + group.quest.members[m] = undefined; + } + }); + + User.find({ + _id: { + $in: _.without(group.members, user._id) + } + }, {auth: 1, preferences: 1, profile: 1}, function(err, members){ + if(err) return next(err); + + var inviterVars = utils.getUserInfo(user, ['name', 'email']); + + _.each(members, function(member){ + if(member.preferences.emailNotifications.invitedQuest !== false){ + utils.txnEmail(member, ('invite-' + (quest.boss ? 'boss' : 'collection') + '-quest'), [ + {name: 'QUEST_NAME', content: quest.text()}, + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email}, + {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} + ]); + } + }); + + questStart(req,res,next); + }); + + // Party member accepting the invitation + } else { + if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'}); + group.quest.members[user._id] = true; + questStart(req,res,next); + } +} + +api.questReject = function(req, res, next) { + var group = res.locals.group; + var user = res.locals.user; + + if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'}); + group.quest.members[user._id] = false; + questStart(req,res,next); +} + +api.questCancel = function(req, res, next){ + // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) + // Quest scroll has not yet left quest owner's inventory so no need to return it. + // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. + var group = res.locals.group; + async.parallel([ + function(cb){ + if (! group.quest.active) { + // Do not cancel active quests because this function does + // not do the clean-up required for that. + // TODO: return an informative error when quest is active + group.quest = {key:null,progress:{},leader:null}; + group.markModified('quest'); + group.save(cb); + } + } + ], function(err){ + if (err) return next(err); + res.json(group); + group = null; + }) +} + +api.questAbort = function(req, res, next){ + // Abort a quest AFTER it has begun (see questCancel for BEFORE) + var group = res.locals.group; + async.parallel([ + function(cb){ + User.update( + {_id:{$in: _.keys(group.quest.members)}}, + { + $set: {'party.quest':Group.cleanQuestProgress()}, + $inc: {_v:1} + }, + {multi:true}, + cb); + }, + // Refund party leader quest scroll + function(cb){ + if (group.quest.active) { + var update = {$inc:{}}; + update['$inc']['items.quests.' + group.quest.key] = 1; + User.update({_id:group.quest.leader}, update).exec(); + } + group.quest = {key:null,progress:{},leader:null}; + group.markModified('quest'); + group.save(cb); + }, function(cb){ + populateQuery(group.type, Group.findById(group._id)).exec(cb); + } + ], function(err, results){ + if (err) return next(err); + + var groupClone = clone(group); + + groupClone.members = results[2].members; + + res.json(groupClone); + group = null; + }) +} diff --git a/website/src/controllers/hall.js b/website/src/controllers/hall.js new file mode 100644 index 0000000000..8754bc2e94 --- /dev/null +++ b/website/src/controllers/hall.js @@ -0,0 +1,85 @@ +var _ = require('lodash'); +var nconf = require('nconf'); +var async = require('async'); +var shared = require('../../../common'); +var User = require('./../models/user').model; +var Group = require('./../models/group').model; +var api = module.exports; + +api.ensureAdmin = function(req, res, next) { + var user = res.locals.user; + if (!(user.contributor && user.contributor.admin)) return res.json(401, {err:"You don't have admin access"}); + next(); +} + +api.getHeroes = function(req,res,next) { + User.find({'contributor.level':{$gt:0}}) + .select('contributor backer balance profile.name') + .sort('-contributor.level') + .exec(function(err, users){ + if (err) return next(err); + res.json(users); + }); +} + +api.getPatrons = function(req,res,next){ + var page = req.query.page || 0, + perPage = 50; + User.find({'backer.tier':{$gt:0}}) + .select('contributor backer profile.name') + .sort('-backer.tier') + .skip(page*perPage) + .limit(perPage) + .exec(function(err, users){ + if (err) return next(err); + res.json(users); + }); +} + +api.getHero = function(req,res,next) { + User.findById(req.params.uid) + .select('contributor balance profile.name purchased items') + .select('auth.local.username auth.local.email auth.facebook auth.blocked') + .exec(function(err, user){ + if (err) return next(err) + if (!user) return res.json(400,{err:'User not found'}); + res.json(user); + }); +} + +api.updateHero = function(req,res,next) { + async.waterfall([ + function(cb){ + User.findById(req.params.uid, cb); + }, + function(member, cb){ + if (!member) return res.json(404, {err: "User not found"}); + member.balance = req.body.balance || 0; + var newTier = req.body.contributor.level; // tier = level in this context + var oldTier = member.contributor && member.contributor.level || 0; + if (newTier > oldTier) { + member.flags.contributor = true; + var gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff + var tierDiff = newTier - oldTier; // can be 2+ tier increases at once + while (tierDiff) { + member.balance += gemsPerTier[newTier] / 4; // balance is in $ + tierDiff--; + newTier--; // give them gems for the next tier down if they weren't aready that tier + } + } + member.contributor = req.body.contributor; + member.purchased.ads = req.body.purchased.ads; + if (member.contributor.level >= 6) member.items.pets['Dragon-Hydra'] = 5; + if (req.body.itemPath && req.body.itemVal + && req.body.itemPath.indexOf('items.') === 0 + && User.schema.paths[req.body.itemPath]) { + shared.dotSet(member, req.body.itemPath, req.body.itemVal); // Sanitization at 5c30944 (deemed unnecessary) + } + if (_.isBoolean(req.body.auth.blocked)) member.auth.blocked = req.body.auth.blocked; + member.save(cb); + } + ], function(err, saved){ + if (err) return next(err); + res.json(204); + }) +} diff --git a/website/src/controllers/members.js b/website/src/controllers/members.js new file mode 100644 index 0000000000..9102df985a --- /dev/null +++ b/website/src/controllers/members.js @@ -0,0 +1,119 @@ +var User = require('mongoose').model('User'); +var groups = require('../models/group'); +var partyFields = require('./groups').partyFields +var api = module.exports; +var async = require('async'); +var _ = require('lodash'); +var shared = require('../../../common'); +var utils = require('../utils'); +var nconf = require('nconf'); + +var fetchMember = function(uuid, restrict){ + return function(cb){ + var q = User.findById(uuid); + if (restrict) q.select(partyFields); + q.exec(function(err, member){ + if (err) return cb(err); + if (!member) return cb({code:404, err: 'User not found'}); + return cb(null, member); + }) + } +} + +var sendErr = function(err, res, next){ + err.code ? res.json(err.code, {err: err.err}) : next(err); +} + +api.getMember = function(req, res, next) { + fetchMember(req.params.uuid, true)(function(err, member){ + if (err) return sendErr(err, res, next); + res.json(member); + }) +} + +api.sendMessage = function(user, member, data){ + var msg; + if (!data.type) { + msg = data.message + } else { + msg = "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you "; + msg += (data.type=='gems') ? data.gems.amount + " gems!`" : shared.content.subscriptionBlocks[data.subscription.key].months + " months of subscription!`"; + msg += data.message; + } + shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); + member.inbox.newMessages++; + member._v++; + member.markModified('inbox.messages'); + + shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member))); + user.markModified('inbox.messages'); +} + +api.sendPrivateMessage = function(req, res, next){ + var fetchedMember; + async.waterfall([ + fetchMember(req.params.uuid), + function(member, cb) { + fetchedMember = member; + if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me + || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them + || member.inbox.optOut) { // or if they've opted out of messaging + return cb({code: 401, err: "Can't send message to this user."}); + } + api.sendMessage(res.locals.user, member, {message:req.body.message}); + async.parallel([ + function (cb2) { member.save(cb2) }, + function (cb2) { res.locals.user.save(cb2) } + ], cb); + } + ], function(err){ + if (err) return sendErr(err, res, next); + + if(fetchedMember.preferences.emailNotifications.newPM !== false){ + utils.txnEmail(fetchedMember, 'new-pm', [ + {name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name}, + {name: 'PMS_INBOX_URL', content: nconf.get('BASE_URL') + '/#/options/groups/inbox'} + ]); + } + + res.send(200); + }) +} + +api.sendGift = function(req, res, next){ + async.waterfall([ + fetchMember(req.params.uuid), + function(member, cb) { + // Gems + switch (req.body.type) { + case "gems": + var amt = req.body.gems.amount / 4, + user = res.locals.user; + if (member.id == user.id) + return cb({code: 401, err: "Cannot send gems to yourself. Try a subscription instead."}); + if (!amt || amt <=0 || user.balance < amt) + return cb({code: 401, err: "Amount must be within 0 and your current number of gems."}); + member.balance += amt; + user.balance -= amt; + api.sendMessage(user, member, req.body); + if(member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(member, 'gifted-gems', [ + {name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name}, + {name: 'X_GEMS_GIFTED', content: req.body.gems.amount} + ]); + } + return async.parallel([ + function (cb2) { member.save(cb2) }, + function (cb2) { user.save(cb2) } + ], cb); + case "subscription": + return cb(); + default: + return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"}); + } + } + ], function(err) { + if (err) return sendErr(err, res, next); + res.send(200); + }); +} diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/payments/iap.js new file mode 100644 index 0000000000..29b03fedfb --- /dev/null +++ b/website/src/controllers/payments/iap.js @@ -0,0 +1,129 @@ +var iap = require('in-app-purchase'); +var async = require('async'); +var payments = require('./index'); +var nconf = require('nconf'); + +var inAppPurchase = require('in-app-purchase'); +inAppPurchase.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get("IAP_GOOGLE_KEYDIR") +}); + +// Validation ERROR Codes +var INVALID_PAYLOAD = 6778001; +var CONNECTION_FAILED = 6778002; +var PURCHASE_EXPIRED = 6778003; + +exports.androidVerify = function(req, res, next) { + var iapBody = req.body; + var user = res.locals.user; + + iap.setup(function (error) { + if (error) { + var resObj = { + ok: false, + data: 'IAP Error' + }; + + console.error('IAP Setup ERROR'); + console.error(error); + + res.json(resObj); + + return; + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + var testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + if (err) { + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString() + } + }; + + res.json(resObj); + console.error(err); + return; + } + + if (iap.isValidated(googleRes)) { + var resObj = { + ok: true, + data: googleRes + }; + + payments.buyGems({user:user, paymentMethod:'IAP GooglePlay'}); + + // yay good! + res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function(req, res, next) { + console.info(req.body); + + var iapBody = req.body; + var user = res.locals.user; + + iap.setup(function (error) { + if (error) { + var resObj = { + ok: false, + data: 'IAP Error' + }; + + console.error('IAP Setup ERROR'); + console.error(error); + + res.json(resObj); + + return; + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + if (err) { + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString() + } + }; + + res.json(resObj); + console.error(err); + return; + } + + if (iap.isValidated(appleRes)) { + var resObj = { + ok: true, + data: appleRes + }; + + payments.buyGems({user:user, paymentMethod:'IAP AppleStore'}); + + // yay good! + res.json(resObj); + } + }); + }); +}; \ No newline at end of file diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js new file mode 100644 index 0000000000..639fe18ebf --- /dev/null +++ b/website/src/controllers/payments/index.js @@ -0,0 +1,156 @@ +/* @see ./routes.coffee for routing*/ +var _ = require('lodash'); +var shared = require('../../../../common'); +var nconf = require('nconf'); +var utils = require('./../../utils'); +var moment = require('moment'); +var isProduction = nconf.get("NODE_ENV") === "production"; +var stripe = require('./stripe'); +var paypal = require('./paypal'); +var members = require('../members') +var async = require('async'); +var iap = require('./iap'); +var mongoose= require('mongoose'); +var cc = require('coupon-code'); + +function revealMysteryItems(user) { + _.each(shared.content.gear.flat, function(item) { + if ( + item.klass === 'mystery' && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && + !user.items.gear.owned[item.key] && + !~user.purchased.plan.mysteryItems.indexOf(item.key) + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +exports.createSubscription = function(data, cb) { + var recipient = data.gift ? data.gift.member : data.user; + //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case + var p = recipient.purchased.plan; + var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + var months = +block.months; + + if (data.gift) { + if (p.customerId && !p.dateTerminated) { // User has active plan + p.extraMonths += months; + } else { + p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); + if (!p.dateUpdated) p.dateUpdated = new Date(); + } + if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(p).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: +p.extraMonths + + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + dateTerminated: null + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [] + }); + } + + // Block sub perks + var perks = Math.floor(months/3); + if (perks) { + p.consecutive.offset += months; + p.consecutive.gemCapExtra += perks*5; + if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; + p.consecutive.trinkets += perks; + } + revealMysteryItems(recipient); + if(isProduction) { + if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); + utils.ga.event('subscribe', data.paymentMethod).send(); + utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); + } + data.user.purchased.txnCount++; + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ + utils.txnEmail(data.gift.member, 'gifted-subscription', [ + {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months} + ]); + } + } + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); +} + +/** + * Sets their subscription to be cancelled later + */ +exports.cancelSubscription = function(data, cb) { + var p = data.user.purchased.plan, + now = moment(), + remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; + + p.dateTerminated = + moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + utils.txnEmail(data.user, 'cancel-subscription'); + utils.ga.event('unsubscribe', data.paymentMethod).send(); +} + +exports.buyGems = function(data, cb) { + var amt = data.gift ? data.gift.gems.amount/4 : 5; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if(isProduction) { + if (!data.gift) utils.txnEmail(data.user, 'donation'); + utils.ga.event('checkout', data.paymentMethod).send(); + //TODO ga.transaction to reflect whether this is gift or self-purchase + utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); + } + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(data.gift.member, 'gifted-gems', [ + {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'X_GEMS_GIFTED', content: data.gift.gems.amount || 20} + ]); + } + } + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); +} + +exports.validCoupon = function(req, res, next){ + mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ + if (err) return next(err); + if (!coupon) return res.json(401, {err:"Invalid coupon code"}); + return res.send(200); + }); +} + +exports.stripeCheckout = stripe.checkout; +exports.stripeSubscribeCancel = stripe.subscribeCancel; +exports.stripeSubscribeEdit = stripe.subscribeEdit; + +exports.paypalSubscribe = paypal.createBillingAgreement; +exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; +exports.paypalSubscribeCancel = paypal.cancelSubscription; +exports.paypalCheckout = paypal.createPayment; +exports.paypalCheckoutSuccess = paypal.executePayment; +exports.paypalIPN = paypal.ipn; + +exports.iapAndroidVerify = iap.androidVerify; +exports.iapIosVerify = iap.iosVerify; \ No newline at end of file diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/payments/paypal.js new file mode 100644 index 0000000000..094171e3af --- /dev/null +++ b/website/src/controllers/payments/paypal.js @@ -0,0 +1,216 @@ +var nconf = require('nconf'); +var moment = require('moment'); +var async = require('async'); +var _ = require('lodash'); +var url = require('url'); +var User = require('mongoose').model('User'); +var payments = require('./index'); +var logger = require('../../logging'); +var ipn = require('paypal-ipn'); +var paypal = require('paypal-rest-sdk'); +var shared = require('../../../../common'); +var mongoose = require('mongoose'); +var cc = require('coupon-code'); + +// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have +// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created +// there, get it's plan.id and store it in config.json +_.each(shared.content.subscriptionBlocks, function(block){ + block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key); +}); + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +var parseErr = function(res, err){ + //var error = err.response ? err.response.message || err.response.details[0].issue : err; + var error = JSON.stringify(err); + return res.json(400,{err:error}); +} + +exports.createBillingAgreement = function(req,res,next){ + var sub = shared.content.subscriptionBlocks[req.query.sub]; + async.waterfall([ + function(cb){ + if (!sub.discount) return cb(null, null); + if (!req.query.coupon) return cb('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb); + }, + function(coupon, cb){ + if (sub.discount && !coupon) return cb('Invalid coupon code.'); + var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)'; + var billingAgreementAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "start_date": moment().add({minutes:5}).format(), + "plan": { + "id": sub.paypalKey + }, + "payer": { + "payment_method": "paypal" + } + }; + paypal.billingAgreement.create(billingAgreementAttributes, cb); + } + ], function(err, billingAgreement){ + if (err) return parseErr(res, err); + // For approving subscription via Paypal, first redirect user to: approval_url + req.session.paypalBlock = req.query.sub; + var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; + res.redirect(approval_url); + }); +} + +exports.executeBillingAgreement = function(req,res,next){ + var block = shared.content.subscriptionBlocks[req.session.paypalBlock]; + delete req.session.paypalBlock; + async.auto({ + exec: function (cb) { + paypal.billingAgreement.execute(req.query.token, {}, cb); + }, + get_user: function (cb) { + User.findById(req.session.userId, cb); + }, + create_sub: ['exec', 'get_user', function (cb, results) { + payments.createSubscription({ + user: results.get_user, + customerId: results.exec.id, + paymentMethod: 'Paypal', + sub: block + }, cb); + }] + },function(err){ + if (err) return parseErr(res, err); + res.redirect('/'); + }) +} + +exports.createPayment = function(req, res) { + // if we're gifting to a user, put it in session for the `execute()` + req.session.gift = req.query.gift || undefined; + var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var price = !gift ? 5.00 + : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) + : Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); + var description = !gift ? "HabitRPG Gems" + : gift.type=='gems' ? "HabitRPG Gems (Gift)" + : shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)"; + var create_payment = { + "intent": "sale", + "payer": { + "payment_method": "paypal" + }, + "redirect_urls": { + "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', + "cancel_url": nconf.get('BASE_URL') + }, + "transactions": [{ + "item_list": { + "items": [{ + "name": description, + //"sku": "1", + "price": price, + "currency": "USD", + "quantity": 1 + }] + }, + "amount": { + "currency": "USD", + "total": price + }, + "description": description + }] + }; + paypal.payment.create(create_payment, function (err, payment) { + if (err) return parseErr(res, err); + var link = _.find(payment.links, {rel: 'approval_url'}).href; + res.redirect(link); + }); +} + +exports.executePayment = function(req, res) { + var paymentId = req.query.paymentId, + PayerID = req.query.PayerID, + gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; + delete req.session.gift; + async.waterfall([ + function(cb){ + paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); + }, + function(payment, cb){ + async.parallel([ + function(cb2){ User.findById(req.session.userId, cb2); }, + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } + ], cb); + }, + function(results, cb){ + if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); + var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} + var method = 'buyGems'; + if (gift) { + gift.member = results[1]; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb); + } + ],function(err){ + if (err) return parseErr(res, err); + res.redirect('/'); + }) +} + +exports.cancelSubscription = function(req, res, next){ + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.json(401, {err: "User does not have a plan subscription"}); + async.auto({ + get_cus: function(cb){ + paypal.billingAgreement.get(user.purchased.plan.customerId, cb); + }, + verify_cus: ['get_cus', function(cb, results){ + var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; + if (hasntBilledYet) + return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); + cb(); + }], + del_cus: ['verify_cus', function(cb, results){ + paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + }], + cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ + var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; + payments.cancelSubscription(data, cb) + }] + }, function(err){ + if (err) return parseErr(res, err); + res.redirect('/'); + user = null; + }); +} + +/** + * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their + * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution + */ +exports.ipn = function(req, res, next) { + console.log('IPN Called'); + res.send(200); // Must respond to PayPal IPN request with an empty 200 first + ipn.verify(req.body, function(err, msg) { + if (err) return logger.error(msg); + switch (req.body.txn_type) { + // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... + case 'recurring_payment_profile_cancel': + case 'subscr_cancel': + User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ + if (err) return logger.error(err); + if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) + payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); + }); + break; + } + }); +}; + diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/payments/paypalBillingSetup.js new file mode 100644 index 0000000000..ce238338f1 --- /dev/null +++ b/website/src/controllers/payments/paypalBillingSetup.js @@ -0,0 +1,93 @@ +// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring +// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this +// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), +// and once for any time you need to edit the plan thereafter +require('coffee-script'); +var path = require('path'); +var nconf = require('nconf'); +_ = require('lodash'); +nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); +var paypal = require('paypal-rest-sdk'); +var blocks = require('../../../../common').content.subscriptionBlocks; +var live = nconf.get('PAYPAL:mode')=='live'; + +var OP = 'create'; // list create update remove + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +// https://developer.paypal.com/docs/api/#billing-plans-and-agreements +var billingPlanTitle ="HabitRPG Subscription"; +var billingPlanAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "type": "INFINITE", + "merchant_preferences": { + "auto_bill_amount": "yes", + "cancel_url": live ? 'https://habitrpg.com' : 'http://localhost:3000', + "return_url": (live ? 'https://habitrpg.com' : 'http://localhost:3000') + '/paypal/subscribe/success' + }, + payment_definitions: [{ + "type": "REGULAR", + "frequency": "MONTH", + "cycles": "0" + }] +}; +_.each(blocks, function(block){ + block.definition = _.cloneDeep(billingPlanAttributes); + _.merge(block.definition.payment_definitions[0], { + "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', + "frequency_interval": ""+block.months, + "amount": { + "currency": "USD", + "value": ""+block.price + } + }); +}) + +switch(OP) { + case "list": + paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ + console.log({err:err, plans:plans}); + }); + break; + case "get": + paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) { + console.log({err:err, plan:plan}); + }) + break; + case "update": + var update = { + "op": "replace", + "path": "/merchant_preferences", + "value": { + "cancel_url": "https://habitrpg.com" + } + }; + paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) { + console.log({err:err, plan:res}); + }); + break; + case "create": + paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ + if (err) return console.log(err); + if (plan.state == "ACTIVE") + return console.log({err:err, plan:plan}); + var billingPlanUpdateAttributes = [{ + "op": "replace", + "path": "/", + "value": { + "state": "ACTIVE" + } + }]; + // Activate the plan by changing status to Active + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ + console.log({err:err, response:response, id:plan.id}); + }); + }); + break; + case "remove": break; +} \ No newline at end of file diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/payments/stripe.js new file mode 100644 index 0000000000..f27c5d98bb --- /dev/null +++ b/website/src/controllers/payments/stripe.js @@ -0,0 +1,123 @@ +var nconf = require('nconf'); +var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); +var async = require('async'); +var payments = require('./index'); +var User = require('mongoose').model('User'); +var shared = require('../../../../common'); +var mongoose = require('mongoose'); +var cc = require('coupon-code'); + +/* + Setup Stripe response when posting payment + */ +exports.checkout = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + + async.waterfall([ + function(cb){ + if (sub) { + async.waterfall([ + function(cb2){ + if (!sub.discount) return cb2(null, null); + if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); + }, + function(coupon, cb2){ + if (sub.discount && !coupon) return cb2('Invalid coupon code.'); + var customer = { + email: req.body.email, + metadata: {uuid: user._id}, + card: token, + plan: sub.key + }; + stripe.customers.create(customer, cb2); + } + ], cb); + } else { + stripe.charges.create({ + amount: !gift ? "500" //"500" = $5 + : gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.key].price*100 + : ""+gift.gems.amount/4*100, + currency: "usd", + card: token + }, cb); + } + }, + function(response, cb) { + if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + async.waterfall([ + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, + function(member, cb2){ + var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; + var method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb2); + } + ], cb); + } + ], function(err){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.send(200); + user = token = null; + }); +}; + +exports.subscribeCancel = function(req, res, next) { + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.json(401, {err: "User does not have a plan subscription"}); + + async.auto({ + get_cus: function(cb){ + stripe.customers.retrieve(user.purchased.plan.customerId, cb); + }, + del_cus: ['get_cus', function(cb, results){ + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancel_sub: ['get_cus', function(cb, results) { + var data = { + user: user, + nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds + paymentMethod: 'Stripe' + }; + payments.cancelSubscription(data, cb); + }] + }, function(err, results){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +exports.subscribeEdit = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + var user_id = user.purchased.plan.customerId; + var sub_id; + + async.waterfall([ + function(cb){ + stripe.customers.listSubscriptions(user_id, cb); + }, + function(response, cb) { + sub_id = response.data[0].id; + console.warn(sub_id); + console.warn([user_id, sub_id, { card: token }]); + stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); + }, + function(response, cb) { + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.send(200); + token = user = user_id = sub_id; + }); +}; \ No newline at end of file diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js new file mode 100644 index 0000000000..5660e67d08 --- /dev/null +++ b/website/src/controllers/user.js @@ -0,0 +1,540 @@ +/* @see ./routes.coffee for routing*/ + +var url = require('url'); +var ipn = require('paypal-ipn'); +var _ = require('lodash'); +var nconf = require('nconf'); +var async = require('async'); +var shared = require('../../../common'); +var User = require('./../models/user').model; +var utils = require('./../utils'); +var ga = utils.ga; +var Group = require('./../models/group').model; +var Challenge = require('./../models/challenge').model; +var moment = require('moment'); +var logging = require('./../logging'); +var acceptablePUTPaths; +var api = module.exports; +var qs = require('qs'); +var request = require('request'); +var validator = require('validator'); + +// api.purchase // Shared.ops + +api.getContent = function(req, res, next) { + var language = 'en'; + + if(typeof req.query.language != 'undefined') + language = req.query.language.toString(); //|| 'en' in i18n + + var content = _.cloneDeep(shared.content); + var walk = function(obj, lang){ + _.each(obj, function(item, key, source){ + if(_.isPlainObject(item) || _.isArray(item)) return walk(item, lang); + if(_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); + }); + } + walk(content, language); + res.json(content); +} + +api.getModelPaths = function(req,res,next){ + res.json(_.reduce(User.schema.paths,function(m,v,k){ + m[k] = v.instance || 'Boolean'; + return m; + },{})); +} + +/* + ------------------------------------------------------------------------ + Tasks + ------------------------------------------------------------------------ +*/ + + +/* + Local Methods + --------------- +*/ + +var findTask = function(req, res) { + return res.locals.user.tasks[req.params.id]; +}; + +/* + API Routes + --------------- +*/ + +/** + This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login + Export it also so we can call it from deprecated.coffee +*/ +api.score = function(req, res, next) { + var id = req.params.id, + direction = req.params.direction, + user = res.locals.user, + task; + + var clearMemory = function(){user = task = id = direction = null;} + + // Send error responses for improper API call + if (!id) return res.json(400, {err: ':id required'}); + if (direction !== 'up' && direction !== 'down') { + if (direction == 'unlink' || direction == 'sort') return next(); + return res.json(400, {err: ":direction must be 'up' or 'down'"}); + } + // If exists already, score it + if (task = user.tasks[id]) { + // Set completed if type is daily or todo and task exists + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; + } + } else { + // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it + // Defaults. Other defaults are handled in user.ops.addTask() + task = { + id: id, + type: req.body && req.body.type, + text: req.body && req.body.text, + notes: (req.body && req.body.notes) || "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task." + }; + task = user.ops.addTask({body:task}); + if (task.type === 'daily' || task.type === 'todo') + task.completed = direction === 'up'; + } + var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); + + user.save(function(err,saved){ + if (err) return next(err); + // TODO this should be return {_v,task,stats,_tmp}, instead of merging everything togther at top-level response + // However, this is the most commonly used API route, and changing it will mess with all 3rd party consumers. Bad idea :( + res.json(200, _.extend({ + delta: delta, + _tmp: user._tmp + }, saved.toJSON().stats)); + + // Webhooks + _.each(user.preferences.webhooks, function(h){ + if (!h.enabled || !validator.isURL(h.url)) return; + request.post({ + url: h.url, + //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" + body: {direction:direction, task: task, delta: delta, user: _.pick(user, ['_id', 'stats', '_tmp'])}, json:true + }); + }); + + if ( + (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there + || (task.type == 'reward') // we don't want to update the reward GP cost + ) return clearMemory(); + Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal){ + if (err) return next(err); + if (!chal) { + task.challenge.broken = 'CHALLENGE_DELETED'; + user.save(); + return clearMemory(); + } + var t = chal.tasks[task.id]; + // this task was removed from the challenge, notify user + if (!t) { + chal.syncToUser(user); + return clearMemory(); + } + t.value += delta; + if (t.type == 'habit' || t.type == 'daily') + t.history.push({value: t.value, date: +new Date}); + chal.save(); + clearMemory(); + }); + }); +}; + +/** + * Get all tasks + */ +api.getTasks = function(req, res, next) { + var user = res.locals.user; + if (req.query.type) { + return res.json(user[req.query.type+'s']); + } else { + return res.json(_.toArray(user.tasks)); + } +}; + +/** + * Get Task + */ +api.getTask = function(req, res, next) { + var task = findTask(req,res); + if (!task) return res.json(404, {err: "No task found."}); + return res.json(200, task); +}; + + +/* + Update Task +*/ + +//api.deleteTask // see Shared.ops +// api.updateTask // handled in Shared.ops +// api.addTask // handled in Shared.ops +// api.sortTask // handled in Shared.ops #TODO updated api, mention in docs + +/* + ------------------------------------------------------------------------ + Items + ------------------------------------------------------------------------ +*/ +// api.buy // handled in Shard.ops + +api.getBuyList = function (req, res, next) { + var list = shared.updateStore(res.locals.user); + return res.json(200, list); +}; + +/* + ------------------------------------------------------------------------ + User + ------------------------------------------------------------------------ +*/ + +/** + * Get User + */ +api.getUser = function(req, res, next) { + var user = res.locals.user.toJSON(); + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = 50; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + delete user.apiToken; + if (user.auth) { + delete user.auth.hashed_password; + delete user.auth.salt; + } + return res.json(200, user); +}; + + +/** + * This tells us for which paths users can call `PUT /user` (or batch-update equiv, which use `User.set()` on our client). + * The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) + * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations + */ +acceptablePUTPaths = _.reduce(require('./../models/user').schema.paths, function(m,v,leaf){ + var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){ + return leaf.indexOf(root) == 0; + }); + if (found) m[leaf]=true; + return m; +}, {}) + +//// Uncomment this if we we want to disable GP-restoring (eg, holiday events) +//_.each('stats.gp'.split(' '), function(removePath){ +// delete acceptablePUTPaths[removePath]; +//}) + +/** + * Update user + * Send up PUT /user as `req.body={path1:val, path2:val, etc}`. Example: + * PUT /user {'stats.hp':50, 'tasks.TASK_ID.repeat.m':false} + * See acceptablePUTPaths for which user paths are supported +*/ +api.update = function(req, res, next) { + var user = res.locals.user; + var errors = []; + if (_.isEmpty(req.body)) return res.json(200, user); + + _.each(req.body, function(v, k) { + if (acceptablePUTPaths[k]) + user.fns.dotSet(k, v); + else + errors.push("path `" + k + "` was not saved, as it's a protected path. See https://github.com/HabitRPG/habitrpg/blob/develop/API.md for PUT /api/v2/user."); + return true; + }); + user.save(function(err) { + if (!_.isEmpty(errors)) return res.json(401, {err: errors}); + if (err) return next(err); + res.json(200, user); + user = errors = null; + }); +}; + +api.cron = function(req, res, next) { + var user = res.locals.user, + progress = user.fns.cron(), + ranCron = user.isModified(), + quest = shared.content.quests[user.party.quest.key]; + + if (ranCron) res.locals.wasModified = true; + if (!ranCron) return next(null,user); + Group.tavernBoss(user,progress); + if (!quest) return user.save(next); + + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + }); +}; + +// api.reroll // Shared.ops +// api.reset // Shared.ops + +api['delete'] = function(req, res, next) { + var plan = res.locals.user.purchased.plan; + if (plan && plan.customerId && !plan.dateTerminated) + return res.json(400,{err:"You have an active subscription, cancel your plan before deleting your account."}); + res.locals.user.remove(function(err){ + if (err) return next(err); + res.send(200); + }) +} + +/* + ------------------------------------------------------------------------ + Gems + ------------------------------------------------------------------------ + */ + +// api.unlock // see Shared.ops + +api.addTenGems = function(req, res, next) { + var user = res.locals.user; + user.balance += 2.5; + user.save(function(err){ + if (err) return next(err); + res.send(204); + }) +} + +/* + ------------------------------------------------------------------------ + Tags + ------------------------------------------------------------------------ + */ +// api.deleteTag // handled in Shared.ops +// api.addTag // handled in Shared.ops +// api.updateTag // handled in Shared.ops +// api.sortTag // handled in Shared.ops + +/* + ------------------------------------------------------------------------ + Spells + ------------------------------------------------------------------------ + */ +api.cast = function(req, res, next) { + var user = res.locals.user, + targetType = req.query.targetType, + targetId = req.query.targetId, + klass = shared.content.spells.special[req.params.spell] ? 'special' : user.stats.class, + spell = shared.content.spells[klass][req.params.spell]; + + if (!spell) return res.json(404, {err: 'Spell "' + req.params.spell + '" not found.'}); + if (spell.mana > user.stats.mp) return res.json(400, {err: 'Not enough mana to cast spell'}); + + var done = function(){ + var err = arguments[0]; + var saved = _.size(arguments == 3) ? arguments[2] : arguments[1]; + if (err) return next(err); + res.json(saved); + user = targetType = targetId = klass = spell = null; + } + + switch (targetType) { + case 'task': + if (!user.tasks[targetId]) return res.json(404, {err: 'Task "' + targetId + '" not found.'}); + spell.cast(user, user.tasks[targetId]); + user.save(done); + break; + + case 'self': + spell.cast(user); + user.save(done); + break; + + case 'party': + case 'user': + async.waterfall([ + function(cb){ + Group.findOne({type: 'party', members: {'$in': [user._id]}}).populate('members', 'profile.name stats achievements items.special').exec(cb); + }, + function(group, cb) { + // Solo player? let's just create a faux group for simpler code + var g = group ? group : {members:[user]}; + var series = [], found; + if (targetType == 'party') { + spell.cast(user, g.members); + series = _.transform(g.members, function(m,v,k){ + m.push(function(cb2){v.save(cb2)}); + }); + } else { + found = _.find(g.members, {_id: targetId}) + spell.cast(user, found); + series.push(function(cb2){found.save(cb2)}); + } + + if (group && !spell.silent) { + series.push(function(cb2){ + var message = '`'+user.profile.name+' casts '+spell.text() + (targetType=='user' ? ' on '+found.profile.name : ' for the party')+'.`'; + group.sendChat(message); + group.save(cb2); + }) + } + + series.push(function(cb2){g = group = series = found = null;cb2();}) + + async.series(series, cb); + }, + function(whatever, cb){ + user.save(cb); + } + ], done); + break; + } +} + +// It supports guild too now but we'll stick to partyInvite for backward compatibility +api.sessionPartyInvite = function(req,res,next){ + if (!req.session.partyInvite) return next(); + var inv = res.locals.user.invitations; + if (inv.party && inv.party.id) return next(); // already invited to a party + async.waterfall([ + function(cb){ + Group.findOne({_id:req.session.partyInvite.id, members:{$in:[req.session.partyInvite.inviter]}}) + .select('invites members type').exec(cb); + }, + function(group, cb){ + if (!group){ + // Don't send error as it will prevent users from using the site + delete req.session.partyInvite; + return cb(); + } + + if(group.type == 'guild'){ + inv.guilds.push(req.session.partyInvite); + }else{ + //req.body.type in 'guild', 'party' + inv.party = req.session.partyInvite; + } + inv.party = req.session.partyInvite; + delete req.session.partyInvite; + if (!~group.invites.indexOf(res.locals.user._id)) + group.invites.push(res.locals.user._id); //$addToSt + group.save(cb); + }, + function(saved, cb){ + res.locals.user.save(cb); + } + ], next); +} + +/** + * All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping + */ +_.each(shared.wrap({}).ops, function(op,k){ + if (!api[k]) { + api[k] = function(req, res, next) { + res.locals.user.ops[k](req,function(err, response){ + // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} + if (err) { + if (!err.code) return next(err); + if (err.code >= 400) return res.json(err.code,{err:err.message}); + // In the case of 200s, they're friendly alert messages like "You're pet has hatched!" - still send the op + } + res.locals.user.save(function(err){ + if (err) return next(err); + res.json(200,response); + }) + }, ga); + } + } +}) + +/* + ------------------------------------------------------------------------ + Batch Update + Run a bunch of updates all at once + ------------------------------------------------------------------------ +*/ +api.batchUpdate = function(req, res, next) { + if (_.isEmpty(req.body)) req.body = []; // cases of {} or null + if (req.body[0] && req.body[0].data) + return res.json(501, {err: "API has been updated, please refresh your browser or upgrade your mobile app."}) + + var user = res.locals.user; + var oldSend = res.send; + var oldJson = res.json; + + // Stash user.save, we'll queue the save op till the end (so we don't overload the server) + var oldSave = user.save; + user.save = function(cb){cb(null,user)} + + // Setup the array of functions we're going to call in parallel with async + res.locals.ops = []; + var ops = _.transform(req.body, function(m,_req){ + if (_.isEmpty(_req)) return; + _req.language = req.language; + + m.push(function() { + var cb = arguments[arguments.length-1]; + res.locals.ops.push(_req); + res.send = res.json = function(code, data) { + if (_.isNumber(code) && code >= 500) + return cb(code+": "+ (data.message ? data.message : data.err ? data.err : JSON.stringify(data))); + return cb(); + }; + api[_req.op](_req, res, cb); + }); + }) + // Finally, save user at the end + .concat(function(){ + user.save = oldSave; + user.save(arguments[arguments.length-1]); + }); + + // call all the operations, then return the user object to the requester + async.waterfall(ops, function(err,_user) { + res.json = oldJson; + res.send = oldSend; + if (err) return next(err); + + var response = _user.toJSON(); + response.wasModified = res.locals.wasModified; + + user.fns.nullify(); + user = res.locals.user = oldSend = oldJson = oldSave = null; + + // return only drops & streaks + if (response._tmp && response._tmp.drop){ + res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v}); + + // Fetch full user object + }else if(response.wasModified){ + // Preen 3-day past-completed To-Dos from Angular & mobile app + response.todos = _.where(response.todos, function(t) { + return !t.completed || (t.challenge && t.challenge.id) || moment(t.dateCompleted).isAfter(moment().subtract({days:3})); + }); + res.json(200, response); + + // return only the version number + }else{ + res.json(200, {_v: response._v}); + } + }); +}; diff --git a/website/src/i18n.js b/website/src/i18n.js new file mode 100644 index 0000000000..271036754a --- /dev/null +++ b/website/src/i18n.js @@ -0,0 +1,141 @@ +var fs = require('fs'), + path = require('path'), + _ = require('lodash'), + User = require('./models/user').model, + shared = require('../../common'), + translations = {}; + +var localePath = path.join(__dirname, "/../../common/locales/") + +var loadTranslations = function(locale){ + var files = fs.readdirSync(path.join(localePath, locale)); + translations[locale] = {}; + _.each(files, function(file){ + if(path.extname(file) !== '.json') return; + _.merge(translations[locale], require(path.join(localePath, locale, file))); + }); +}; + +// First fetch english so we can merge with missing strings in other languages +loadTranslations('en'); + +fs.readdirSync(localePath).forEach(function(file) { + if(file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return; + loadTranslations(file); + // Merge missing strings from english + _.defaults(translations[file], translations.en); +}); + +var langCodes = Object.keys(translations); + +var avalaibleLanguages = _.map(langCodes, function(langCode){ + return { + code: langCode, + name: translations[langCode].languageName + } +}); + +// Load MomentJS localization files +var momentLangs = {}; + +// Handle different language codes from MomentJS and /locales +var momentLangsMapping = { + 'en': 'en-gb', + 'en_GB': 'en-gb', + 'no': 'nn', + 'zh': 'zh-cn', + 'es_419': 'es' +}; + +var momentLangs = {}; + +_.each(langCodes, function(code){ + var lang = _.find(avalaibleLanguages, {code: code}); + lang.momentLangCode = (momentLangsMapping[code] || code); + try{ + // MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files + var f = fs.readFileSync(path.join(__dirname, '/../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8'); + momentLangs[code] = f; + }catch (e){} +}); + +// Remove en_GB from langCodes checked by browser to avaoi it being +// used in place of plain original 'en' +var defaultLangCodes = _.without(langCodes, 'en_GB'); + +var getUserLanguage = function(req, res, next){ + var getFromBrowser = function(){ + var acceptable = _(req.acceptedLanguages).map(function(lang){ + return lang.slice(0, 2); + }).uniq().value(); + var matches = _.intersection(acceptable, defaultLangCodes); + if(matches.length > 0 && matches[0].toLowerCase() === 'es'){ + var acceptedCompleteLang = _.find(req.acceptedLanguages, function(accepted){ + return accepted.slice(0, 2) == 'es'; + }); + + if(acceptedCompleteLang){ + acceptedCompleteLang = acceptedCompleteLang.toLowerCase(); + }else{ + return 'en'; + } + + var latinAmericanSpanishes = ['es-419', 'es-mx', 'es-gt', 'es-cr', 'es-pa', 'es-do', 'es-ve', 'es-co', 'es-pe', + 'es-ar', 'es-ec', 'es-cl', 'es-uy', 'es-py', 'es-bo', 'es-sv', 'es-hn', + 'es-ni', 'es-pr']; + + return (latinAmericanSpanishes.indexOf(acceptedCompleteLang) !== -1) ? 'es_419' : 'es'; + }else if(matches.length > 0){ + return matches[0].toLowerCase(); + }else{ + return 'en'; + } + }; + + var getFromUser = function(user){ + var lang; + if(user && user.preferences.language && translations[user.preferences.language]){ + lang = user.preferences.language; + }else{ + var preferred = getFromBrowser(); + lang = translations[preferred] ? preferred : 'en'; + } + req.language = lang; + next(); + }; + + if(req.query.lang){ + req.language = translations[req.query.lang] ? (req.query.lang) : 'en'; + next(); + }else if(req.locals && req.locals.user){ + getFromUser(req.locals.user); + }else if(req.session && req.session.userId){ + User.findOne({_id: req.session.userId}, function(err, user){ + if(err) return next(err); + getFromUser(user); + }); + }else{ + getFromUser(null); + } +}; + +shared.i18n.translations = translations; + +module.exports = { + translations: translations, + avalaibleLanguages: avalaibleLanguages, + langCodes: langCodes, + getUserLanguage: getUserLanguage, + momentLangs: momentLangs +}; + + +// Export en strings only, temporary solution for mobile +// This is copied from middleware.js#module.exports.locals#t() +module.exports.enTranslations = function(){ // stringName and vars are the allowed parameters + var language = _.find(avalaibleLanguages, {code: 'en'}); + //language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); + var args = Array.prototype.slice.call(arguments, 0); + args.push(language.code); + return shared.i18n.t.apply(null, args); +}; diff --git a/website/src/logging.js b/website/src/logging.js new file mode 100644 index 0000000000..16b7b497f2 --- /dev/null +++ b/website/src/logging.js @@ -0,0 +1,73 @@ +var nconf = require('nconf'); +var winston = require('winston'); +require('winston-mail').Mail; +require('winston-newrelic'); + +var logger, loggly; + +// Currently disabled +if (nconf.get('LOGGLY:enabled')){ + loggly = require('loggly').createClient({ + token: nconf.get('LOGGLY:token'), + subdomain: nconf.get('LOGGLY:subdomain'), + auth: { + username: nconf.get('LOGGLY:username'), + password: nconf.get('LOGGLY:password') + }, + // + // Optional: Tag to send with EVERY log message + // + tags: [('heroku-'+nconf.get('BASE_URL'))], + json: true + }); +} + +if (logger == null) { + logger = new (winston.Logger)({}); + if (nconf.get('NODE_ENV') == 'production') { + //logger.add(winston.transports.newrelic, {}); + if (!nconf.get('DISABLE_ERROR_EMAILS')) { + logger.add(winston.transports.Mail, { + to: nconf.get('ADMIN_EMAIL') || nconf.get('SMTP_USER'), + from: "HabitRPG <" + nconf.get('SMTP_USER') + ">", + subject: "HabitRPG Error", + host: nconf.get('SMTP_HOST'), + port: nconf.get('SMTP_PORT'), + tls: nconf.get('SMTP_TLS'), + username: nconf.get('SMTP_USER'), + password: nconf.get('SMTP_PASS'), + level: 'error' + }); + } + } else { + logger.add(winston.transports.Console, {colorize:true}); + logger.add(winston.transports.File, {filename: 'habitrpg.log'}); + } +} + +// A custom log function that wraps Winston. Makes it easy to instrument code +// and still possible to replace Winston in the future. +module.exports.log = function(/* variable args */) { + if (logger) + logger.log.apply(logger, arguments); +}; + +module.exports.info = function(/* variable args */) { + if (logger) + logger.info.apply(logger, arguments); +}; + +module.exports.warn = function(/* variable args */) { + if (logger) + logger.warn.apply(logger, arguments); +}; + +module.exports.error = function(/* variable args */) { + if (logger) + logger.error.apply(logger, arguments); +}; + +module.exports.loggly = function(/* variable args */){ + if (loggly) + loggly.log.apply(loggly, arguments); +}; diff --git a/website/src/middleware.js b/website/src/middleware.js new file mode 100644 index 0000000000..34e1446be8 --- /dev/null +++ b/website/src/middleware.js @@ -0,0 +1,211 @@ +var nconf = require('nconf'); +var _ = require('lodash'); +var fs = require('fs'); +var path = require('path'); +var User = require('./models/user').model +var limiter = require('connect-ratelimit'); +var logging = require('./logging'); +var domainMiddleware = require('domain-middleware'); +var cluster = require('cluster'); +var i18n = require('./i18n.js'); +var shared = require('../../common'); +var request = require('request'); +var os = require('os'); +var moment = require('moment'); +var utils = require('./utils'); + +module.exports.apiThrottle = function(app) { + if (nconf.get('NODE_ENV') !== 'production') return; + app.use(limiter({ + end:false, + catagories:{ + normal: { + // 2 req/s, but split as minutes + totalRequests: 80, + every: 60000 + } + } + })).use(function(req,res,next){ + //logging.info(res.ratelimit); + if (res.ratelimit.exceeded) return res.json(429,{err:'Rate limit exceeded'}); + next(); + }); +} + +module.exports.domainMiddleware = function(server,mongoose) { + if (nconf.get('NODE_ENV')=='production') { + var mins = 3, // how often to run this check + useAvg = false, // use average over 3 minutes, or simply the last minute's report + url = 'https://api.newrelic.com/v2/applications/'+nconf.get('NEW_RELIC_APPLICATION_ID')+'/metrics/data.json?names[]=Apdex&values[]=score'; + setInterval(function(){ + // see https://docs.newrelic.com/docs/apm/apis/api-v2-examples/average-response-time-examples-api-v2, https://rpm.newrelic.com/api/explore/applications/data + request({ + url: useAvg ? url+'&from='+moment().subtract({minutes:mins}).utc().format()+'&to='+moment().utc().format()+'&summarize=true' : url, + headers: {'X-Api-Key': nconf.get('NEW_RELIC_API_KEY')} + }, function(err, response, body){ + var ts = JSON.parse(body).metric_data.metrics[0].timeslices, + score = ts[ts.length-1].values.score, + apdexBad = score < .75 || score == 1, + memory = os.freemem() / os.totalmem(), + memoryHigh = false; //memory < 0.1; + if (apdexBad || memoryHigh) throw "[Memory Leak] Apdex="+score+" Memory="+parseFloat(memory).toFixed(3)+" Time="+moment().format(); + }) + }, mins*60*1000); + } + + return domainMiddleware({ + server: { + close:function(){ + server.close(); + mongoose.connection.close(); + } + }, + killTimeout: 10000 + }); +} + +module.exports.errorHandler = function(err, req, res, next) { + //res.locals.domain.emit('error', err); + // when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER) + var stack = (err.stack ? err.stack : err.message ? err.message : err) + + "\n ----------------------------\n" + + "\n\noriginalUrl: " + req.originalUrl + + "\n\nauth: " + req.headers['x-api-user'] + ' | ' + req.headers['x-api-key'] + + "\n\nheaders: " + JSON.stringify(req.headers) + + "\n\nbody: " + JSON.stringify(req.body) + + (res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : ""); + logging.error(stack); + /*logging.loggly({ + error: "Uncaught error", + stack: (err.stack || err.message || err), + body: req.body, headers: req.header, + auth: req.headers['x-api-user'], + originalUrl: req.originalUrl + });*/ + var message = err.message ? err.message : err; + message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length); + res.json(500,{err:message}); //res.end(err.message); +} + + +module.exports.forceSSL = function(req, res, next){ + var baseUrl = nconf.get("BASE_URL"); + // Note x-forwarded-proto is used by Heroku & nginx, you'll have to do something different if you're not using those + if (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'] !== 'https' + && nconf.get('NODE_ENV') === 'production' + && baseUrl.indexOf('https') === 0) { + return res.redirect(baseUrl + req.url); + } + next() +} + +module.exports.cors = function(req, res, next) { + res.header("Access-Control-Allow-Origin", req.headers.origin || "*"); + res.header("Access-Control-Allow-Methods", "OPTIONS,GET,POST,PUT,HEAD,DELETE"); + res.header("Access-Control-Allow-Headers", "Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key"); + if (req.method === 'OPTIONS') return res.send(200); + return next(); +}; + +var siteVersion = 1; + +module.exports.forceRefresh = function(req, res, next){ + if(req.query.siteVersion && req.query.siteVersion != siteVersion){ + return res.json(400, {needRefresh: true}); + } + + return next(); +}; + +var buildFiles = []; + +var walk = function(folder){ + var res = fs.readdirSync(folder); + + res.forEach(function(fileName){ + file = folder + '/' + fileName; + if(fs.statSync(file).isDirectory()){ + walk(file); + }else{ + var relFolder = path.relative(path.join(__dirname, "/../build"), folder); + var old = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); + + if(relFolder){ + old = relFolder + '/' + old; + fileName = relFolder + '/' + fileName; + } + + buildFiles[old] = fileName + } + }); +} + +walk(path.join(__dirname, "/../build")); + +var getBuildUrl = function(url){ + if(buildFiles[url]) return '/' + buildFiles[url]; + + return '/' + url; +} + +var manifestFiles = require("../public/manifest.json"); + +var getManifestFiles = function(page){ + var files = manifestFiles[page]; + + if(!files) throw new Error("Page not found!"); + + var code = ''; + + if(nconf.get('NODE_ENV') === 'production'){ + code += ''; + code += ''; + }else{ + _.each(files.css, function(file){ + code += ''; + }); + _.each(files.js, function(file){ + code += ''; + }); + } + + return code; +} + +module.exports.locals = function(req, res, next) { + var language = _.find(i18n.avalaibleLanguages, {code: req.language}); + var isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/' + + // Load moment.js language file only when not on static pages + language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); + + var tavern = require('./models/group').tavern; + var envVars = _.pick(nconf.get(), 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY'.split(' ')); + res.locals.habitrpg = _.merge(envVars, { + IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')), + getManifestFiles: getManifestFiles, + getBuildUrl: getBuildUrl, + avalaibleLanguages: i18n.avalaibleLanguages, + language: language, + isStaticPage: isStaticPage, + translations: i18n.translations[language.code], + t: function(){ // stringName and vars are the allowed parameters + var args = Array.prototype.slice.call(arguments, 0); + args.push(language.code); + return shared.i18n.t.apply(null, args); + }, + siteVersion: siteVersion, + Content: shared.content, + mods: require('./models/user').mods, + tavern: tavern, // for world boss + worldDmg: (tavern && tavern.quest && tavern.quest.extra && tavern.quest.extra.worldDmg) || {} + }); + + // Put query-string party (& guild but use partyInvite for backward compatibility) + // invitations into session to be handled later + try{ + req.session.partyInvite = JSON.parse(utils.decrypt(req.query.partyInvite)) + } catch(e){} + + next(); +} diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js new file mode 100644 index 0000000000..d44b798bc0 --- /dev/null +++ b/website/src/models/challenge.js @@ -0,0 +1,120 @@ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); + +var ChallengeSchema = new Schema({ + _id: {type: String, 'default': shared.uuid}, + name: String, + shortName: String, + description: String, + official: {type: Boolean,'default':false}, + habits: [TaskSchemas.HabitSchema], + dailys: [TaskSchemas.DailySchema], + todos: [TaskSchemas.TodoSchema], + rewards: [TaskSchemas.RewardSchema], + leader: {type: String, ref: 'User'}, + group: {type: String, ref: 'Group'}, + timestamp: {type: Date, 'default': Date.now}, + members: [{type: String, ref: 'User'}], + memberCount: {type: Number, 'default': 0}, + prize: {type: Number, 'default': 0} +}); + +ChallengeSchema.virtual('tasks').get(function () { + var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); + var tasks = _.object(_.pluck(tasks,'id'), tasks); + return tasks; +}); + +ChallengeSchema.methods.toJSON = function(){ + var doc = this.toObject(); + doc._isMember = this._isMember; + return doc; +} + +// -------------- +// Syncing logic +// -------------- + +function syncableAttrs(task) { + var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument + // only sync/compare important attrs + var omitAttrs = 'challenge history tags completed streak notes'.split(' '); + if (t.type != 'reward') omitAttrs.push('value'); + return _.omit(t, omitAttrs); +} + +/** + * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers + */ +function comparableData(obj) { + return JSON.stringify( + _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) + .sortBy('id') // we don't want to update if they're sort-order is different + .transform(function(result, task){ + result.push(syncableAttrs(task)); + }) + .value()) +} + +ChallengeSchema.methods.isOutdated = function(newData) { + return comparableData(this) !== comparableData(newData); +} + +/** + * Syncs all new tasks, deleted tasks, etc to the user object + * @param user + * @return nothing, user is modified directly. REMEMBER to save the user! + */ +ChallengeSchema.methods.syncToUser = function(user, cb) { + if (!user) return; + var self = this; + self.shortName = self.shortName || self.name; + + // Add challenge to user.challenges + if (!_.contains(user.challenges, self._id)) { + user.challenges.push(self._id); + } + + // Sync tags + var tags = user.tags || []; + var i = _.findIndex(tags, {id: self._id}) + if (~i) { + if (tags[i].name !== self.shortName) { + // update the name - it's been changed since + user.tags[i].name = self.shortName; + } + } else { + user.tags.push({ + id: self._id, + name: self.shortName, + challenge: true + }); + } + + // Sync new tasks and updated tasks + _.each(self.tasks, function(task){ + var list = user[task.type+'s']; + var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); + if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided + userTask.challenge = {id:self._id}; + userTask.tags = userTask.tags || {}; + userTask.tags[self._id] = true; + _.merge(userTask, syncableAttrs(task)); + }) + + // Flag deleted tasks as "broken" + _.each(user.tasks, function(task){ + if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { + task.challenge.broken = 'TASK_DELETED'; + } + }) + + user.save(cb); +}; + + +module.exports.schema = ChallengeSchema; +module.exports.model = mongoose.model("Challenge", ChallengeSchema); diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js new file mode 100644 index 0000000000..3b0afb3f2a --- /dev/null +++ b/website/src/models/coupon.js @@ -0,0 +1,59 @@ +var mongoose = require("mongoose"); +var shared = require('../../../common'); +var _ = require('lodash'); +var async = require('async'); +var cc = require('coupon-code'); +var autoinc = require('mongoose-id-autoinc'); + +var CouponSchema = new mongoose.Schema({ + _id: {type: String, 'default': cc.generate}, + event: {type:String, enum:['wondercon','google_6mo']}, + user: {type: 'String', ref: 'User'} +}); + +CouponSchema.statics.generate = function(event, count, callback) { + async.times(count, function(n,cb){ + mongoose.model('Coupon').create({event: event}, cb); + }, callback); +} + +CouponSchema.statics.apply = function(user, code, next){ + async.auto({ + get_coupon: function (cb) { + mongoose.model('Coupon').findById(cc.validate(code), cb); + }, + apply_coupon: ['get_coupon', function (cb, results) { + if (!results.get_coupon) return cb("Invalid coupon code"); + if (results.get_coupon.user) return cb("Coupon already used"); + switch (results.get_coupon.event) { + case 'wondercon': + user.items.gear.owned.eyewear_special_wondercon_red = true; + user.items.gear.owned.eyewear_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_black = true; + user.items.gear.owned.body_special_wondercon_gold = true; + user.extra = {signupEvent: 'wondercon'}; + user.save(cb); + break; + } + }], + expire_coupon: ['apply_coupon', function (cb, results) { + results.get_coupon.user = user._id; + results.get_coupon.save(cb); + }] + }, function(err, results){ + if (err) return next(err); + next(null,results.apply_coupon[0]); + }) +} + +CouponSchema.plugin(autoinc.plugin, { + model: 'Coupon', + field: 'seq' +}); + +module.exports.schema = CouponSchema; +module.exports.model = mongoose.model("Coupon", CouponSchema); + diff --git a/website/src/models/group.js b/website/src/models/group.js new file mode 100644 index 0000000000..d776377488 --- /dev/null +++ b/website/src/models/group.js @@ -0,0 +1,370 @@ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var async = require('async'); +var logging = require('../logging'); + +var GroupSchema = new Schema({ + _id: {type: String, 'default': shared.uuid}, + name: String, + description: String, + leader: {type: String, ref: 'User'}, + members: [{type: String, ref: 'User'}], + invites: [{type: String, ref: 'User'}], + type: {type: String, "enum": ['guild', 'party']}, + privacy: {type: String, "enum": ['private', 'public'], 'default':'private'}, + //_v: {type: Number,'default': 0}, + chat: Array, + /* + # [{ + # timestamp: Date + # user: String + # text: String + # contributor: String + # uuid: String + # id: String + # }] + */ + leaderOnly: { // restrict group actions to leader (members can't do them) + challenges: {type:Boolean, 'default':false}, + //invites: {type:Boolean, 'default':false} + }, + memberCount: {type: Number, 'default': 0}, + challengeCount: {type: Number, 'default': 0}, + balance: Number, + logo: String, + leaderMessage: String, + challenges: [{type:'String', ref:'Challenge'}], // do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + quest: { + key: String, + active: {type:Boolean, 'default':false}, + leader: {type:String, ref:'User'}, + progress:{ + hp: Number, + collect: {type:Schema.Types.Mixed, 'default':{}}, // {feather: 5, ingot: 3} + rage: Number, // limit break / "energy stored in shell", for explosion-attacks + }, + + //Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click + //'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. + //TODO when booting user, remove from .joined and check again if we can now start the quest + members: Schema.Types.Mixed, + extra: Schema.Types.Mixed + } +}, { + strict: 'throw', + minimize: false // So empty objects are returned +}); + +/** + * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration + * to remove duplicates, then take these fucntions out + */ +function removeDuplicates(doc){ + // Remove duplicate members + if (doc.members) { + var uniqMembers = _.uniq(doc.members); + if (uniqMembers.length != doc.members.length) { + doc.members = uniqMembers; + } + } +} + +// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() +// @see https://github.com/LearnBoost/mongoose/issues/964 +GroupSchema.pre('save', function(next){ + removeDuplicates(this); + this.memberCount = _.size(this.members); + this.challengeCount = _.size(this.challenges); + next(); +}) + +GroupSchema.methods.toJSON = function(){ + var doc = this.toObject(); + removeDuplicates(doc); + doc._isMember = this._isMember; + + //fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). + // Required as angular 1.3 is strict on dupes, and no message.id to `track by` + _.remove(doc.chat,function(msg){return !msg.id}); + + // @see pre('save') comment above + this.memberCount = _.size(this.members); + this.challengeCount = _.size(this.challenges); + + return doc; +} + +var chatDefaults = module.exports.chatDefaults = function(msg,user){ + var message = { + id: shared.uuid(), + text: msg, + timestamp: +new Date, + likes: {}, + flags: {}, + flagCount: 0 + }; + if (user) { + _.defaults(message, { + uuid: user._id, + contributor: user.contributor && user.contributor.toObject(), + backer: user.backer && user.backer.toObject(), + user: user.profile.name + }); + } else { + message.uuid = 'system'; + } + return message; +} +GroupSchema.methods.sendChat = function(message, user){ + var group = this; + group.chat.unshift(chatDefaults(message,user)); + group.chat.splice(200); + // Kick off chat notifications in the background. + var lastSeenUpdate = {$set:{}, $inc:{_v:1}}; + lastSeenUpdate['$set']['newMessages.'+group._id] = {name:group.name,value:true}; + if (group._id == 'habitrpg') { + // TODO For Tavern, only notify them if their name was mentioned + // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? + // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); + } else { + mongoose.model('User').update({_id:{$in:group.members, $ne: user ? user._id : ''}},lastSeenUpdate,{multi:true}).exec(); + } +} + +var cleanQuestProgress = function(merge){ + var clean = { + key: null, + progress: { + up: 0, + down: 0, + collect: {} + }, + completed: null + }; + merge = merge || {progress:{}}; + _.merge(clean, _.omit(merge,'progress')); + _.merge(clean.progress, merge.progress); + return clean; +} +GroupSchema.statics.cleanQuestProgress = cleanQuestProgress; + +// Participants: Grant rewards & achievements, finish quest +GroupSchema.methods.finishQuest = function(quest, cb) { + var group = this; + var questK = quest.key; + var updates = {$inc:{},$set:{}}; + + updates['$inc']['achievements.quests.' + questK] = 1; + updates['$inc']['stats.gp'] = +quest.drop.gp; + updates['$inc']['stats.exp'] = +quest.drop.exp; + updates['$inc']['_v'] = 1; + if (group._id == 'habitrpg') { + updates['$set']['party.quest.completed'] = questK; // Just show the notif + } else { + updates['$set']['party.quest'] = cleanQuestProgress({completed: questK}); // clear quest progress + } + + _.each(quest.drop.items, function(item){ + var dropK = item.key; + switch (item.type) { + case 'gear': + // TODO This means they can lose their new gear on death, is that what we want? + updates['$set']['items.gear.owned.'+dropK] = true; + break; + case 'eggs': + case 'food': + case 'hatchingPotions': + case 'quests': + updates['$inc']['items.'+item.type+'.'+dropK] = _.where(quest.drop.items,{type:item.type,key:item.key}).length; + break; + case 'pets': + updates['$set']['items.pets.'+dropK] = 5; + break; + case 'mounts': + updates['$set']['items.mounts.'+dropK] = true; + break; + } + }) + var q = group._id === 'habitrpg' ? {} : {_id:{$in:_.keys(group.quest.members)}}; + group.quest = {};group.markModified('quest'); + mongoose.model('User').update(q, updates, {multi:true}, cb); +} + +// FIXME this is a temporary measure, we need to remove quests from users when they traverse parties +function isOnQuest(user,progress,group){ + return group && progress && user.party.quest.key && user.party.quest.key == group.quest.key; +} + +GroupSchema.statics.collectQuest = function(user, progress, cb) { + this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ + if (!isOnQuest(user,progress,group)) return cb(null); + var quest = shared.content.quests[group.quest.key]; + + _.each(progress.collect,function(v,k){ + group.quest.progress.collect[k] += v; + }); + + var foundText = _.reduce(progress.collect, function(m,v,k){ + m.push(v + ' ' + quest.collect[k].text('en')); + return m; + }, []); + foundText = foundText ? foundText.join(', ') : 'nothing'; + group.sendChat("`" + user.profile.name + " found "+foundText+".`"); + group.markModified('quest.progress.collect'); + + // Still needs completing + if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){ + return group.quest.progress.collect[k] < v.count; + })) return group.save(cb); + + async.series([ + function(cb2){ + group.finishQuest(quest,cb2); + }, + function(cb2){ + group.sendChat('`All items found! Party has received their rewards.`'); + group.save(cb2); + } + ],cb); + }) +} + +// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` +module.exports.tavern = {}; +var tavernQ = {_id:'habitrpg','quest.key':{$ne:null}}; +process.nextTick(function(){ + mongoose.model('Group').findOne(tavernQ,function(err,tavern){ + module.exports.tavern = tavern; + }); +}) +GroupSchema.statics.tavernBoss = function(user,progress) { + if (!progress) return; + + // hack: prevent crazy damage to world boss + var dmg = Math.min(900, Math.abs(progress.up||0)), + rage = -Math.min(900, Math.abs(progress.down||0)); + + async.waterfall([ + function(cb){ + mongoose.model('Group').findOne(tavernQ,cb); + }, + function(tavern,cb){ + if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true); + module.exports.tavern = tavern; + + var quest = shared.content.quests[tavern.quest.key]; + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + tavern.finishQuest(quest, function(){}); + tavern.save(cb); + module.exports.tavern = undefined; + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + var wd = tavern.quest.extra.worldDmg; + // var scene = wd.tavern ? wd.stables ? wd.market ? false : 'market' : 'stables' : 'tavern'; // Dilatory attacks tavern, stables, market + var scene = wd.stables ? wd.bailey ? wd.guide ? false : 'guide' : 'bailey' : 'stables'; // Stressbeast attacks stables, Bailey, Justin + if (!scene) { + tavern.sendChat('`'+quest.boss.name('en')+' tries to unleash '+quest.boss.rage.title('en')+', but is too tired.`'); + tavern.quest.progress.rage = 0 //quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + tavern.quest.progress.hp += (quest.boss.rage.healing * tavern.quest.progress.hp); + } + } + if ((tavern.quest.progress.hp < quest.boss.desperation.threshold) && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); + } + tavern.save(cb); + } + } + ],function(err,res){ + if (err === true) return; // no current quest + if (err) return logging.error(err); + dmg = rage = null; + }) +} + +GroupSchema.statics.bossQuest = function(user, progress, cb) { + this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ + if (!isOnQuest(user,progress,group)) return cb(null); + var quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return cb(null); // FIXME why is this ever happening, progress should be defined at this point + var down = progress.down * quest.boss.str; // multiply by boss strength + + group.quest.progress.hp -= progress.up; + group.sendChat("`" + user.profile.name + " attacks " + quest.boss.name('en') + " for " + (progress.up.toFixed(1)) + " damage, " + quest.boss.name('en') + " attacks party for " + Math.abs(down).toFixed(1) + " damage.`"); //TODO Create a party preferred language option so emits like this can be localized + + // If boss has Rage, increment Rage as well + if (quest.boss.rage) { + group.quest.progress.rage += Math.abs(down); + if (group.quest.progress.rage >= quest.boss.rage.value) { + group.sendChat(quest.boss.rage.effect('en')); + group.quest.progress.rage = 0; + if (quest.boss.rage.healing) group.quest.progress.hp += (group.quest.progress.hp * quest.boss.rage.healing); //TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; + } + } + // Everyone takes damage + var series = [ + function(cb2){ + mongoose.models.User.update({_id:{$in: _.keys(group.quest.members)}}, {$inc:{'stats.hp':down, _v:1}}, {multi:true}, cb2); + } + ] + + // Boss slain, finish quest + if (group.quest.progress.hp <= 0) { + group.sendChat('`You defeated ' + quest.boss.name('en') + '! Questing party members receive the rewards of victory.`'); + // Participants: Grant rewards & achievements, finish quest + series.push(function(cb2){ + group.finishQuest(quest,cb2); + }); + } + + series.push(function(cb2){group.save(cb2)}); + async.series(series,cb); + }) +} + +GroupSchema.methods.toJSON = function() { + var doc = this.toObject(); + if(doc.chat){ + doc.chat.forEach(function(msg){ + msg.flags = {}; + }); + } + + return doc; +}; + + +module.exports.schema = GroupSchema; +var Group = module.exports.model = mongoose.model("Group", GroupSchema); + +// initialize tavern if !exists (fresh installs) +Group.count({_id:'habitrpg'},function(err,ct){ + if (ct > 0) return; + new Group({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy:'public' + }).save(); +}) diff --git a/website/src/models/task.js b/website/src/models/task.js new file mode 100644 index 0000000000..2646263e80 --- /dev/null +++ b/website/src/models/task.js @@ -0,0 +1,104 @@ +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); + +// Task Schema +// ----------- + +var TaskSchema = { + //_id:{type: String,'default': helpers.uuid}, + id: {type: String,'default': shared.uuid}, + dateCreated: {type:Date, 'default':Date.now}, + text: String, + notes: {type: String, 'default': ''}, + tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, + value: {type: Number, 'default': 0}, // redness + priority: {type: Number, 'default': '1'}, + attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, + challenge: { + id: {type: 'String', ref:'Challenge'}, + broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED + winner: String // user.profile.name + // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` + } +}; + +var HabitSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'habit'}, + history: Array, // [{date:Date, value:Number}], // this causes major performance problems + up: {type: Boolean, 'default': true}, + down: {type: Boolean, 'default': true} + }, TaskSchema) + , { _id: false, minimize:false } +); + +var collapseChecklist = {type:Boolean, 'default':false}; +var checklist = [{ + completed:{type:Boolean,'default':false}, + text: String, + _id:false, + id: {type:String,'default':shared.uuid} +}]; + +var DailySchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'daily'}, + history: Array, + completed: {type: Boolean, 'default': false}, + repeat: { + m: {type: Boolean, 'default': true}, + t: {type: Boolean, 'default': true}, + w: {type: Boolean, 'default': true}, + th: {type: Boolean, 'default': true}, + f: {type: Boolean, 'default': true}, + s: {type: Boolean, 'default': true}, + su: {type: Boolean, 'default': true} + }, + collapseChecklist:collapseChecklist, + checklist:checklist, + streak: {type: Number, 'default': 0} + }, TaskSchema) + , { _id: false, minimize:false } +) + +var TodoSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'todo'}, + completed: {type: Boolean, 'default': false}, + dateCompleted: Date, + date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date + collapseChecklist:collapseChecklist, + checklist:checklist + }, TaskSchema) + , { _id: false, minimize:false } +); + +var RewardSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'reward'} + }, TaskSchema) + , { _id: false, minimize:false } +); + +/** + * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while + */ +//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ +// schema.post('init', function(doc){ +// if (!doc.id && doc._id) doc.id = doc._id; +// }) +//}) + +module.exports.TaskSchema = TaskSchema; +module.exports.HabitSchema = HabitSchema; +module.exports.DailySchema = DailySchema; +module.exports.TodoSchema = TodoSchema; +module.exports.RewardSchema = RewardSchema; diff --git a/website/src/models/user.js b/website/src/models/user.js new file mode 100644 index 0000000000..5329fe0c16 --- /dev/null +++ b/website/src/models/user.js @@ -0,0 +1,534 @@ +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); +var Challenge = require('./challenge').model; +var moment = require('moment'); + +// User Schema +// ----------- + +var UserSchema = new Schema({ + // ### UUID and API Token + _id: { + type: String, + 'default': shared.uuid + }, + apiToken: { + type: String, + 'default': shared.uuid + }, + + // ### Mongoose Update Object + // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which + // have been updated (http://goo.gl/gQLz41), but we want *every* update + _v: { type: Number, 'default': 0 }, + achievements: { + originalUser: Boolean, + helpedHabit: Boolean, + ultimateGear: Boolean, + beastMaster: Boolean, + beastMasterCount: Number, + mountMaster: Boolean, + mountMasterCount: Number, + triadBingo: Boolean, + triadBingoCount: Number, + veteran: Boolean, + snowball: Number, + spookDust: Number, + streak: Number, + challenges: Array, + quests: Schema.Types.Mixed, + rebirths: Number, + rebirthLevel: Number, + perfect: Number, + habitBirthday: Boolean, // TODO: Deprecate this. Superseded by habitBirthdays + habitBirthdays: Number, + valentine: Number, + costumeContest: Boolean, + nye: Number + }, + auth: { + blocked: Boolean, + facebook: Schema.Types.Mixed, + local: { + email: String, + hashed_password: String, + salt: String, + username: String + }, + timestamps: { + created: {type: Date,'default': Date.now}, + loggedin: {type: Date,'default': Date.now} + } + }, + + backer: { + tier: Number, + npc: String, + tokensApplied: Boolean + }, + + contributor: { + level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 + admin: Boolean, + sudo: Boolean, + text: String, // Artisan, Friend, Blacksmith, etc + contributions: String, // a markdown textarea to list their contributions + links + critical: String + }, + + balance: {type: Number, 'default':0}, + filters: {type: Schema.Types.Mixed, 'default': {}}, + + purchased: { + ads: {type: Boolean, 'default': false}, + skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} + hair: {type: Schema.Types.Mixed, 'default': {}}, + shirt: {type: Schema.Types.Mixed, 'default': {}}, + background: {type: Schema.Types.Mixed, 'default': {}}, + txnCount: {type: Number, 'default':0}, + mobileChat: Boolean, + plan: { + planId: String, + paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', '']} + customerId: String, + dateCreated: Date, + dateTerminated: Date, + dateUpdated: Date, + extraMonths: {type:Number, 'default':0}, + gemsBought: {type: Number, 'default': 0}, + mysteryItems: {type: Array, 'default': []}, + consecutive: { + count: {type:Number, 'default':0}, + offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type:Number, 'default':0}, + trinkets: {type:Number, 'default':0} + } + } + }, + + flags: { + customizationsNotification: {type: Boolean, 'default': false}, + showTour: {type: Boolean, 'default': true}, + dropsEnabled: {type: Boolean, 'default': false}, + itemsEnabled: {type: Boolean, 'default': false}, + newStuff: {type: Boolean, 'default': false}, + rewrite: {type: Boolean, 'default': true}, + partyEnabled: Boolean, // FIXME do we need this? + contributor: Boolean, + classSelected: {type: Boolean, 'default': false}, + mathUpdates: Boolean, + rebirthEnabled: {type: Boolean, 'default': false}, + freeRebirth: {type: Boolean, 'default': false}, + levelDrops: {type:Schema.Types.Mixed, 'default':{}}, + chatRevoked: Boolean, + // Used to track the status of recapture emails sent to each user, + // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user + recaptureEmailsPhase: {type: Number, 'default': 0}, + communityGuidelinesAccepted: {type: Boolean, 'default': false} + }, + history: { + exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined + todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined + }, + + // FIXME remove? + invitations: { + guilds: {type: Array, 'default': []}, + party: Schema.Types.Mixed + }, + items: { + gear: { + owned: _.transform(shared.content.gear.flat, function(m,v,k){ + m[v.key] = {type: Boolean}; + if (v.key.match(/[weapon|armor|head|shield]_warrior_0/)) + m[v.key]['default'] = true; + }), + + equipped: { + weapon: {type: String, 'default': 'weapon_warrior_0'}, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + }, + costume: { + weapon: {type: String, 'default': 'weapon_base_0'}, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + }, + }, + + special:{ + snowball: {type: Number, 'default': 0}, + spookDust: {type: Number, 'default': 0}, + valentine: Number, + valentineReceived: Array, // array of strings, by sender name + nye: Number, + nyeReceived: Array + }, + + // -------------- Animals ------------------- + // Complex bit here. The result looks like: + // pets: { + // 'Wolf-Desert': 0, // 0 means does not own + // 'PandaCub-Red': 10, // Number represents "Growth Points" + // etc... + // } + pets: + _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), + // Then add quest pets + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), + // Then add additional pets (backer, contributor) + _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }) + ), + currentPet: String, // Cactus-Desert + + // eggs: { + // 'PandaCub': 0, // 0 indicates "doesn't own" + // 'Wolf': 5 // Number indicates "stacking" + // } + eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), + + // hatchingPotions: { + // 'Desert': 0, // 0 indicates "doesn't own" + // 'CottonCandyBlue': 5 // Number indicates "stacking" + // } + hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), + + // Food: { + // 'Watermelon': 0, // 0 indicates "doesn't own" + // 'RottenMeat': 5 // Number indicates "stacking" + // } + food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), + + // mounts: { + // 'Wolf-Desert': true, + // 'PandaCub-Red': false, + // etc... + // } + mounts: _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), + // Then add quest pets + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), + // Then add additional pets (backer, contributor) + _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) + ), + currentMount: String, + + // Quests: { + // 'boss_0': 0, // 0 indicates "doesn't own" + // 'collection_honey': 5 // Number indicates "stacking" + // } + quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), + + lastDrop: { + date: {type: Date, 'default': Date.now}, + count: {type: Number, 'default': 0} + } + }, + + lastCron: {type: Date, 'default': Date.now}, + + // {GROUP_ID: Boolean}, represents whether they have unseen chat messages + newMessages: {type: Schema.Types.Mixed, 'default': {}}, + + party: { + // id // FIXME can we use a populated doc instead of fetching party separate from user? + order: {type:String, 'default':'level'}, + orderAscending: {type:String, 'default':'ascending'}, + quest: { + key: String, + progress: { + up: {type: Number, 'default': 0}, + down: {type: Number, 'default': 0}, + collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} + }, + completed: String // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser + } + }, + preferences: { + armorSet: String, + dayStart: {type:Number, 'default': 0, min: 0, max: 23}, + size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, + hair: { + color: {type: String, 'default': 'red'}, + base: {type: Number, 'default': 3}, + bangs: {type: Number, 'default': 1}, + beard: {type: Number, 'default': 0}, + mustache: {type: Number, 'default': 0}, + flower: {type: Number, 'default': 1} + }, + hideHeader: {type:Boolean, 'default':false}, + skin: {type:String, 'default':'915533'}, + shirt: {type: String, 'default': 'blue'}, + timezoneOffset: Number, + sound: {type:String, 'default':'off', enum: ['off','danielTheBard', 'wattsTheme']}, + language: String, + automaticAllocation: Boolean, + allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, + costume: Boolean, + dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, + sleep: {type: Boolean, 'default': false}, + stickyHeader: {type: Boolean, 'default': true}, + disableClasses: {type: Boolean, 'default': false}, + newTaskEdit: {type: Boolean, 'default': false}, + dailyDueDefaultView: {type: Boolean, 'default': false}, + tagsCollapsed: {type: Boolean, 'default': false}, + advancedCollapsed: {type: Boolean, 'default': false}, + toolbarCollapsed: {type:Boolean, 'default':false}, + background: String, + webhooks: {type: Schema.Types.Mixed, 'default': {}}, + // For this fields make sure to use strict comparison when searching for falsey values (=== false) + // As users who didn't login after these were introduced may have them undefined/null + emailNotifications: { + unsubscribeFromAll: {type: Boolean, 'default': false}, + newPM: {type: Boolean, 'default': true}, + wonChallenge: {type: Boolean, 'default': true}, + giftedGems: {type: Boolean, 'default': true}, + giftedSubscription: {type: Boolean, 'default': true}, + invitedParty: {type: Boolean, 'default': true}, + invitedGuild: {type: Boolean, 'default': true}, + questStarted: {type: Boolean, 'default': true}, + invitedQuest: {type: Boolean, 'default': true}, + //remindersToLogin: {type: Boolean, 'default': true}, + // Those importantAnnouncements are in fact the recapture emails + importantAnnouncements: {type: Boolean, 'default': true} + } + }, + profile: { + blurb: String, + imageUrl: String, + name: String, + }, + stats: { + hp: {type: Number, 'default': 50}, + mp: {type: Number, 'default': 10}, + exp: {type: Number, 'default': 0}, + gp: {type: Number, 'default': 0}, + lvl: {type: Number, 'default': 1}, + + // Class System + 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, + points: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + buffs: { + str: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + stealth: {type: Number, 'default': 0}, + streaks: {type: Boolean, 'default': false}, + snowball: {type: Boolean, 'default': false}, + spookDust: {type: Boolean, 'default': false} + }, + training: { + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0} + } + }, + + tags: {type: [{ + _id: false, + id: { type: String, 'default': shared.uuid }, + name: String, + challenge: String + }]}, + + challenges: [{type: 'String', ref:'Challenge'}], + + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + + habits: {type:[TaskSchemas.HabitSchema]}, + dailys: {type:[TaskSchemas.DailySchema]}, + todos: {type:[TaskSchemas.TodoSchema]}, + rewards: {type:[TaskSchemas.RewardSchema]}, + + extra: Schema.Types.Mixed + +}, { + strict: true, + minimize: false // So empty objects are returned +}); + +UserSchema.methods.deleteTask = function(tid) { + this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method +} + +UserSchema.methods.toJSON = function() { + var doc = this.toObject(); + doc.id = doc._id; + + // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + doc.filters = {}; + doc._tmp = this._tmp; // be sure to send down drop notifs + + return doc; +}; + +//UserSchema.virtual('tasks').get(function () { +// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); +// var tasks = _.object(_.pluck(tasks,'id'), tasks); +// return tasks; +//}); + +UserSchema.post('init', function(doc){ + shared.wrap(doc); +}) + +UserSchema.pre('save', function(next) { + + // Populate new users with default content + if (this.isNew){ + //TODO for some reason this doesn't work here: `_.merge(this, shared.content.userDefaults);` + var self = this; + _.each(['habits', 'dailys', 'todos', 'rewards', 'tags'], function(taskType){ + self[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ + var newTask = _.cloneDeep(task); + + // Render task's text and notes in user's language + if(taskType === 'tags'){ + // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTask.id = shared.uuid(); + newTask.name = newTask.name(self.preferences.language); + }else{ + newTask.text = newTask.text(self.preferences.language); + newTask.notes = newTask.notes(self.preferences.language); + + if(newTask.checklist){ + newTask.checklist = _.map(newTask.checklist, function(checklistItem){ + checklistItem.text = checklistItem.text(self.preferences.language); + return checklistItem; + }); + } + } + + return newTask; + }); + }); + } + + //this.markModified('tasks'); + if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { + this.preferences.dayStart = 0; + } + + if (!this.profile.name) { + var fb = this.auth.facebook; + this.profile.name = + (this.auth.local && this.auth.local.username) || + (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || + 'Anonymous'; + } + + // Determines if Beast Master should be awarded + var petCount = shared.countPets(_.reduce(this.items.pets,function(m,v){ + //HOTFIX - Remove when solution is found, the first argument passed to reduce is a function + if(_.isFunction(v)) return m; + return m+(v?1:0)},0), this.items.pets); + + if (petCount >= 90 || this.achievements.beastMasterCount > 0) { + this.achievements.beastMaster = true + } + + // Determines if Mount Master should be awarded + var mountCount = shared.countMounts(_.reduce(this.items.mounts,function(m,v){ + //HOTFIX - Remove when solution is found, the first argument passed to reduce is a function + if(_.isFunction(v)) return m; + return m+(v?1:0)},0), this.items.mounts); + + if (mountCount >= 90 || this.achievements.mountMasterCount > 0) { + this.achievements.mountMaster = true + } + + // Determines if Triad Bingo should be awarded + + var triadCount = shared.countTriad(this.items.pets); + + if ((mountCount >= 90 && triadCount >= 90) || this.achievements.triadBingoCount > 0) { + this.achievements.triadBingo = true; + } + + // EXAMPLE CODE for allowing all existing and new players to be + // automatically granted an item during a certain time period: + // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) + // this.items.pets['JackOLantern-Base'] = 5; + + //our own version incrementer + if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; + this._v++; + + next(); +}); + +UserSchema.methods.unlink = function(options, cb) { + var cid = options.cid, keep = options.keep, tid = options.tid; + var self = this; + switch (keep) { + case 'keep': + self.tasks[tid].challenge = {}; + break; + case 'remove': + self.deleteTask(tid); + break; + case 'keep-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + t.challenge = {}; + } + }); + break; + case 'remove-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + self.deleteTask(t.id); + } + }) + break; + } + self.markModified('habits'); + self.markModified('dailys'); + self.markModified('todos'); + self.markModified('rewards'); + self.save(cb); +} + +module.exports.schema = UserSchema; +module.exports.model = mongoose.model("User", UserSchema); + +mongoose.model("User") + .find({'contributor.admin':true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec(function(err,mods){ + module.exports.mods = mods +}); diff --git a/website/src/routes/apiv1.js b/website/src/routes/apiv1.js new file mode 100644 index 0000000000..f37619f195 --- /dev/null +++ b/website/src/routes/apiv1.js @@ -0,0 +1,173 @@ +var express = require('express'); +var router = new express.Router(); +var _ = require('lodash'); +var async = require('async'); +var icalendar = require('icalendar'); +var api = require('./../controllers/user'); +var auth = require('./../controllers/auth'); +var middleware = require('../middleware'); +var logging = require('./../logging'); +var i18n = require('./../i18n'); + +/* ---------- Deprecated API ------------*/ + +var initDeprecated = function(req, res, next) { + req.headers['x-api-user'] = req.params.uid; + req.headers['x-api-key'] = req.body.apiToken; + return next(); +}; + +router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, i18n.getUserLanguage, api.score); + +// FIXME add this back in +router.get('/v1/users/:uid/calendar.ics', i18n.getUserLanguage, function(req, res, next) { + return next() //disable for now + + var apiToken, model, query, uid; + uid = req.params.uid; + apiToken = req.query.apiToken; + model = req.getModel(); + query = model.query('users').withIdAndToken(uid, apiToken); + return query.fetch(function(err, result) { + var formattedIcal, ical, tasks, tasksWithDates; + if (err) { + return res.send(500, err); + } + tasks = result.get('tasks'); + /* tasks = result[0].tasks*/ + + tasksWithDates = _.filter(tasks, function(task) { + return !!task.date; + }); + if (_.isEmpty(tasksWithDates)) { + return res.send(500, "No events found"); + } + ical = new icalendar.iCalendar(); + ical.addProperty('NAME', 'HabitRPG'); + _.each(tasksWithDates, function(task) { + var d, event; + event = new icalendar.VEvent(task.id); + event.setSummary(task.text); + d = new Date(task.date); + d.date_only = true; + event.setDate(d); + ical.addComponent(event); + return true; + }); + res.type('text/calendar'); + formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:'); + return res.send(200, formattedIcal); + }); +}); + +/* + ------------------------------------------------------------------------ + Batch Update + This is super-deprecated, and will be removed once apiv2 is running against mobile for a while + ------------------------------------------------------------------------ + */ +var batchUpdate = function(req, res, next) { + var user = res.locals.user; + var oldSend = res.send; + var oldJson = res.json; + var performAction = function(action, cb) { + + // req.body=action.data; delete action.data; _.defaults(req.params, action) + // Would require changing action.dir on mobile app + req.params.id = action.data && action.data.id; + req.params.direction = action.dir; + req.params.type = action.type; + req.body = action.data; + res.send = res.json = function(code, data) { + if (_.isNumber(code) && code >= 400) { + logging.error({ + code: code, + data: data + }); + } + //FIXME send error messages down + return cb(); + }; + switch (action.op) { + case "score": + api.score(req, res); + break; + case "addTask": + api.addTask(req, res); + break; + case "delTask": + api.deleteTask(req, res); + break; + case "revive": + api.revive(req, res); + break; + default: + cb(); + break; + } + }; + + // Setup the array of functions we're going to call in parallel with async + var actions = _.transform(req.body || [], function(result, action) { + if (!_.isEmpty(action)) { + result.push(function(cb) { + performAction(action, cb); + }); + } + }); + + // call all the operations, then return the user object to the requester + async.series(actions, function(err) { + res.json = oldJson; + res.send = oldSend; + if (err) return res.json(500, {err: err}); + var response = user.toJSON(); + response.wasModified = res.locals.wasModified; + if (response._tmp && response._tmp.drop){ + res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v}); + }else if(response.wasModified){ + res.json(200, response); + }else{ + res.json(200, {_v: response._v}); + } + }); +}; + +/* + ------------------------------------------------------------------------ + API v1 Routes + ------------------------------------------------------------------------ + */ + + +var cron = api.cron; + +router.get('/status', i18n.getUserLanguage, function(req, res) { + return res.json({ + status: 'up' + }); +}); + +// Scoring +router.post('/user/task/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); +router.post('/user/tasks/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); + +// Tasks +router.get('/user/tasks', auth.auth, i18n.getUserLanguage, cron, api.getTasks); +router.get('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.getTask); +router["delete"]('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.deleteTask); +router.post('/user/task', auth.auth, i18n.getUserLanguage, cron, api.addTask); + +// User +router.get('/user', auth.auth, i18n.getUserLanguage, cron, api.getUser); +router.post('/user/revive', auth.auth, i18n.getUserLanguage, cron, api.revive); +router.post('/user/batch-update', middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron, batchUpdate); + +function deprecated(req, res) { + res.json(404, {err:'API v1 is no longer supported, please use API v2 instead (https://github.com/HabitRPG/habitrpg/blob/develop/API.md)'}); +} +router.get('*', i18n.getUserLanguage, deprecated); +router.post('*', i18n.getUserLanguage, deprecated); +router.put('*', i18n.getUserLanguage, deprecated); + +module.exports = router; diff --git a/website/src/routes/apiv2.coffee b/website/src/routes/apiv2.coffee new file mode 100644 index 0000000000..479521d054 --- /dev/null +++ b/website/src/routes/apiv2.coffee @@ -0,0 +1,790 @@ +### +---------- /api/v2 API ------------ +see https://github.com/wordnik/swagger-node-express +Every url added to router is prefaced by /api/v2 +Note: Many user-route ops exist in ../../common/script/index.coffee#user.ops, so that they can (1) be called both +client and server. +v1 user. Requires x-api-user (user id) and x-api-key (api key) headers, Test with: +$ mocha test/user.mocha.coffee +### + +user = require("../controllers/user") +groups = require("../controllers/groups") +members = require("../controllers/members") +auth = require("../controllers/auth") +hall = require("../controllers/hall") +challenges = require("../controllers/challenges") +dataexport = require("../controllers/dataexport") +nconf = require("nconf") +middleware = require("../middleware") +cron = user.cron +_ = require('lodash') +content = require('../../../common').content +i18n = require('../i18n') + + +module.exports = (swagger, v2) -> + [path,body,query] = [swagger.pathParam, swagger.bodyParam, swagger.queryParam] + + swagger.setAppHandler(v2) + swagger.setErrorHandler("next") + swagger.setHeaders = -> #disable setHeaders, since we have our own thing going on in middleware.js (and which requires `req`, which swagger doesn't pass in) + swagger.configureSwaggerPaths("", "/api-docs", "") + + api = + + '/status': + spec: + description: "Returns the status of the server (up or down)" + action: (req, res) -> + res.json status: "up" + + '/content': + spec: + description: "Get all available content objects. This is essential, since Habit often depends on item keys (eg, when purchasing a weapon)." + parameters: [ + query("language","Optional language to use for content's strings. Default is english.","string") + ] + action: user.getContent + + '/content/paths': + spec: + description: "Show user model tree" + action: user.getModelPaths + + "/export/history": + spec: + description: "Export user history" + method: 'GET' + middleware: [auth.auth, i18n.getUserLanguage] + action: dataexport.history #[todo] encode data output options in the data controller and use these to build routes + + # --------------------------------- + # User + # --------------------------------- + + # Scoring + + "/user/tasks/{id}/{direction}": + spec: + #notes: "Simple scoring of a task." + description: "Simple scoring of a task. This is most-likely the only API route you'll be using as a 3rd-party developer. The most common operation is for the user to gain or lose points based on some action (browsing Reddit, running a mile, 1 Pomodor, etc). Call this route, if the task you're trying to score doesn't exist, it will be created for you. When random events occur, the user._tmp variable will be filled. Critical hits can be accessed through user._tmp.crit. The Streakbonus can be accessed through user._tmp.streakBonus. Both will contain the multiplier value. When random drops occur, the following values are available: user._tmp.drop = {text,type,dialog,value,key,notes}" + parameters: [ + path("id", "ID of the task to score. If this task doesn't exist, a task will be created automatically", "string") + path("direction", "Either 'up' or 'down'", "string") + body '',"If you're creating a 3rd-party task, pass up any task attributes in the body (see TaskSchema).",'object' + ] + method: 'POST' + action: user.score + + # Tasks + "/user/tasks:GET": + spec: + path: '/user/tasks' + description: "Get all user's tasks" + action: user.getTasks + + "/user/tasks:POST": + spec: + path: '/user/tasks' + description: "Create a task" + method: 'POST' + parameters: [ body "","Send up the whole task (see TaskSchema)","object" ] + action: user.addTask + + "/user/tasks/{id}:GET": + spec: + path: '/user/tasks/{id}' + description: "Get an individual task" + parameters: [ + path("id", "Task ID", "string") + ] + action: user.getTask + + "/user/tasks/{id}:PUT": + spec: + path: '/user/tasks/{id}' + description: "Update a user's task" + method: 'PUT' + parameters: [ + path "id", "Task ID", "string" + body "","Send up the whole task (see TaskSchema)","object" + ] + action: user.updateTask + + "/user/tasks/{id}:DELETE": + spec: + path: '/user/tasks/{id}' + description: "Delete a task" + method: 'DELETE' + parameters: [ path("id", "Task ID", "string") ] + action: user.deleteTask + + + "/user/tasks/{id}/sort": + spec: + method: 'POST' + description: 'Sort tasks' + parameters: [ + path("id", "Task ID", "string") + query("from","Index where you're sorting from (0-based)","integer") + query("to","Index where you're sorting to (0-based)","integer") + ] + action: user.sortTask + + + "/user/tasks/clear-completed": + spec: + method: 'POST' + description: "Clears competed To-Dos (needed periodically for performance)." + action: user.clearCompleted + + + "/user/tasks/{id}/unlink": + spec: + method: 'POST' + description: 'Unlink a task from its challenge' + parameters: [ + path("id", "Task ID", "string") + query 'keep',"When unlinking a challenge task, how to handle the orphans?",'string',['keep','keep-all','remove','remove-all'] + ] + middleware: [auth.auth, i18n.getUserLanguage] ## removing cron since they may want to remove task first + action: challenges.unlink + + + # Inventory + "/user/inventory/buy": + spec: + description: "Get a list of buyable gear" + action: user.getBuyList + + "/user/inventory/buy/{key}": + spec: + method: 'POST' + description: "Buy a gear piece and equip it automatically" + parameters:[ + path 'key',"The key of the item to buy (call /content route for available keys)",'string', _.keys(content.gear.flat) + ] + action: user.buy + + "/user/inventory/sell/{type}/{key}": + spec: + method: 'POST' + description: "Sell inventory items back to Alexander" + parameters: [ + #TODO verify these are the correct types + path('type',"The type of object you're selling back.",'string',['eggs','hatchingPotions','food']) + path('key',"The object key you're selling back (call /content route for available keys)",'string') + ] + action: user.sell + + "/user/inventory/purchase/{type}/{key}": + spec: + method: 'POST' + description: "Purchase a gem-purchaseable 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/feed/{pet}/{food}": + spec: + method: 'POST' + description: "Feed your pet some food" + parameters: [ + path 'pet',"The key of the pet you're feeding",'string',_.keys(content.pets) + path 'food',"The key of the food to feed your pet",'string',_.keys(content.food) + ] + action: user.feed + + "/user/inventory/equip/{type}/{key}": + spec: + method: 'POST' + description: "Equip an item (either pet, mount, equipped or costume)" + parameters: [ + path 'type',"Type to equip",'string',['pet','mount','equipped', 'costume'] + path 'key',"The object key you're equipping (call /content route for available keys)",'string' + ] + action: user.equip + + "/user/inventory/hatch/{egg}/{hatchingPotion}": + spec: + method: 'POST' + description: "Pour a hatching potion on an egg" + parameters: [ + path 'egg',"The egg key to hatch",'string',_.keys(content.eggs) + path 'hatchingPotion',"The hatching potion to pour",'string',_.keys(content.hatchingPotions) + ] + action: user.hatch + + + # User + "/user:GET": + spec: + path: '/user' + description: "Get the full user object" + action: user.getUser + + "/user:PUT": + spec: + path: '/user' + method: 'PUT' + description: "Update the user object (only certain attributes are supported)" + parameters: [ + body '','The user object (see UserSchema)','object' + ] + action: user.update + + "/user:DELETE": + spec: + path: '/user' + method: 'DELETE' + description: "Delete a user object entirely, USE WITH CAUTION!" + middleware: [auth.auth, i18n.getUserLanguage] + action: user["delete"] + + "/user/revive": + spec: + method: 'POST' + description: "Revive your dead user" + action: user.revive + + "/user/reroll": + spec: + method: 'POST' + description: 'Drink the Fortify Potion (Note, it used to be called re-roll)' + action: user.reroll + + "/user/reset": + spec: + method: 'POST' + description: "Completely reset your account" + action: user.reset + + "/user/sleep": + spec: + method: 'POST' + description: "Toggle whether you're resting in the inn" + action: user.sleep + + "/user/rebirth": + spec: + method: 'POST' + description: "Rebirth your avatar" + action: user.rebirth + + "/user/class/change": + spec: + method: 'POST' + description: "Either remove your avatar's class, or change it to something new" + parameters: [ + query 'class',"The key of the class to change to. If not provided, user's class is removed.",'string',['warrior','healer','rogue','wizard',''] + ] + action: user.changeClass + + "/user/class/allocate": + spec: + method: 'POST' + description: "Allocate one point towards an attribute" + parameters: [ + query 'stat','The stat to allocate towards','string',['str','per','int','con'] + ] + action:user.allocate + + "/user/class/cast/{spell}": + spec: + method: 'POST' + description: "Casts a spell on a target." + parameters: [ + path 'spell',"The key of the spell to cast (see ../../common#content.coffee)",'string' + query 'targetType',"The type of object you're targeting",'string',['party','self','user','task'] + query 'targetId',"The ID of the object you're targeting",'string' + + ] + action: user.cast + + "/user/unlock": + spec: + method: 'POST' + description: "Unlock a certain gem-purchaseable path (or multiple paths)" + parameters: [ + query 'path',"The path to unlock, such as hair.green or shirts.red,shirts.blue",'string' + ] + action: user.unlock + + "/user/batch-update": + spec: + method: 'POST' + description: "This is an advanced route which is useful for apps which might for example need offline support. You can send a whole batch of user-based operations, which allows you to queue them up offline and send them all at once. The format is {op:'nameOfOperation',parameters:{},body:{},query:{}}" + parameters:[ + body '','The array of batch-operations to perform','object' + ] + middleware: [middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron, user.sessionPartyInvite] + action: user.batchUpdate + + # Tags + "/user/tags": + spec: + method: 'POST' + description: 'Create a new tag' + parameters: [ + body '','New tag (see UserSchema.tags)','object' + ] + action: user.addTag + + "/user/tags/sort": + spec: + method: 'POST' + description: 'Sort tags' + parameters: [ + query("from","Index where you're sorting from (0-based)","integer") + query("to","Index where you're sorting to (0-based)","integer") + ] + action: user.sortTag + + "/user/tags/{id}:PUT": + spec: + path: '/user/tags/{id}' + method: 'PUT' + description: "Edit a tag" + parameters: [ + path 'id','The id of the tag to edit','string' + body '','Tag edits (see UserSchema.tags)','object' + ] + action: user.updateTag + + "/user/tags/{id}:DELETE": + spec: + path: '/user/tags/{id}' + method: 'DELETE' + description: 'Delete a tag' + parameters: [ + path 'id','Id of tag to delete','string' + ] + action: user.deleteTag + + # Webhooks + "/user/webhooks": + spec: + method: 'POST' + description: 'Create a new webhook' + parameters: [ + body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' + ] + action: user.addWebhook + + "/user/webhooks/{id}:PUT": + spec: + path: '/user/webhooks/{id}' + method: 'PUT' + description: "Edit a webhook" + parameters: [ + path 'id','The id of the webhook to edit','string' + body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' + ] + action: user.updateWebhook + + "/user/webhooks/{id}:DELETE": + spec: + path: '/user/webhooks/{id}' + method: 'DELETE' + description: 'Delete a webhook' + parameters: [ + path 'id','Id of webhook to delete','string' + ] + action: user.deleteWebhook + + # --------------------------------- + # Groups + # --------------------------------- + "/groups:GET": + spec: + path: '/groups' + description: "Get a list of groups" + parameters: [ + query 'type',"Comma-separated types of groups to return, eg 'party,guilds,public,tavern'",'string' + ] + middleware: [auth.auth, i18n.getUserLanguage] + action: groups.list + + + "/groups:POST": + spec: + path: '/groups' + method: 'POST' + description: 'Create a group' + parameters: [ + body '','Group object (see GroupSchema)','object' + ] + middleware: [auth.auth, i18n.getUserLanguage] + action: groups.create + + "/groups/{gid}:GET": + spec: + path: '/groups/{gid}' + description: "Get a group. The party the user currently is in can be accessed with the gid 'party'." + parameters: [path('gid','Group ID','string')] + middleware: [auth.auth, i18n.getUserLanguage] + action: groups.get + + "/groups/{gid}:POST": + spec: + path: '/groups/{gid}' + method: 'POST' + description: "Edit a group" + parameters: [body('','Group object (see GroupSchema)','object')] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.update + + "/groups/{gid}/join": + spec: + method: 'POST' + description: 'Join a group' + parameters: [path('gid','Id of the group to join','string')] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.join + + "/groups/{gid}/leave": + spec: + method: 'POST' + description: 'Leave a group' + parameters: [path('gid','ID of the group to leave','string')] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.leave + + "/groups/{gid}/invite": + spec: + method: 'POST' + description: "Invite a user to a group" + parameters: [ + path 'gid','Group id','string' + body '','a payload of invites either under body.uuids or body.emails, only one of them!','object' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action:groups.invite + + "/groups/{gid}/removeMember": + spec: + method: 'POST' + description: "Remove / boot a member from a group" + parameters: [ + path 'gid','Group id','string' + query 'uuid','User id to boot','string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action:groups.removeMember + + "/groups/{gid}/questAccept": + spec: + method: 'POST' + description: "Accept a quest invitation" + parameters: [ + path 'gid',"Group id",'string' + query 'key',"optional. if provided, trigger new invite, if not, accept existing invite",'string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action:groups.questAccept + + "/groups/{gid}/questReject": + spec: + method: 'POST' + description: 'Reject quest invitation' + parameters: [ + path 'gid','Group id','string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.questReject + + "/groups/{gid}/questCancel": + spec: + method: 'POST' + description: 'Cancel quest before it starts (in invitation stage)' + parameters: [path('gid','Group to cancel quest in','string')] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.questCancel + + "/groups/{gid}/questAbort": + spec: + method: 'POST' + description: 'Abort quest after it has started (all progress will be lost)' + parameters: [path('gid','Group to abort quest in','string')] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.questAbort + + #TODO PUT /groups/:gid/chat/:messageId + + "/groups/{gid}/chat:GET": + spec: + path: "/groups/{gid}/chat" + description: "Get all chat messages" + parameters: [path('gid','Group to return the chat from ','string')] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.getChat + + + "/groups/{gid}/chat:POST": + spec: + method: 'POST' + path: "/groups/{gid}/chat" + description: "Send a chat message" + parameters: [ + query 'message', 'Chat message','string' + path 'gid','Group id','string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.postChat + + # placing before route below, so that if !=='seen' it goes to next() + "/groups/{gid}/chat/seen": + spec: + method: 'POST' + description: "Flag chat messages for a particular group as seen" + parameters: [ + path 'gid','Group id','string' + ] + action: groups.seenMessage + + "/groups/{gid}/chat/{messageId}": + spec: + method: 'DELETE' + description: 'Delete a chat message in a given group' + parameters: [ + path 'gid', 'ID of the group containing the message to be deleted', 'string' + path 'messageId', 'ID of message to be deleted', 'string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.deleteChatMessage + + "/groups/{gid}/chat/{mid}/like": + spec: + method: 'POST' + description: "Like a chat message" + parameters: [ + path 'gid','Group id','string' + path 'mid','Message id','string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.likeChatMessage + + "/groups/{gid}/chat/{mid}/flag": + spec: + method: 'POST' + description: "Flag a chat message" + parameters: [ + path 'gid','Group id','string' + path 'mid','Message id','string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.flagChatMessage + + "/groups/{gid}/chat/{mid}/clearflags": + spec: + method: 'POST' + description: "Clear flag count from message and unhide it" + parameters: [ + path 'gid','Group id','string' + path 'mid','Message id','string' + ] + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] + action: groups.clearFlagCount + + # --------------------------------- + # Members + # --------------------------------- + "/members/{uuid}:GET": + spec: + path: '/members/{uuid}' + description: "Get a member." + parameters: [path('uuid','Member ID','string')] + middleware: [i18n.getUserLanguage] # removed auth.auth, so anon users can view shared avatars + action: members.getMember + "/members/{uuid}/message": + spec: + method: 'POST' + description: 'Send a private message to a member' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + body '', '{"message": "The private message to send"}', 'object' + ] + middleware: [auth.auth] + action: members.sendPrivateMessage + "/members/{uuid}/block": + spec: + method: 'POST' + description: 'Block a member from sending private messages' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + ] + middleware: [auth.auth] + action: user.blockUser + "/members/{uuid}/gift": + spec: + method: 'POST' + description: 'Send a gift to a member' + parameters: [ + path 'uuid', 'The UUID of the member', 'string' + body '', '{"type": "gems or subscription", "gems":{"amount":Number, "fromBalance":Boolean}, "subscription":{"months":Number}}', 'object' + ] + middleware: [auth.auth] + action: members.sendGift + + # --------------------------------- + # Hall of Heroes / Patrons + # --------------------------------- + "/hall/heroes": + spec: {} + middleware:[auth.auth, i18n.getUserLanguage] + action: hall.getHeroes + + "/hall/heroes/{uid}:GET": + spec: path: "/hall/heroes/{uid}" + middleware:[auth.auth, i18n.getUserLanguage, hall.ensureAdmin] + action: hall.getHero + + "/hall/heroes/{uid}:POST": + spec: + method: 'POST' + path: "/hall/heroes/{uid}" + middleware: [auth.auth, i18n.getUserLanguage, hall.ensureAdmin] + action: hall.updateHero + + "/hall/patrons": + spec: + parameters: [ + query 'page','Page number to fetch (this list is long)','string' + ] + middleware:[auth.auth, i18n.getUserLanguage] + action: hall.getPatrons + + + # --------------------------------- + # Challenges + # --------------------------------- + + # Note: while challenges belong to groups, and would therefore make sense as a nested resource + # (eg /groups/:gid/challenges/:cid), they will also be referenced by users from the "challenges" tab + # without knowing which group they belong to. So to prevent unecessary lookups, we have them as a top-level resource + "/challenges:GET": + spec: + path: '/challenges' + description: "Get a list of challenges" + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.list + + + "/challenges:POST": + spec: + path: '/challenges' + method: 'POST' + description: "Create a challenge" + parameters: [body('','Challenge object (see ChallengeSchema)','object')] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.create + + "/challenges/{cid}:GET": + spec: + path: '/challenges/{cid}' + description: 'Get a challenge' + parameters: [path('cid','Challenge id','string')] + action: challenges.get + + "/challenges/{cid}/csv": + spec: + description: 'Get a challenge (csv format)' + parameters: [path('cid','Challenge id','string')] + action: challenges.csv + + "/challenges/{cid}:POST": + spec: + path: '/challenges/{cid}' + method: 'POST' + description: "Update a challenge" + parameters: [ + path 'cid','Challenge id','string' + body('','Challenge object (see ChallengeSchema)','object') + ] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.update + + "/challenges/{cid}:DELETE": + spec: + path: '/challenges/{cid}' + method: 'DELETE' + description: "Delete a challenge" + parameters: [path('cid','Challenge id','string')] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges["delete"] + + "/challenges/{cid}/close": + spec: + method: 'POST' + description: 'Close a challenge' + parameters: [ + path 'cid','Challenge id','string' + query 'uid','User ID of the winner','string',true + ] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.selectWinner + + "/challenges/{cid}/join": + spec: + method: 'POST' + description: "Join a challenge" + parameters: [path('cid','Challenge id','string')] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.join + + "/challenges/{cid}/leave": + spec: + method: 'POST' + description: 'Leave a challenge' + parameters: [path('cid','Challenge id','string')] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.leave + + "/challenges/{cid}/member/{uid}": + spec: + description: "Get a member's progress in a particular challenge" + parameters: [ + path 'cid','Challenge id','string' + path 'uid','User id','string' + ] + middleware: [auth.auth, i18n.getUserLanguage] + action: challenges.getMember + + + if nconf.get("NODE_ENV") is "development" + api["/user/addTenGems"] = + spec: method:'POST' + action: user.addTenGems + + _.each api, (route, path) -> + ## Spec format is: + # spec: + # path: "/pet/{petId}" + # description: "Operations about pets" + # notes: "Returns a pet based on ID" + # summary: "Find pet by ID" + # method: "GET" + # parameters: [path("petId", "ID of pet that needs to be fetched", "string")] + # type: "Pet" + # errorResponses: [swagger.errors.invalid("id"), swagger.errors.notFound("pet")] + # nickname: "getPetById" + + route.spec.description ?= '' + _.defaults route.spec, + path: path + nickname: path + notes: route.spec.description + summary: route.spec.description + parameters: [] + #type: 'Pet' + errorResponses: [] + method: 'GET' + route.middleware ?= if path.indexOf('/user') is 0 then [auth.auth, i18n.getUserLanguage, cron] else [i18n.getUserLanguage] + swagger["add#{route.spec.method}"](route);true + + + swagger.configure("#{nconf.get('BASE_URL')}/api/v2", "2") diff --git a/website/src/routes/auth.js b/website/src/routes/auth.js new file mode 100644 index 0000000000..442c919f6b --- /dev/null +++ b/website/src/routes/auth.js @@ -0,0 +1,21 @@ +var auth = require('../controllers/auth'); +var express = require('express'); +var i18n = require('../i18n'); +var router = new express.Router(); + +/* auth.auth*/ +auth.setupPassport(router); //FIXME make this consistent with the others +router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); +router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); +router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); +router.delete('/api/v2/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); +router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); +router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); +router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); +router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); + +router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); +router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); +router.post('/api/v1/user/auth/social', i18n.getUserLanguage, auth.loginSocial); + +module.exports = router; \ No newline at end of file diff --git a/website/src/routes/coupon.js b/website/src/routes/coupon.js new file mode 100644 index 0000000000..57a33866c6 --- /dev/null +++ b/website/src/routes/coupon.js @@ -0,0 +1,12 @@ +var nconf = require('nconf'); +var express = require('express'); +var router = new express.Router(); +var auth = require('../controllers/auth'); +var coupon = require('../controllers/coupon'); +var i18n = require('../i18n'); + +router.get('/api/v2/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); +router.post('/api/v2/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); +router.post('/api/v2/user/coupon/:code', auth.auth, i18n.getUserLanguage, coupon.enterCode); + +module.exports = router; \ No newline at end of file diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js new file mode 100644 index 0000000000..ba27957124 --- /dev/null +++ b/website/src/routes/dataexport.js @@ -0,0 +1,16 @@ +var express = require('express'); +var router = new express.Router(); +var dataexport = require('../controllers/dataexport'); +var auth = require('../controllers/auth'); +var nconf = require('nconf'); +var i18n = require('../i18n'); +var middleware = require('../middleware.js'); + +/* Data export */ +router.get('/history.csv',auth.authWithSession,i18n.getUserLanguage,dataexport.history); //[todo] encode data output options in the data controller and use these to build routes +router.get('/userdata.xml',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.xml); +router.get('/userdata.json',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.json); +router.get('/avatar-:uuid.html', i18n.getUserLanguage, middleware.locals, dataexport.avatarPage); +router.get('/avatar-:uuid.png', i18n.getUserLanguage, middleware.locals, dataexport.avatarImage); + +module.exports = router; diff --git a/website/src/routes/pages.js b/website/src/routes/pages.js new file mode 100644 index 0000000000..1f9be4b624 --- /dev/null +++ b/website/src/routes/pages.js @@ -0,0 +1,37 @@ +var nconf = require('nconf'); +var express = require('express'); +var router = new express.Router(); +var _ = require('lodash'); +var middleware = require('../middleware'); +var user = require('../controllers/user'); +var auth = require('../controllers/auth'); +var i18n = require('../i18n'); + +// -------- App -------- +router.get('/', i18n.getUserLanguage, middleware.locals, function(req, res) { + if (!req.headers['x-api-user'] && !req.headers['x-api-key'] && !(req.session && req.session.userId)) + return res.redirect('/static/front') + + return res.render('index', { + title: 'HabitRPG | Your Life, The Role Playing Game', + env: res.locals.habitrpg + }); +}); + +// -------- Marketing -------- + +var pages = ['front', 'privacy', 'terms', 'api', 'features', 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', 'old-news', 'press-kit']; + +_.each(pages, function(name){ + router.get('/static/' + name, i18n.getUserLanguage, middleware.locals, function(req, res) { + res.render('static/' + name, {env: res.locals.habitrpg}); + }); +}) + +// --------- Redirects -------- + +router.get('/static/extensions', function(req, res) { + res.redirect('http://habitrpg.wikia.com/wiki/App_and_Extension_Integrations'); +}); + +module.exports = router; diff --git a/website/src/routes/payments.js b/website/src/routes/payments.js new file mode 100644 index 0000000000..118f923689 --- /dev/null +++ b/website/src/routes/payments.js @@ -0,0 +1,25 @@ +var nconf = require('nconf'); +var express = require('express'); +var router = new express.Router(); +var auth = require('../controllers/auth'); +var payments = require('../controllers/payments'); +var i18n = require('../i18n'); + +router.get('/paypal/checkout', auth.authWithUrl, i18n.getUserLanguage, payments.paypalCheckout); +router.get('/paypal/checkout/success', i18n.getUserLanguage, payments.paypalCheckoutSuccess); +router.get('/paypal/subscribe', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribe); +router.get('/paypal/subscribe/success', i18n.getUserLanguage, payments.paypalSubscribeSuccess); +router.get('/paypal/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribeCancel); +router.post('/paypal/ipn', i18n.getUserLanguage, payments.paypalIPN); // misc ipn handling + +router.post("/stripe/checkout", auth.auth, i18n.getUserLanguage, payments.stripeCheckout); +router.post("/stripe/subscribe/edit", auth.auth, i18n.getUserLanguage, payments.stripeSubscribeEdit) +//router.get("/stripe/subscribe", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead +router.get("/stripe/subscribe/cancel", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribeCancel); + +router.post("/iap/android/verify", auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify); +router.post("/iap/ios/verify", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.iapIosVerify); + +router.get("/api/v2/coupons/valid-discount/:code", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.validCoupon); + +module.exports = router; \ No newline at end of file diff --git a/website/src/seed.js b/website/src/seed.js new file mode 100644 index 0000000000..c54c715168 --- /dev/null +++ b/website/src/seed.js @@ -0,0 +1,55 @@ +/* + * This script is no longer required due to this code in src/models/group.js: + * // initialize tavern if !exists (fresh installs) + * Group.count({_id:'habitrpg'},function(err,ct){ + * ... + * }) + * + * However we're keeping this script in case future seed updates are needed. + * + * Reference: https://github.com/HabitRPG/habitrpg/issues/3852#issuecomment-55334572 + */ + + +/* + +require('coffee-script') // for habitrpg-shared +var nconf = require('nconf'); +var utils = require('./utils'); +var logging = require('./logging'); +utils.setupConfig(); +var async = require('async'); +var mongoose = require('mongoose'); +User = require('./models/user').model; +Group = require('./models/group').model; + +async.waterfall([ + function(cb){ + mongoose.connect(nconf.get('NODE_DB_URI'), cb); + }, + function(cb){ + Group.findById('habitrpg', cb); + }, + function(tavern, cb){ + logging.info({tavern:tavern,cb:cb}); + if (!tavern) { + tavern = new Group({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy:'public' + }); + tavern.save(cb) + } else { + cb(); + } + } +],function(err){ + if (err) throw err; + logging.info("Done initializing database"); + mongoose.disconnect(); +}) + +*/ diff --git a/website/src/server.js b/website/src/server.js new file mode 100644 index 0000000000..1c3484d776 --- /dev/null +++ b/website/src/server.js @@ -0,0 +1,143 @@ +// Only do the minimal amount of work before forking just in case of a dyno restart +var cluster = require("cluster"); +var _ = require('lodash'); +var nconf = require('nconf'); +var utils = require('./utils'); +utils.setupConfig(); +var logging = require('./logging'); +var isProd = nconf.get('NODE_ENV') === 'production'; +var isDev = nconf.get('NODE_ENV') === 'development'; +var cores = +nconf.get("WEB_CONCURRENCY") || 0; + +if (cores!==0 && cluster.isMaster && (isDev || isProd)) { + // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) + _.times(cores, cluster.fork); + + cluster.on('disconnect', function(worker, code, signal) { + var w = cluster.fork(); // replace the dead worker + logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); + }); + +} else { + require('coffee-script'); // remove this once we've fully converted over + var express = require("express"); + var http = require("http"); + var path = require("path"); + var swagger = require("swagger-node-express"); + var autoinc = require('mongoose-id-autoinc'); + var shared = require('../../common'); + + // Setup translations + var i18n = require('./i18n'); + + var middleware = require('./middleware'); + + var TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; + var app = express(); + var server = http.createServer(); + + // ------------ MongoDB Configuration ------------ + mongoose = require('mongoose'); + var mongooseOptions = !isProd ? {} : { + replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } } + }; + var db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, function(err) { + if (err) throw err; + logging.info('Connected with Mongoose'); + }); + autoinc.init(db); + + // load schemas & models + require('./models/challenge'); + require('./models/group'); + require('./models/user'); + + // ------------ Passport Configuration ------------ + var passport = require('passport') + var util = require('util') + var FacebookStrategy = require('passport-facebook').Strategy; + // Passport session setup. + // To support persistent login sessions, Passport needs to be able to + // serialize users into and deserialize users out of the session. Typically, + // this will be as simple as storing the user ID when serializing, and finding + // the user by ID when deserializing. However, since this example does not + // have a database of user records, the complete Facebook profile is serialized + // and deserialized. + passport.serializeUser(function(user, done) { + done(null, user); + }); + + passport.deserializeUser(function(obj, done) { + done(null, obj); + }); + + // FIXME + // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) + // The proper fix would be to move to a general OAuth module simply to verify accessTokens + passport.use(new FacebookStrategy({ + clientID: nconf.get("FACEBOOK_KEY"), + clientSecret: nconf.get("FACEBOOK_SECRET"), + //callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" + }, + function(accessToken, refreshToken, profile, done) { + done(null, profile); + } + )); + + // ------------ Server Configuration ------------ + var publicDir = path.join(__dirname, "/../public"); + + app.set("port", nconf.get('PORT')); + middleware.apiThrottle(app); + app.use(middleware.domainMiddleware(server,mongoose)); + if (!isProd) app.use(express.logger("dev")); + app.use(express.compress()); + app.set("views", __dirname + "/../views"); + app.set("view engine", "jade"); + app.use(express.favicon(publicDir + '/favicon.ico')); + app.use(middleware.cors); + app.use(middleware.forceSSL); + app.use(express.urlencoded()); + app.use(express.json()); + app.use(require('method-override')()); + //app.use(express.cookieParser(nconf.get('SESSION_SECRET'))); + app.use(express.cookieParser()); + app.use(express.cookieSession({ secret: nconf.get('SESSION_SECRET'), httpOnly: false, cookie: { maxAge: TWO_WEEKS }})); + //app.use(express.session()); + + // Initialize Passport! Also use passport.session() middleware, to support + // persistent login sessions (recommended). + app.use(passport.initialize()); + app.use(passport.session()); + + app.use(app.router); + + var maxAge = isProd ? 31536000000 : 0; + // Cache emojis without copying them to build, they are too many + app.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); + app.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); + app.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); + app.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); + app.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); + app.use(express['static'](publicDir)); + + // Custom Directives + app.use(require('./routes/pages').middleware); + app.use(require('./routes/payments').middleware); + app.use(require('./routes/auth').middleware); + app.use(require('./routes/coupon').middleware); + var v2 = express(); + app.use('/api/v2', v2); + app.use('/api/v1', require('./routes/apiv1').middleware); + app.use('/export', require('./routes/dataexport').middleware); + require('./routes/apiv2.coffee')(swagger, v2); + app.use(middleware.errorHandler); + + server.on('request', app); + server.listen(app.get("port"), function() { + return logging.info("Express server listening on port " + app.get("port")); + }); + + module.exports = server; +} diff --git a/website/src/utils.js b/website/src/utils.js new file mode 100644 index 0000000000..81ffca3f29 --- /dev/null +++ b/website/src/utils.js @@ -0,0 +1,144 @@ +var nodemailer = require('nodemailer'); +var nconf = require('nconf'); +var crypto = require('crypto'); +var path = require("path"); +var request = require('request'); + +// Set when utils.setupConfig is run +var isProd, baseUrl; + +module.exports.ga = undefined; // set Google Analytics on nconf init + +module.exports.sendEmail = function(mailData) { + var smtpTransport = nodemailer.createTransport("SMTP",{ + service: nconf.get('SMTP_SERVICE'), + auth: { + user: nconf.get('SMTP_USER'), + pass: nconf.get('SMTP_PASS') + } + }); + smtpTransport.sendMail(mailData, function(error, response){ + var logging = require('./logging'); + if(error) logging.error(error); + else logging.info("Message sent: " + response.message); + smtpTransport.close(); // shut down the connection pool, no more messages + }); +} + +function getUserInfo(user, fields) { + var info = {}; + + if(fields.indexOf('name') != -1){ + if(user.auth.local){ + info.name = user.profile.name || user.auth.local.username; + }else if(user.auth.facebook){ + info.name = user.profile.name || user.auth.facebook.displayName || user.auth.facebook.username; + } + } + + if(fields.indexOf('email') != -1){ + if(user.auth.local){ + info.email = user.auth.local.email; + }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ + info.email = user.auth.facebook.emails[0].value; + } + } + + if(fields.indexOf('canSend') != -1){ + info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + } + + return info; +} + +module.exports.getUserInfo = getUserInfo; + +module.exports.txnEmail = function(mailingInfoArray, emailType, variables){ + var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; + var variables = [ + {name: 'BASE_URL', content: baseUrl} + ].concat(variables || []); + + // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed + mailingInfoArray = mailingInfoArray.map(function(mailingInfo){ + return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo; + }).filter(function(mailingInfo){ + return (mailingInfo.email && mailingInfo.canSend); + }); + + // When only one recipient send his info as variables + if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){ + variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name}); + } + + if(isProd && mailingInfoArray.length > 0){ + request({ + url: nconf.get('EMAIL_SERVER:url') + '/job', + method: 'POST', + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + pass: nconf.get('EMAIL_SERVER:authPassword') + }, + json: { + type: 'email', + data: { + emailType: emailType, + to: mailingInfoArray, + variables: variables + }, + options: { + attempts: 5, + backoff: {delay: 10*60*1000, type: 'fixed'} + } + } + }); + } +} + +// Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/ +// Note: would use [password-hash](https://github.com/davidwood/node-password-hash), but we need to run +// model.query().equals(), so it's a PITA to work in their verify() function + +module.exports.encryptPassword = function(password, salt) { + return crypto.createHmac('sha1', salt).update(password).digest('hex'); +} + +module.exports.makeSalt = function() { + var len = 10; + return crypto.randomBytes(Math.ceil(len / 2)).toString('hex').substring(0, len); +} + +/** + * Load nconf and define default configuration values if config.json or ENV vars are not found + */ +module.exports.setupConfig = function(){ + nconf.argv() + .env() + //.file('defaults', path.join(path.resolve(__dirname, '../config.json.example'))) + .file('user', path.join(path.resolve(__dirname, './../../config.json'))); + + if (nconf.get('NODE_ENV') === "development") + Error.stackTraceLimit = Infinity; + if (nconf.get('NODE_ENV') === 'production') + require('newrelic'); + + isProd = nconf.get('NODE_ENV') === 'production'; + baseUrl = nconf.get('BASE_URL'); + + module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); +}; + +var algorithm = 'aes-256-ctr'; +module.exports.encrypt = function(text){ + var cipher = crypto.createCipher(algorithm,nconf.get('SESSION_SECRET')) + var crypted = cipher.update(text,'utf8','hex') + crypted += cipher.final('hex'); + return crypted; +} + +module.exports.decrypt = function(text){ + var decipher = crypto.createDecipher(algorithm,nconf.get('SESSION_SECRET')) + var dec = decipher.update(text,'hex','utf8') + dec += decipher.final('utf8'); + return dec; +} diff --git a/website/views/avatar-static.jade b/website/views/avatar-static.jade new file mode 100644 index 0000000000..9223081dba --- /dev/null +++ b/website/views/avatar-static.jade @@ -0,0 +1,28 @@ +doctype html +html(ng-app="habitrpg") + head + title=title + link(rel='shortcut icon', href='#{env.getBuildUrl("favicon.ico")}?v=3') + + meta(charset='utf-8') + meta(name='viewport', content='width=device-width, initial-scale=1.0') + meta(name='apple-mobile-web-app-capable', content='yes') + + script(type='text/javascript'). + window.env = !{JSON.stringify(env)}; + + != env.getManifestFiles("app") + + script(type='text/javascript'). + window.habitrpg + .controller('StaticAvatarCtrl', ['$scope', function($scope){ + $scope.profile = window.env.user; + }]) + + //webfonts + link(href='//fonts.googleapis.com/css?family=Lato:300,400,700,400italic,700italic', rel='stylesheet', type='text/css') + + body(ng-cloak) + include ./shared/header/avatar + div(ng-controller='StaticAvatarCtrl') + +herobox({main:true}) diff --git a/website/views/index.jade b/website/views/index.jade new file mode 100644 index 0000000000..8c93d28e04 --- /dev/null +++ b/website/views/index.jade @@ -0,0 +1,46 @@ +doctype html +//html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":applyingAction}', ui-keypress="{27:'castCancel()'}") +html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":applyingAction}', ui-keyup="{27:'castCancel()'}") + head + title=env.t('titleIndex') + // ?v=1 needed to force refresh + link(rel='shortcut icon', href='#{env.getBuildUrl("favicon.ico")}?v=3') + + meta(charset='utf-8') + meta(name='viewport', content='width=device-width, initial-scale=1.0') + meta(name='apple-mobile-web-app-capable', content='yes') + + if(env.NODE_ENV == 'production') + script(type='text/javascript'). + window.NREUM||(NREUM={}),__nr_require=function(t,e,n){function r(n){if(!e[n]){var o=e[n]={exports:{}};t[n][0].call(o.exports,function(e){var o=t[n][1][e];return r(o?o:e)},o,o.exports)}return e[n].exports}if("function"==typeof __nr_require)return __nr_require;for(var o=0;od;d++)c[d].apply(u,n);return u}function a(t,e){f[t]=s(t).concat(e)}function s(t){return f[t]||[]}function c(){return n(e)}var f={};return{on:a,emit:e,create:c,listeners:s,_events:f}}function r(){return{}}var o="nr@context",i=t("gos");e.exports=n()},{gos:"7eSDFh"}],ee:[function(t,e){e.exports=t("QJf3ax")},{}],3:[function(t){function e(t,e,n,i,s){try{c?c-=1:r("err",[s||new UncaughtException(t,e,n)])}catch(f){try{r("ierr",[f,(new Date).getTime(),!0])}catch(u){}}return"function"==typeof a?a.apply(this,o(arguments)):!1}function UncaughtException(t,e,n){this.message=t||"Uncaught error with no additional information",this.sourceURL=e,this.line=n}function n(t){r("err",[t,(new Date).getTime()])}var r=t("handle"),o=t(5),i=t("ee"),a=window.onerror,s=!1,c=0;t("loader").features.err=!0,window.onerror=e,NREUM.noticeError=n;try{throw new Error}catch(f){"stack"in f&&(t(1),t(4),"addEventListener"in window&&t(2),window.XMLHttpRequest&&XMLHttpRequest.prototype&&XMLHttpRequest.prototype.addEventListener&&t(3),s=!0)}i.on("fn-start",function(){s&&(c+=1)}),i.on("fn-err",function(t,e,r){s&&(this.thrown=!0,n(r))}),i.on("fn-end",function(){s&&!this.thrown&&c>0&&(c-=1)}),i.on("internal-error",function(t){r("ierr",[t,(new Date).getTime(),!0])})},{1:8,2:5,3:9,4:7,5:21,ee:"QJf3ax",handle:"D5DuLP",loader:"G9z0Bl"}],4:[function(t){function e(){}if(window.performance&&window.performance.timing&&window.performance.getEntriesByType){var n=t("ee"),r=t("handle"),o=t(2);t("loader").features.stn=!0,t(1),n.on("fn-start",function(t){var e=t[0];e instanceof Event&&(this.bstStart=Date.now())}),n.on("fn-end",function(t,e){var n=t[0];n instanceof Event&&r("bst",[n,e,this.bstStart,Date.now()])}),o.on("fn-start",function(t,e,n){this.bstStart=Date.now(),this.bstType=n}),o.on("fn-end",function(t,e){r("bstTimer",[e,this.bstStart,Date.now(),this.bstType])}),n.on("pushState-start",function(){this.time=Date.now(),this.startPath=location.pathname+location.hash}),n.on("pushState-end",function(){r("bstHist",[location.pathname+location.hash,this.startPath,this.time])}),"addEventListener"in window.performance&&(window.performance.addEventListener("webkitresourcetimingbufferfull",function(){r("bstResource",[window.performance.getEntriesByType("resource")]),window.performance.webkitClearResourceTimings()},!1),window.performance.addEventListener("resourcetimingbufferfull",function(){r("bstResource",[window.performance.getEntriesByType("resource")]),window.performance.clearResourceTimings()},!1)),document.addEventListener("scroll",e,!1),document.addEventListener("keypress",e,!1),document.addEventListener("click",e,!1)}},{1:6,2:8,ee:"QJf3ax",handle:"D5DuLP",loader:"G9z0Bl"}],5:[function(t,e){function n(t){i.inPlace(t,["addEventListener","removeEventListener"],"-",r)}function r(t){return t[1]}var o=(t(1),t("ee").create()),i=t(2)(o),a=t("gos");if(e.exports=o,n(window),"getPrototypeOf"in Object){for(var s=document;s&&!s.hasOwnProperty("addEventListener");)s=Object.getPrototypeOf(s);s&&n(s);for(var c=XMLHttpRequest.prototype;c&&!c.hasOwnProperty("addEventListener");)c=Object.getPrototypeOf(c);c&&n(c)}else XMLHttpRequest.prototype.hasOwnProperty("addEventListener")&&n(XMLHttpRequest.prototype);o.on("addEventListener-start",function(t){if(t[1]){var e=t[1];"function"==typeof e?this.wrapped=t[1]=a(e,"nr@wrapped",function(){return i(e,"fn-",null,e.name||"anonymous")}):"function"==typeof e.handleEvent&&i.inPlace(e,["handleEvent"],"fn-")}}),o.on("removeEventListener-start",function(t){var e=this.wrapped;e&&(t[1]=e)})},{1:21,2:22,ee:"QJf3ax",gos:"7eSDFh"}],6:[function(t,e){var n=(t(2),t("ee").create()),r=t(1)(n);e.exports=n,r.inPlace(window.history,["pushState"],"-")},{1:22,2:21,ee:"QJf3ax"}],7:[function(t,e){var n=(t(2),t("ee").create()),r=t(1)(n);e.exports=n,r.inPlace(window,["requestAnimationFrame","mozRequestAnimationFrame","webkitRequestAnimationFrame","msRequestAnimationFrame"],"raf-"),n.on("raf-start",function(t){t[0]=r(t[0],"fn-")})},{1:22,2:21,ee:"QJf3ax"}],8:[function(t,e){function n(t,e,n){var r=t[0];"string"==typeof r&&(r=new Function(r)),t[0]=o(r,"fn-",null,n)}var r=(t(2),t("ee").create()),o=t(1)(r);e.exports=r,o.inPlace(window,["setTimeout","setInterval","setImmediate"],"setTimer-"),r.on("setTimer-start",n)},{1:22,2:21,ee:"QJf3ax"}],9:[function(t,e){function n(){c.inPlace(this,d,"fn-")}function r(t,e){c.inPlace(e,["onreadystatechange"],"fn-")}function o(t,e){return e}var i=t("ee").create(),a=t(1),s=t(2),c=s(i),f=s(a),u=window.XMLHttpRequest,d=["onload","onerror","onabort","onloadstart","onloadend","onprogress","ontimeout"];e.exports=i,window.XMLHttpRequest=function(t){var e=new u(t);try{i.emit("new-xhr",[],e),f.inPlace(e,["addEventListener","removeEventListener"],"-",function(t,e){return e}),e.addEventListener("readystatechange",n,!1)}catch(r){try{i.emit("internal-error",[r])}catch(o){}}return e},window.XMLHttpRequest.prototype=u.prototype,c.inPlace(XMLHttpRequest.prototype,["open","send"],"-xhr-",o),i.on("send-xhr-start",r),i.on("open-xhr-start",r)},{1:5,2:22,ee:"QJf3ax"}],10:[function(t){function e(t){if("string"==typeof t&&t.length)return t.length;if("object"!=typeof t)return void 0;if("undefined"!=typeof ArrayBuffer&&t instanceof ArrayBuffer&&t.byteLength)return t.byteLength;if("undefined"!=typeof Blob&&t instanceof Blob&&t.size)return t.size;if("undefined"!=typeof FormData&&t instanceof FormData)return void 0;try{return JSON.stringify(t).length}catch(e){return void 0}}function n(t){var n=this.params,r=this.metrics;if(!this.ended){this.ended=!0;for(var i=0;c>i;i++)t.removeEventListener(s[i],this.listener,!1);if(!n.aborted){if(r.duration=(new Date).getTime()-this.startTime,4===t.readyState){n.status=t.status;var a=t.responseType,f="arraybuffer"===a||"blob"===a||"json"===a?t.response:t.responseText,u=e(f);if(u&&(r.rxSize=u),this.sameOrigin){var d=t.getResponseHeader("X-NewRelic-App-Data");d&&(n.cat=d.split(", ").pop())}}else n.status=0;r.cbTime=this.cbTime,o("xhr",[n,r,this.startTime])}}}function r(t,e){var n=i(e),r=t.params;r.host=n.hostname+":"+n.port,r.pathname=n.pathname,t.sameOrigin=n.sameOrigin}if(window.XMLHttpRequest&&XMLHttpRequest.prototype&&XMLHttpRequest.prototype.addEventListener&&!/CriOS/.test(navigator.userAgent)){t("loader").features.xhr=!0;var o=t("handle"),i=t(2),a=t("ee"),s=["load","error","abort","timeout"],c=s.length,f=t(1);t(4),t(3),a.on("new-xhr",function(){this.totalCbs=0,this.called=0,this.cbTime=0,this.end=n,this.ended=!1,this.xhrGuids={}}),a.on("open-xhr-start",function(t){this.params={method:t[0]},r(this,t[1]),this.metrics={}}),a.on("open-xhr-end",function(t,e){"loader_config"in NREUM&&"xpid"in NREUM.loader_config&&this.sameOrigin&&e.setRequestHeader("X-NewRelic-ID",NREUM.loader_config.xpid)}),a.on("send-xhr-start",function(t,n){var r=this.metrics,o=t[0],i=this;if(r&&o){var f=e(o);f&&(r.txSize=f)}this.startTime=(new Date).getTime(),this.listener=function(t){try{"abort"===t.type&&(i.params.aborted=!0),("load"!==t.type||i.called===i.totalCbs&&(i.onloadCalled||"function"!=typeof n.onload))&&i.end(n)}catch(e){try{a.emit("internal-error",[e])}catch(r){}}};for(var u=0;c>u;u++)n.addEventListener(s[u],this.listener,!1)}),a.on("xhr-cb-time",function(t,e,n){this.cbTime+=t,e?this.onloadCalled=!0:this.called+=1,this.called!==this.totalCbs||!this.onloadCalled&&"function"==typeof n.onload||this.end(n)}),a.on("xhr-load-added",function(t,e){var n=""+f(t)+!!e;this.xhrGuids&&!this.xhrGuids[n]&&(this.xhrGuids[n]=!0,this.totalCbs+=1)}),a.on("xhr-load-removed",function(t,e){var n=""+f(t)+!!e;this.xhrGuids&&this.xhrGuids[n]&&(delete this.xhrGuids[n],this.totalCbs-=1)}),a.on("addEventListener-end",function(t,e){e instanceof XMLHttpRequest&&"load"===t[0]&&a.emit("xhr-load-added",[t[1],t[2]],e)}),a.on("removeEventListener-end",function(t,e){e instanceof XMLHttpRequest&&"load"===t[0]&&a.emit("xhr-load-removed",[t[1],t[2]],e)}),a.on("fn-start",function(t,e,n){e instanceof XMLHttpRequest&&("onload"===n&&(this.onload=!0),("load"===(t[0]&&t[0].type)||this.onload)&&(this.xhrCbStart=(new Date).getTime()))}),a.on("fn-end",function(t,e){this.xhrCbStart&&a.emit("xhr-cb-time",[(new Date).getTime()-this.xhrCbStart,this.onload,e],e)})}},{1:"XL7HBI",2:11,3:9,4:5,ee:"QJf3ax",handle:"D5DuLP",loader:"G9z0Bl"}],11:[function(t,e){e.exports=function(t){var e=document.createElement("a"),n=window.location,r={};e.href=t,r.port=e.port;var o=e.href.split("://");return!r.port&&o[1]&&(r.port=o[1].split("/")[0].split("@").pop().split(":")[1]),r.port&&"0"!==r.port||(r.port="https"===o[0]?"443":"80"),r.hostname=e.hostname||n.hostname,r.pathname=e.pathname,r.protocol=o[0],"/"!==r.pathname.charAt(0)&&(r.pathname="/"+r.pathname),r.sameOrigin=!e.hostname||e.hostname===document.domain&&e.port===n.port&&e.protocol===n.protocol,r}},{}],gos:[function(t,e){e.exports=t("7eSDFh")},{}],"7eSDFh":[function(t,e){function n(t,e,n){if(r.call(t,e))return t[e];var o=n();if(Object.defineProperty&&Object.keys)try{return Object.defineProperty(t,e,{value:o,writable:!0,enumerable:!1}),o}catch(i){}return t[e]=o,o}var r=Object.prototype.hasOwnProperty;e.exports=n},{}],D5DuLP:[function(t,e){function n(t,e,n){return r.listeners(t).length?r.emit(t,e,n):(o[t]||(o[t]=[]),void o[t].push(e))}var r=t("ee").create(),o={};e.exports=n,n.ee=r,r.q=o},{ee:"QJf3ax"}],handle:[function(t,e){e.exports=t("D5DuLP")},{}],XL7HBI:[function(t,e){function n(t){var e=typeof t;return!t||"object"!==e&&"function"!==e?-1:t===window?0:i(t,o,function(){return r++})}var r=1,o="nr@id",i=t("gos");e.exports=n},{gos:"7eSDFh"}],id:[function(t,e){e.exports=t("XL7HBI")},{}],loader:[function(t,e){e.exports=t("G9z0Bl")},{}],G9z0Bl:[function(t,e){function n(){var t=l.info=NREUM.info;if(t&&t.licenseKey&&t.applicationID&&f&&f.body){s(h,function(e,n){e in t||(t[e]=n)}),l.proto="https"===p.split(":")[0]||t.sslForHttp?"https://":"http://",a("mark",["onload",i()]);var e=f.createElement("script");e.src=l.proto+t.agent,f.body.appendChild(e)}}function r(){"complete"===f.readyState&&o()}function o(){a("mark",["domContent",i()])}function i(){return(new Date).getTime()}var a=t("handle"),s=t(1),c=window,f=c.document,u="addEventListener",d="attachEvent",p=(""+location).split("?")[0],h={beacon:"bam.nr-data.net",errorBeacon:"bam.nr-data.net",agent:"js-agent.newrelic.com/nr-515.min.js"},l=e.exports={offset:i(),origin:p,features:{}};f[u]?(f[u]("DOMContentLoaded",o,!1),c[u]("load",n,!1)):(f[d]("onreadystatechange",r),c[d]("onload",n)),a("mark",["firstbyte",i()])},{1:20,handle:"D5DuLP"}],20:[function(t,e){function n(t,e){var n=[],o="",i=0;for(o in t)r.call(t,o)&&(n[i]=e(o,t[o]),i+=1);return n}var r=Object.prototype.hasOwnProperty;e.exports=n},{}],21:[function(t,e){function n(t,e,n){e||(e=0),"undefined"==typeof n&&(n=t?t.length:0);for(var r=-1,o=n-e||0,i=Array(0>o?0:o);++r diff --git a/website/views/main/index.jade b/website/views/main/index.jade new file mode 100644 index 0000000000..586ce3ca60 --- /dev/null +++ b/website/views/main/index.jade @@ -0,0 +1,4 @@ +script(id='partials/main.html', type="text/ng-template") + include ./filters + div(ng-controller='TasksCtrl') + habitrpg-tasks(main='true', obj='user') diff --git a/website/views/options/index.jade b/website/views/options/index.jade new file mode 100644 index 0000000000..513aa4a3bd --- /dev/null +++ b/website/views/options/index.jade @@ -0,0 +1,12 @@ +include ./profile +include ./social/index +include ./inventory/index +include ./settings + +script(id='partials/options.html', type="text/ng-template") + .container-fluid + .row + .col-md-12 + .tab-content.row + .tab-pane.active + div(ui-view) diff --git a/website/views/options/inventory/index.jade b/website/views/options/inventory/index.jade new file mode 100644 index 0000000000..63959b1708 --- /dev/null +++ b/website/views/options/inventory/index.jade @@ -0,0 +1,27 @@ +include ./inventory +include ./stable + +script(type='text/ng-template', id='partials/options.inventory.html') + ul.options-menu + li(ng-class="{ active: $state.includes('options.inventory.drops') }") + a(ui-sref='options.inventory.drops') + =env.t('market') + li(ng-class="{ active: $state.includes('options.inventory.pets') }") + a(ui-sref='options.inventory.pets') + =env.t('pets') + li(ng-class="{ active: $state.includes('options.inventory.mounts') }") + a(ui-sref='options.inventory.mounts') + =env.t('mounts') + li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.equipment') }") + a(ui-sref='options.inventory.equipment') + =env.t('equipment') + li(ng-class="{ active: $state.includes('options.inventory.timetravelers') }") + a(ui-sref='options.inventory.timetravelers') + =env.t('timeTravelers') + li(ng-class="{ active: $state.includes('options.inventory.seasonalshop') }") + a(ui-sref='options.inventory.seasonalshop') + =env.t('seasonalShop') + + .tab-content + .tab-pane.active + div(ui-view) diff --git a/website/views/options/inventory/inventory.jade b/website/views/options/inventory/inventory.jade new file mode 100644 index 0000000000..3d96015e3e --- /dev/null +++ b/website/views/options/inventory/inventory.jade @@ -0,0 +1,251 @@ +script(type='text/ng-template', id='partials/options.inventory.equipment.html') + .container-fluid + .row + .col-md-6.border-right + h3.equipment-title.hint(popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', popover=env.t('battleGearText'))=env.t('battleGear') + li.customize-menu.inventory-gear + menu.pets-menu(label='{{::label}}', ng-repeat='(klass,label) in {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer"), special:env.t("special"), mystery:env.t("mystery")}', ng-show='gear[klass]') + div(ng-repeat='item in gear[klass]') + button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='user.ops.equip({params:{key:item.key}})', class='shop_{{::item.key}}', ng-class='{selectableInventory: user.items.gear.equipped[item.type] == item.key}') + .col-md-6 + h3.equipment-title.hint(popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', popover=env.t('costumeText'))=env.t('costume') + .checkbox.equipment-title + label + input(type="checkbox", ng-model="user.preferences.costume", ng-change='set({"preferences.costume":user.preferences.costume ? true : false})') + |  + =env.t('useCostume') + li.customize-menu(ng-if='user.preferences.costume') + menu.pets-menu(label='{{::label}}', ng-repeat='(klass,label) in {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer"), special:env.t("special"), mystery:env.t("mystery")}', ng-show='gear[klass]') + div(ng-repeat='item in gear[klass]') + button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='user.ops.equip({params:{type:"costume", key:item.key}})', class='shop_{{::item.key}}', ng-class='{selectableInventory: user.items.gear.costume[item.type] == item.key}') + +script(type='text/ng-template', id='partials/options.inventory.seasonalshop.html') + .container-fluid + .stable.row + .col-md-2 + .seasonalshop_closed + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"", linkEnd: ""}) + .popover-content + p!=env.t('seasonalShopClosedText', {linkStart:"", linkEnd: ""}) + // br + .well(ng-if='User.user.achievements.rebirths > 0')=env.t('seasonalShopRebirth') + li.customize-menu.inventory-gear + menu.pets-menu(label='{{::label}}', ng-repeat='(set,label) in ::{candycane:env.t("candycaneSet"), ski:env.t("skiSet"), snowflake:env.t("snowflakeSet"), yeti:env.t("yetiSet")}') + // The `if true || false` conditional for applying the transparent class is necessary because + // when a user activates the orb of rebirth, the seasonal items are still in their inventory, but + // they have each have a value of false. The item can be purchased for gold in the rewards column, + // not the seasonal shop. This makes that more clear. + div(ng-repeat='item in ::getSeasonalShopArray(set)' ng-class="{transparent: user.items.gear.owned[item.key] === true ||user.items.gear.owned[item.key] === false}") + button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='purchase(item.type,item)', class='shop_{{::item.key}}') + .text-center + | {{((item.specialClass == "wizard") && (item.type == "weapon")) + 1}}  + span.Pet_Currency_Gem1x.inline-gems + menu.pets-menu(label=env.t('quests')) + div(ng-repeat='quest in ::getSeasonalShopQuests()') + button.customize-option(data-popover-html="{{::quest.previous && !user.achievements.quests[quest.previous] ? env.t('scrollsPre') : questPopover(quest) | markdown}}", popover-append-to-body='true', popover-title='{{::quest.text()}}', popover-trigger='mouseenter', popover-placement='right', ng-click='buyQuest(quest.key)', ng-class='(quest.previous && !user.achievements.quests[quest.previous]) ? "inventory_quest_scroll_locked inventory_quest_scroll_{{::quest.key}}_locked locked" : "inventory_quest_scroll inventory_quest_scroll_{{::quest.key}}"') + p + | {{::quest.value}}  + span.Pet_Currency_Gem1x.inline-gems + menu.pets-menu(label=env.t('seasonalItems')) + div + button.customize-option(popover='{{::Content.spells.special.snowball.notes()}}', popover-title='{{::Content.spells.special.snowball.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='purchase("special", Content.spells.special.snowball)', class='inventory_special_snowball') + p + | {{::Content.spells.special.snowball.value}} + span(class='shop_gold') + // div + button.customize-option(popover='{{::Content.spells.special.nye.notes()}}', popover-title='{{::Content.spells.special.nye.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='castStart(Content.spells.special.nye)', class='inventory_special_nye') + p + | {{Content.spells.special.nye.value}} + span(class='shop_gold') + +script(type='text/ng-template', id='partials/options.inventory.timetravelers.html') + .container-fluid + .stable.row(ng-if='user.purchased.plan.consecutive.trinkets <= 0') + .col-md-2 + .npc_timetravelers + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title!=env.t('timeTravelersTitleNoSub', {linkStartTyler: "", linkStartVicky: "", linkEnd: ""}) + .popover-content + p!=env.t('timeTravelersPopoverNoSub', {linkStart: "", linkEnd: ""}) + .row.stable(ng-if='user.purchased.plan.consecutive.trinkets > 0') + .col-md-2 + .npc_timetravelers_active + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title=env.t('timeTravelersTitle') + .popover-content + .pull-right + span.inventory_special_trinket.inline-gems + b x{{user.purchased.plan.consecutive.trinkets}} + p!=env.t('timeTravelersPopover', {linkStart: "", linkEnd: ""}) + + .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(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}})', class='shop_{{::item.key}}') + +script(type='text/ng-template', id='partials/options.inventory.drops.html') + .container-fluid + .row + .col-md-6 + h2=env.t('inventory') + p.well=env.t('inventoryText') + menu.inventory-list(type='list') + + li.customize-menu + menu.pets-menu(label=(env.t('eggs') + ' ({{eggCount}})')) + p.muted(ng-show='eggCount < 1')=env.t('noEggs') + div(ng-repeat='(egg,points) in ownedItems(user.items.eggs)') + //TODO move positioning this styling to css + button.customize-option(popover='{{::Content.eggs[egg].notes()}}', popover-title!=env.t("egg", {eggType: "{{::Content.eggs[egg].text()}}"}), popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='chooseEgg(egg)', class='Pet_Egg_{{::egg}}', ng-class='{selectableInventory: selectedPotion && !(user.items.pets[egg+"-"+selectedPotion.key]>0)}') + .badge.badge-info.stack-count {{points}} + //-p {{Content.eggs[egg].text()}} + + li.customize-menu + menu.hatchingPotion-menu(label=(env.t('hatchingPotions') + ' ({{potCount}})')) + p.muted(ng-show='potCount < 1')=env.t('noHatchingPotions') + div(ng-repeat='(pot,points) in ownedItems(user.items.hatchingPotions)') + button.customize-option(popover='{{::Content.hatchingPotions[pot].notes()}}', popover-title!=env.t("potion", {potionType: "{{::Content.hatchingPotions[pot].text()}}"}), popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='choosePotion(pot)', class='Pet_HatchingPotion_{{::pot}}', ng-class='{selectableInventory: selectedEgg && !(user.items.pets[selectedEgg.key+"-"+pot]>0)}') + .badge.badge-info.stack-count {{points}} + + li.customize-menu + menu.pets-menu(label=(env.t('quests') + ' ({{questCount}})')) + p.muted(ng-show='questCount < 1')=env.t('noScrolls') + p.muted!=env.t('scrollsText1') + ' ' + env.t('scrollsText2') + '' + div(ng-repeat='(quest_key,points) in ownedItems(user.items.quests)', ng-init='quest = Content.quests[quest_key]') + button.customize-option(data-popover-html="{{:: quest.previous && !user.achievements.quests[quest.previous] ? env.t('scrollsPre') : questPopover(quest) | markdown}}", popover-title='{{::quest.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='showQuest(quest_key)', ng-class='(quest.previous && !user.achievements.quests[quest.previous]) ? "inventory_quest_scroll_locked inventory_quest_scroll_{{::quest.key}}_locked locked" : "inventory_quest_scroll inventory_quest_scroll_{{::quest.key}}"') + .badge.badge-info.stack-count {{points}} + + li.customize-menu + menu.pets-menu(label=env.t('food') + ' ({{foodCount}})') + p.muted(ng-show='foodCount < 1')=env.t('noFood') + div(ng-repeat='(food,points) in ownedItems(user.items.food)') + button.customize-option(popover='{{::Content.food[food].notes()}}', popover-title='{{::Content.food[food].text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='chooseFood(food)', class='Pet_Food_{{::food}}') + .badge.badge-info.stack-count {{points}} + + + li.customize-menu + menu.pets-menu(label=env.t('special')) + mixin specialItem(k) + div(ng-if='user.items.special.#{k}') + button.customize-option(popover='{{::Content.special.#{k}.notes()}}', popover-title='{{::Content.special.#{k}.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='castStart(Content.special.#{k})', class='inventory_special_#{k}') + .badge.badge-info.stack-count {{user.items.special.#{k}}} + +specialItem('snowball') + +specialItem('spookDust') + + div(ng-if='user.items.special.valentineReceived[0]') + button.customize-option(popover="Valentine's Day Card from {{User.user.items.special.valentineReceived[0]}}", popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='openModal("valentine")', class='inventory_special_valentine') + .badge.badge-info.stack-count {{user.items.special.valentineReceived.length}} + + div(ng-if='user.purchased.plan.customerId || user.purchased.plan.mysteryItems.length') + button.customize-option(popover=env.t('subscriberItemText'), popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', class='inventory_present', ng-click="user.ops.openMysteryItem({})") + .badge.badge-info.stack-count {{user.purchased.plan.mysteryItems.length}} + + div(ng-if='user.purchased.plan.consecutive.trinkets') + button.customize-option(popover=env.t('mysticHourglassPopover'), popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', class='inventory_special_trinket', ng-click="$state.go('options.inventory.timetravelers')") + .badge.badge-info.stack-count {{user.purchased.plan.consecutive.trinkets}} + + div(ng-if='user.items.special.nyeReceived[0]') + button.customize-option(popover="New Year's Card from {{User.user.items.special.nyeReceived[0]}}", popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='openModal("nye")', class='inventory_special_nye') + .badge.badge-info.stack-count {{user.items.special.nyeReceived.length}} + + .col-md-6.border-left + h2=env.t('market') + .row-fluid + table.npc_alex_container + tr + td + .pull-left(class="#{env.worldDmg.market ? 'npc_alex_broken' : 'npc_alex'}") + td + .popover.static-popover.fade.right.in + .arrow + h3.popover-title + a(target='_blank', href='http://www.kickstarter.com/profile/523661924')=env.t('alexander') + .popover-content + p=env.t('welcomeMarket') + p + button.btn.btn-primary(ng-show='selectedEgg', ng-click='sellInventory()')=env.t('sellForGold', {item: "{{selectedEgg.text()}}", gold: "{{selectedEgg.value}}"}) + button.btn.btn-primary(ng-show='selectedPotion', ng-click='sellInventory()')=env.t('sellForGold', {item: "{{selectedPotion.text()}}", gold: "{{selectedPotion.value}}"}) + button.btn.btn-primary(ng-show='selectedFood', ng-click='sellInventory()')=env.t('sellForGold', {item: "{{selectedFood.text()}}", gold: "{{selectedFood.value}}"}) + menu.inventory-list(type='list') + li.customize-menu + menu.pets-menu(label=env.t('eggs')) + div(ng-repeat='egg in Content.eggs', ng-if='egg.canBuy') + button.customize-option(popover='{{::egg.notes()}}', popover-title!=env.t("egg", {eggType: "{{::egg.text()}}"}), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='purchase("eggs", egg)', class='Pet_Egg_{{::egg.key}}') + p + | {{::egg.value}}  + span.Pet_Currency_Gem1x.inline-gems + //- buyable quest eggs + each egg,quest in {gryphon:'Gryphon',hedgehog:'Hedgehog',ghost_stag:'Deer',rat:'Rat',octopus:'Octopus',dilatory_derby:'Seahorse',harpy:'Parrot',rooster:'Rooster',spider:'Spider',owl:'Owl',penguin:'Penguin',rock:'Rock'} + div(ng-show='user.achievements.quests.#{quest} > 0') + button.customize-option(popover='{{::Content.eggs.#{egg}.notes()}}', popover-title!=env.t("egg", {eggType: "{{::Content.eggs.#{egg}.text()}}"}), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='purchase("eggs", Content.eggs.#{egg})', class='Pet_Egg_#{egg}') + p + | {{::Content.eggs.#{egg}.value}}  + span.Pet_Currency_Gem1x.inline-gems + div(ng-show='(user.achievements.quests.trex + user.achievements.quests.trex_undead) > 0') + button.customize-option(popover='{{::Content.eggs.TRex.notes()}}', popover-title!=env.t("egg", {eggType: "{{Content.eggs.TRex.text()}}"}), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='purchase("eggs", Content.eggs.TRex)', class='Pet_Egg_TRex') + p + | {{::Content.eggs.TRex.value}}  + span.Pet_Currency_Gem1x.inline-gems + + li.customize-menu + menu.pets-menu(label=env.t('hatchingPotions')) + div(ng-repeat='pot in Content.hatchingPotions') + button.customize-option(popover='{{::pot.notes()}}', popover-title!=env.t("potion", {potionType: "{{::pot.text()}}"}), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='purchase("hatchingPotions", pot)', class='Pet_HatchingPotion_{{::pot.key}}') + p + | {{::pot.value}}  + span.Pet_Currency_Gem1x.inline-gems + + li.customize-menu + menu.pets-menu(label=env.t('food')) + div(ng-repeat='food in Content.food', ng-if='food.canBuy') + button.customize-option(popover='{{::food.notes()}}', popover-title='{{::food.text()}}', popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='purchase("food", food)', class='Pet_Food_{{::food.key}}') + p + | {{::food.value}}  + span.Pet_Currency_Gem1x.inline-gems + + li.customize-menu + menu.pets-menu(label=env.t('quests')) + p.muted!=env.t('scrollsText1') + ' ' + env.t('scrollsText2') + '' + div(ng-repeat='quest in Content.quests', ng-if='quest.canBuy') + button.customize-option(data-popover-html="{{::quest.previous && !user.achievements.quests[quest.previous] ? env.t('scrollsPre') : questPopover(quest) | markdown}}", popover-title='{{::quest.text()}}', popover-append-to-body="true", popover-trigger='mouseenter', ng-click='buyQuest(quest.key)', ng-class='(quest.previous && !user.achievements.quests[quest.previous]) ? "inventory_quest_scroll_locked inventory_quest_scroll_{{::quest.key}}_locked locked" : "inventory_quest_scroll inventory_quest_scroll_{{::quest.key}}"') + p + | {{::quest.value}}  + span.Pet_Currency_Gem1x.inline-gems + + li.customize-menu + menu.pets-menu(label=env.t('special')) + div + button.customize-option(popover=env.t('fortifyPop'), popover-title=env.t('fortifyName'), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='openModal("reroll")', class='inventory_special_fortify') + p + | 4  + span.Pet_Currency_Gem1x.inline-gems + div(ng-show='user.flags.rebirthEnabled') + button.customize-option(popover=env.t('rebirthPop'), popover-title=env.t('rebirthName'), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='openModal("rebirth")', class='rebirth_orb') + p(ng-show='user.stats.lvl < 100') + | 8  + span.Pet_Currency_Gem1x.inline-gems + div(ng-show='petCount >= 90 || mountCount >= 90') + button.customize-option(popover=env.t('petKeyPop'), popover-title=env.t('petKeyName'), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='openModal("pet-key", {size:"lg"})', class='pet_key') + p + | 4  + span.Pet_Currency_Gem1x.inline-gems + div(ng-if='user.purchased.plan.customerId', ng-class='::{transparent:(Shared.planGemLimits.convCap + User.user.purchased.plan.consecutive.gemCapExtra - User.user.purchased.plan.gemsBought) < 1}') + button.customize-option(popover=env.t('subGemPop'), popover-title=env.t('subGemName'), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', ng-click='user.ops.purchase({params:{type:"gems",key:"gem"}})') + span.Pet_Currency_Gem.inline-gems + .badge.badge-success.stack-count {{Shared.planGemLimits.convCap + User.user.purchased.plan.consecutive.gemCapExtra - User.user.purchased.plan.gemsBought}} + p + | 20  + span.shop_gold + // div + button.customize-option(popover='{{::Content.spells.special.valentine.notes()}}', popover-title='{{::Content.spells.special.valentine.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='castStart(Content.spells.special.valentine)', class='inventory_special_valentine') + p + | {{Content.spells.special.valentine.value}} + span(class='shop_gold') \ No newline at end of file diff --git a/website/views/options/inventory/stable.jade b/website/views/options/inventory/stable.jade new file mode 100644 index 0000000000..27bc3f8ef0 --- /dev/null +++ b/website/views/options/inventory/stable.jade @@ -0,0 +1,98 @@ +mixin petList(source) + menu.pets(type='list') + each egg in source + li.customize-menu + menu + each potion in env.Content.hatchingPotions + - pet = egg.key+"-"+potion.key + div(popover-trigger='mouseenter', popover=env.t('petName', {potion: potion.text(env.language.code), egg: egg.text(env.language.code)}), popover-placement='bottom') + button(class="pet-button Pet-#{pet}", ng-if='user.items.pets["#{pet}"]>0', ng-class='{active: user.items.currentPet == "#{pet}", selectableInventory: #{!egg.noMount} && selectedFood && !user.items.mounts["#{pet}"]}', ng-click='choosePet("#{egg.key}", "#{potion.key}")') + .progress(ng-show='!user.items.mounts["#{pet}"] && "#{egg.key}"!="Egg"') + .progress-bar.progress-bar-success(style='width:{{user.items.pets["#{pet}"]/.5}}%') + button(class="pet-button pet-not-owned", ng-if='!user.items.pets["#{pet}"]') + .PixelPaw + button(class="pet-evolved pet-button Pet-#{pet}", ng-if='user.items.pets["#{pet}"]<0') + +mixin mountList(source) + menu.pets(type='list') + each egg in source + -if(!egg.noMount) { + li.customize-menu + menu + each potion in env.Content.hatchingPotions + - mount = egg.key+"-"+potion.key + div(popover-trigger='mouseenter', popover=env.t('mountName', {potion: potion.text(env.language.code), mount: egg.mountText(env.language.code)}), popover-placement='bottom') + button(class="pet-button Mount_Head_#{mount}", ng-show='user.items.mounts["#{mount}"]', ng-class='{active: user.items.currentMount == "#{mount}"}', ng-click='chooseMount("#{egg.key}", "#{potion.key}")') + //div(class='Mount_Head_{{mount}}') + button(class="pet-button pet-not-owned", ng-hide='user.items.mounts["#{mount}"]') + .PixelPaw + -} + + +script(type='text/ng-template', id='partials/options.inventory.mounts.html') + .container-fluid + .stable.row + .col-md-2 + div(class="#{env.worldDmg.stables ? 'npc_matt_broken' : 'npc_matt'}") + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title + a(target='_blank', href='http://www.kickstarter.com/profile/mattboch')=env.t('mattBoch') + .popover-content + p=env.t('mattShall', {name: "{{user.profile.name}}"}) + h4= env.t('mountMasterProgress') + ': {{mountCount}} / {{totalMounts}} ' + env.t('mountsTamed') + .col-md-12 + +mountList(env.Content.dropEggs) + .col-md-12 + h4=env.t('questMounts') + +mountList(env.Content.questEggs) + .col-md-12 + h4=env.t('rareMounts') + menu + div + each t,k in env.Content.specialMounts + - var animal = k.split('-')[0], color = k.split('-')[1] + button(ng-if='user.items.mounts["#{animal}-#{color}"]', class="pet-button Mount_Head_#{animal}-#{color}", ng-class='{active: user.items.currentMount == "#{animal}-#{color}"}', ng-click='chooseMount("#{animal}", "#{color}")', popover=env.t(t), popover-trigger='mouseenter', popover-placement='bottom') + +script(type='text/ng-template', id='partials/options.inventory.pets.html') + .container-fluid + .stable.row + .col-md-2 + div(class="#{env.worldDmg.stables ? 'npc_matt_broken' : 'npc_matt'}") + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title + a(target='_blank', href='http://www.kickstarter.com/profile/mattboch')=env.t('mattBoch') + .popover-content + p=env.t('mattBochText1') + h4= env.t('beastMasterProgress') + ': {{petCount}} / {{totalPets}} ' + env.t('petsFound') + + .col-md-12 + +petList(env.Content.dropEggs) + .col-md-12 + h4=env.t('questPets') + +petList(env.Content.questEggs) + + .col-md-12 + h4=env.t('rarePets') + menu + div + each t,k in env.Content.specialPets + - var egg = k.split('-')[0], pot = k.split('-')[1] + button(ng-if='user.items.pets["#{egg}-#{pot}"]', class="pet-button Pet-#{egg}-#{pot}", ng-class='{active: user.items.currentPet == "#{egg}-#{pot}"}', ng-click='choosePet("#{egg}", "#{pot}")', popover=env.t(t), popover-trigger='mouseenter', popover-placement='bottom') + a(target='_blank', href='http://habitrpg.wikia.com/wiki/Contributing_to_HabitRPG') + button(ng-if='!user.items.pets["Dragon-Hydra"]', class="pet-button pet-not-owned", popover-trigger='mouseenter', popover-placement='right', popover=env.t('rarePetPop1'), popover-title=env.t('rarePetPop2')) + .PixelPaw-Gold + + .well.food-tray + p(ng-show='foodCount < 1')=env.t('noFood') + menu.inventory-list(type='list', ng-if='foodCount > 0') + li.customize-menu + menu.pets-menu(label=env.t('food')) + div(ng-repeat='(food,points) in ownedItems(user.items.food)') + button.customize-option(popover-append-to-body='true', popover='{{:: Content.food[food].notes()}}', popover-title='{{:: Content.food[food].text()}}', popover-trigger='mouseenter', popover-placement='top', ng-click='chooseFood(food)', class='Pet_Food_{{::food}}') + .badge.badge-info.stack-count {{points}} + // Remove this once we have images in + p {{:: Content.food[food].text()}} diff --git a/website/views/options/profile.jade b/website/views/options/profile.jade new file mode 100644 index 0000000000..1ab07efe1b --- /dev/null +++ b/website/views/options/profile.jade @@ -0,0 +1,299 @@ +mixin gemCost(cost) + small.cost + | #{cost} / + = ' ' + env.t('locked') + block + +-var showPath = function(path, items, joiner) { return path+'["'+items.join('"] '+joiner+' '+path+'["')+'"]'; } +-var unlockPath = function(path, items) { return 'unlock("'+path+'.'+items.join(','+path+'.')+'")'; } + +// Make it a mixin so we can call it from mobile +mixin customizeProfile(mobile) + + mixin buyPref(path,colors,title,status) + li.customize-menu(ng-if='#{status=="disabled" ? showPath("user.purchased."+path, colors, "||") : true}', class=~["limited","seasonal"].indexOf(status) ? "well limited-edition" : "") + if ~['limited','seasonal'].indexOf(status) + .label.label-info.pull-right.hint(popover=limited, popover-title=env.t(status+'Edition'), popover-placement='right', popover-trigger='mouseenter')=env.t(status+'Edition') + menu(label=env.t(title)) + span(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath(path, colors)}')!= env.t('unlockSet',{cost:5}) + ' ' + each color in colors + button.customize-option(type='button', class='#{path=="skin" ? "skin_"+color : "customize-option hair hair_bangs_1_"+color}', ng-class='{locked: !user.purchased.#{path}["#{color}"]}', ng-if='#{status!="disabled"} || user.purchased.#{path}["#{color}"]', ng-click='unlock("#{path}.#{color}")') + + div(class=mobile ? 'padding' : 'container-fluid row') + .col-md-4 + h3(class=mobile?'item item-divider':'')=env.t('bodyBody') + + menu(type='list') + li.customize-menu + menu(label=env.t('bodySize')) + .btn-group.button-bar + button.button.btn.btn-sm.btn-default(ng-class='{active: user.preferences.size=="slim"}', ng-click='set({"preferences.size":"slim"})')=env.t('bodySlim') + button.button.btn.btn-sm.btn-default(ng-class='{active: user.preferences.size=="broad"}', ng-click='set({"preferences.size":"broad"})')=env.t('bodyBroad') + + li.customize-menu + menu(label=env.t('shirts')) + each shirt in ['black', 'blue', 'green', 'pink', 'white', 'yellow'] + button.customize-option(class='{{user.preferences.size}}_shirt_'+shirt, type='button', ng-click='set({"preferences.shirt":"'+shirt+'"})') + + menu(label=env.t('specialShirts')) + - var specialShirts = ['convict', 'cross', 'fire', 'horizon', 'ocean', 'purple', 'rainbow', 'redblue', 'thunder', 'tropical', 'zombie'] + span(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath("shirt",specialShirts)}')!= env.t('unlockSet',{cost:5}) + ' ' + each shirt in specialShirts + button.customize-option(type='button', class='{{user.preferences.size}}_shirt_'+shirt, ng-class='{locked: !user.purchased.shirt.'+shirt+'}', ng-click='unlock("shirt.'+shirt+'")') + + + .col-md-4 + h3(class=mobile?'item item-divider':'')=env.t('bodyHead') + menu(type='list') + // For special events code, see commit dfa27b3 + + + // Color + li.customize-menu + menu(label=env.t('color')) + each color in ['white','brown','blond','red','black'] + button(type='button', class='#{path=="skin" ? "skin_"+color : "customize-option hair hair_bangs_1_"+color}', ng-click='set({"preferences.hair.color": "#{color}"})') + each color in ['candycane','frost','winternight','holly'] + button(type='button', ng-if='user.purchased.hair.color.#{color}', class='customize-option hair hair_bangs_1_#{color}', ng-click='unlock("hair.color.#{color}")') + each color in ['pblue','pgreen','porange','ppink','ppurple','pyellow'] + button(type='button', ng-if='user.purchased.hair.color.#{color}', class='customize-option hair hair_bangs_1_#{color}', ng-click='unlock("hair.color.#{color}")') + +buyPref('hair.color', ['rainbow','yellow','green','purple','blue','TRUred'], 'rainbowColors') + +buyPref('hair.color', ['candycorn','ghostwhite','halloween','midnight','pumpkin','zombie'], 'hauntedColors', 'disabled') + +buyPref('hair.color', ['aurora','festive','hollygreen','peppermint','snowy','winterstar'], 'winteryColors', 'disabled') + + li.customize-menu + menu(label=env.t('bodyHair')) + + // Bangs + menu(label=env.t('hairBangs')) + button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.bangs":0})') + each num in [1,2,3] + button(class='hair_bangs_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-click='set({"preferences.hair.bangs":#{num}})') + + // Base hairstyles (free) + menu(label=env.t('hairBase')) + button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.base":0})') + each num in [1,3] + button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-click='set({"preferences.hair.base":#{num}})') + + // Purchasable hairstyles + menu(label=env.t('hairSet1')) + - var styles = [2,4,5,6,7,8] + span(ng-hide='#{showPath("user.purchased.hair.base", styles, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath("hair.base",styles)}')!= env.t('unlockSet',{cost:5}) + ' ' + each num in styles + button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.base["#{num}"]}', ng-click='unlock("hair.base.#{num}")') + menu(label=env.t('hairSet2')) + - var styles = [9,10,11,12,13,14] + span(ng-hide='#{showPath("user.purchased.hair.base", styles, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath("hair.base", styles)}')!= env.t('unlockSet', {cost: 5}) + ' ' + each num in styles + button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.base["#{num}"]}', ng-click='unlock("hair.base.#{num}")') + + + // Flower + li.customize-menu + menu(label=env.t('flower')) + button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.flower":0})') + each num in [1,2,3,4,5,6] + button(class='hair_flower_#{num} customize-option', type='button', ng-click='set({"preferences.hair.flower":#{num}})') + + li.customize-menu + menu(label=env.t('bodyFacialHair')) + span(ng-hide='user.purchased.hair.mustache["1"] && user.purchased.hair.mustache["2"] && user.purchased.hair.beard["1"] && user.purchased.hair.beard["2"] && user.purchased.hair.beard["3"]') + +gemCost(2) + button.btn.btn-xs(ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' ' + + // Beard + menu(label=env.t('beard')) + button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.beard":0})') + each num in [1,2,3] + button(class='hair_beard_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.beard["#{num}"]}', ng-click='unlock("hair.beard.#{num}")') + + // Mustache + menu(label=env.t('mustache')) + button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.mustache":0})') + each num in [1,2] + button(class='hair_mustache_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.mustache["#{num}"]}', ng-click='unlock("hair.mustache.#{num}")') + + .col-md-4 + h3(class=mobile?'item item-divider':'')=env.t('bodySkin') + // skin + menu(type='list') + li.customize-menu + menu(label=env.t('basicSkins')) + each color in ['ddc994','f5a76e','ea8349','c06534','98461a','915533','c3e1dc','6bd049'] + button.customize-option(type='button', class='skin_#{color}', ng-click='set({"preferences.skin":"#{color}"})') + + // Rainbow Skin + +buyPref('skin', ['eb052b','f69922','f5d70f','0ff591','2b43f6','d7a9f7','800ed0','rainbow'], 'rainbowSkins') + + // Special Events + +buyPref('skin', ['monster','pumpkin','skeleton','zombie','ghost','shadow'], 'spookySkins', 'disabled') + +buyPref('skin', ['candycorn','ogre','pumpkin2','reptile','shadow2','skeleton2','transparent','zombie2'], 'supernaturalSkins', 'disabled') + + +script(id='partials/options.profile.avatar.html', type='text/ng-template') + +customizeProfile() + +mixin profileStats + .container-fluid + div(class=mobile?'padding':'row') + // FIXME, get this working on mobile + .border-right(ng-class='user.flags.classSelected && !user.preferences.disableClasses ? "col-md-4" : "col-md-6"') + include ../shared/profiles/stats + unless mobile + .col-md-4.border-right.allocate-stats(ng-if='user.flags.classSelected && !user.preferences.disableClasses') + h3=env.t('characterBuild') + h4 + =env.t('class') + ': ' + span {{ {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer")}[user.stats.class] }}  + a.btn.btn-danger.btn-xs(ng-click='changeClass(null)')=env.t('changeClass') + small.cost 3 + table.table.table-striped + tr + td + strong.inline + |{{user.stats.points}}  + strong.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('levelPopover'))=env.t('unallocated') + td + tr + td(colspan=2) + fieldset.auto-allocate + .checkbox + label + input(type='checkbox', ng-model='user.preferences.automaticAllocation', ng-change='set({"preferences.automaticAllocation": user.preferences.automaticAllocation?true: false})', ng-click='set({"preferences.allocationMode":"taskbased"})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('autoAllocationPop'))=env.t('autoAllocation') + form(ng-show='user.preferences.automaticAllocation',style='margin-left:1em') + .radio + label + input(type='radio', name='allocationMode', value='flat', ng-model='user.preferences.allocationMode', ng-change='set({"preferences.allocationMode": "flat"})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('evenAllocationPop'))=env.t('evenAllocation') + .radio + label + input(type='radio', name='allocationMode', value='classbased', ng-model='user.preferences.allocationMode', ng-change='set({"preferences.allocationMode": "classbased"})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('classAllocationPop'))=env.t('classAllocation') + .radio + label + input(type='radio', name='allocationMode', value='taskbased', ng-model='user.preferences.allocationMode', ng-change='set({"preferences.allocationMode": "taskbased"})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('taskAllocationPop'))=env.t('taskAllocation') + div(ng-show='user.preferences.automaticAllocation && !(user.preferences.allocationMode === "taskbased") && (user.stats.points > 0)') + a.btn.btn-primary.btn-xs(ng-click='user.ops.allocateNow({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('distributePointsPop')) + span.glyphicon.glyphicon-download + |  + =env.t('distributePoints') + tr + td= env.t('allocateStr') + ' {{user.stats.str}}' + td + a.btn.btn-primary(ng-show='user.stats.points', ng-click='allocate("str")', popover-trigger='mouseenter', popover-placement='right', popover=env.t('allocateStrPop')) + + tr + td= env.t('allocateInt') + ' {{user.stats.int}}' + td + a.btn.btn-primary(ng-show='user.stats.points', ng-click='allocate("int")', popover-trigger='mouseenter', popover-placement='right', popover=env.t('allocateIntPop')) + + tr + td= env.t('allocateCon') + ' {{user.stats.con}}' + td + a.btn.btn-primary(ng-show='user.stats.points', ng-click='allocate("con")', popover-trigger='mouseenter', popover-placement='right', popover=env.t('allocateConPop')) + + tr + td= env.t('allocatePer') + ' {{user.stats.per}}' + td + a.btn.btn-primary(ng-show='user.stats.points', ng-click='allocate("per")', popover-trigger='mouseenter', popover-placement='right', popover=env.t('allocatePerPop')) + + + + div(ng-class='user.flags.classSelected && !user.preferences.disableClasses ? "col-md-4" : "col-md-6"') + button.btn.btn-default(ng-if='user.preferences.disableClasses', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') + hr(ng-if='user.preferences.disableClasses') + include ../shared/profiles/achievements + +script(id='partials/options.profile.stats.html', type='text/ng-template') + +profileStats() + +script(id='partials/options.profile.profile.html', type='text/ng-template') + .container-fluid + .row + .col-md-12(ng-show='!_editing.profile') + button.btn.btn-default(ng-click='_editing.profile = true', ng-show='!_editing.profile')= env.t('edit') + h4=env.t('displayName') + span(ng-show='profile.profile.name') {{profile.profile.name}} + p + small.muted=env.t('displayNameDescription1') + |  + a(href='/#/options/settings/settings')=env.t('displayNameDescription2') + |  + =env.t('displayNameDescription3') + span.muted(ng-hide='profile.profile.name') -  + =env.t('none') + |  - + + h4=env.t('displayPhoto') + img(ng-show='profile.profile.imageUrl', ng-src='{{profile.profile.imageUrl}}') + span.muted(ng-hide='profile.profile.imageUrl') -  + =env.t('none') + |  - + + h4=env.t('displayBlurb') + markdown(ng-show='profile.profile.blurb', text='profile.profile.blurb') + span.muted(ng-hide='profile.profile.blurb') -  + =env.t('none') + |  - + //{{profile.profile.blurb | linky:'_blank'}} + + form.col-md-4(ng-show='_editing.profile', ng-submit='save()') + input.btn.btn-primary(type='submit', value=env.t('save')) + + // TODO use photo-upload instead: https://groups.google.com/forum/?fromgroups=#!topic/derbyjs/xMmADvxBOak + .form-group + label=env.t('displayName') + input.form-control(type='text', placeholder=env.t('fullName'), ng-model='editingProfile.name') + .form-group + label=env.t('photoUrl') + input.form-control(type='url', ng-model='editingProfile.imageUrl', placeholder=env.t('imageUrl')) + .form-group + label=env.t('displayBlurb') + textarea.form-control(rows=5, placeholder=env.t('displayBlurb'), ng-model='editingProfile.blurb') + include ../shared/formatting-help + +mixin backgrounds(mobile) + div(class=mobile ? 'padding' : 'container-fluid') + // backgrounds are listed in content file in chronological order, but + // we want to display them with most recent at top (reversed) + - var bgsKeys = Object.keys(env.Content.backgrounds); + - for (var i = bgsKeys.length-1; i >= 0; i--) { + - var k = bgsKeys[i], bgs = env.Content.backgrounds[k]; + li.customize-menu + menu(label=env.t(k)) + span(ng-hide="ownsSet('background',#{JSON.stringify(bgs)})") + +gemCost(7) + button.btn.btn-xs(ng-click="unlock(setKeys('background',#{JSON.stringify(bgs)}))")!= env.t('unlockSet',{cost:15}) + ' ' + each bg,k in bgs + button.customize-option(type='button', class='background_#{k}', ng-class="user.purchased.background.#{k} ? 'background-unlocked' : 'background-locked'", ng-click='unlock("background.#{k}")', popover-title=bg.text(env.language.code), popover=bg.notes(env.language.code),popover-trigger='mouseenter') + i.glyphicon.glyphicon-lock(ng-if="!user.purchased.background.#{k}") + - } + +script(type='text/ng-template', id='partials/options.profile.backgrounds.html') + +backgrounds() + +script(id='partials/options.profile.html', type="text/ng-template") + ul.options-menu + li(ng-class="{ active: $state.includes('options.profile.avatar') }") + a(ui-sref='options.profile.avatar') + =env.t('avatar') + li(ng-class="{ active: $state.includes('options.profile.backgrounds') }") + a(ui-sref='options.profile.backgrounds') + =env.t('backgrounds') + li(ng-class="{ active: $state.includes('options.profile.stats') }") + a(ui-sref='options.profile.stats') + =env.t('statsAch') + li(ng-class="{ active: $state.includes('options.profile.profile') }") + a(ui-sref='options.profile.profile') + =env.t('profile') + + .tab-content + .tab-pane.active + div(ui-view) diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade new file mode 100644 index 0000000000..92eeaadce9 --- /dev/null +++ b/website/views/options/settings.jade @@ -0,0 +1,387 @@ +script(id='partials/options.settings.html', type="text/ng-template") + ul.options-menu + li(ng-class="{ active: $state.includes('options.settings.settings') }") + a(ui-sref='options.settings.settings') + =env.t('settings') + li(ng-class="{ active: $state.includes('options.settings.api') }") + a(ui-sref='options.settings.api') + =env.t('API') + li(ng-class="{ active: $state.includes('options.settings.export') }") + a(ui-sref='options.settings.export') + =env.t('dataExport') + li(ng-class="{ active: $state.includes('options.settings.coupon') }") + a(ui-sref='options.settings.coupon') + =env.t('coupon') + li(ng-class="{ active: $state.includes('options.settings.subscription') }") + a(ui-sref='options.settings.subscription')=env.t('subscription') + li(ng-class="{ active: $state.includes('options.settings.notifications') }") + a(ui-sref='options.settings.notifications')=env.t('notifications') + + .tab-content + .tab-pane.active + div(ui-view) + +script(type='text/ng-template', id='partials/options.settings.settings.html') + .container-fluid + .row + .personal-options.col-md-6 + .panel.panel-default + .panel-heading + =env.t('settings') + .panel-body + + .form-horizontal + h5=env.t('language') + select.form-control(ng-model='language.code', ng-options='lang.code as lang.name for lang in avalaibleLanguages', ng-change='changeLanguage()') + small + !=env.t('americanEnglishGovern') + br + strong + !=env.t('helpWithTranslation') + + .form-horizontal + h5=env.t('dateFormat') + select.form-control(ng-model='user.preferences.dateFormat', ng-options='DF for DF in availableFormats', ng-change='set({"preferences.dateFormat": user.preferences.dateFormat})') + .checkbox + label + input(type='checkbox', ng-click='hideHeader() ', ng-checked='user.preferences.hideHeader!==true') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('showHeaderPop'))=env.t('showHeader') + .checkbox + label + input(type='checkbox', ng-click='toggleStickyHeader()', ng-checked='user.preferences.stickyHeader!==false', ng-disabled="user.preferences.hideHeader!==false") + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('stickyHeaderPop'))=env.t('stickyHeader') + .checkbox + label + input(type='checkbox', ng-model='user.preferences.newTaskEdit', ng-change='set({"preferences.newTaskEdit": user.preferences.newTaskEdit?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('newTaskEditPop'))=env.t('newTaskEdit') + .checkbox + label + input(type='checkbox', ng-model='user.preferences.tagsCollapsed', ng-change='set({"preferences.tagsCollapsed": user.preferences.tagsCollapsed?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('startCollapsedPop'))=env.t('startCollapsed') + .checkbox + label + input(type='checkbox', ng-model='user.preferences.advancedCollapsed', ng-change='set({"preferences.advancedCollapsed": user.preferences.advancedCollapsed?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('startAdvCollapsedPop'))=env.t('startAdvCollapsed') + .checkbox + label + input(type='checkbox', ng-model='user.preferences.dailyDueDefaultView', ng-change='set({"preferences.dailyDueDefaultView": user.preferences.dailyDueDefaultView?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('dailyDueDefaultViewPop'))=env.t('dailyDueDefaultView') + button.btn.btn-default(ng-click='showTour()', popover-placement='right', popover-trigger='mouseenter', popover=env.t('restartTour'))= env.t('showTour') + button.btn.btn-default(ng-click='showBailey()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('showBaileyPop'))= env.t('showBailey') + button.btn.btn-default(ng-click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('fixValPop'))= env.t('fixVal') + button.btn.btn-default(ng-click="openModal('invite-friends', {controller:'GroupsCtrl'})") Invite Friends + button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') + button.btn.btn-default(ng-if='!user.preferences.disableClasses && user.flags.classSelected', ng-click='showClassesTour()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('classTourPop'))= env.t('showClass') + + div.alert.alert-warning(style='padding:2px;margin-top:7px') + h5.hint(popover=env.t('clockInfo'), popover-trigger='mouseenter')=env.t('customDayStart') + .form-group + .input-group + input.form-control(type='number', min='0', max='23', ng-model='user.preferences.dayStart', ng-blur='saveDayStart()') + span.input-group-addon= ':00 (' + env.t('24HrClock') + ')' + small + =env.t('subWarning1') + |  + a(href='https://github.com/HabitRPG/habitrpg/issues/1057' target='_blank')=env.t('subWarning2') + |  + =env.t('subWarning3') + + .personal-options.col-md-6 + .panel.panel-default + .panel-heading + span Registration + .panel-body + div(ng-if='user.auth.facebook.id') + button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb') + button.btn.btn-danger(ng-click='http("delete","/api/v2/user/auth/social",null,"detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') + hr + div(ng-if='!user.auth.local.username') + p Add local authentication: + form(ng-submit='http("post","/api/v2/register",localAuth,"addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate) + //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll') + .form-group + input.form-control(type='text', placeholder=env.t('username'), ng-model='localAuth.username', required) + .form-group + input.form-control(type='text', placeholder=env.t('email'), ng-model='localAuth.email', required) + .form-group + input.form-control(type='password', placeholder=env.t('password'), ng-model='localAuth.password', required) + .form-group + input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='localAuth.confirmPassword', required) + input.btn.btn-default(type='submit', ng-disabled='localAuth.$invalid', value=env.t('submit')) + + div(ng-if='user.auth.local.username') + p=env.t('username') + |: {{user.auth.local.username}} + p + small.muted + =env.t('loginNameDescription1') + |  + a(href='/#/options/profile/profile')=env.t('loginNameDescription2') + |  + =env.t('loginNameDescription3') + p=env.t('email') + |: {{user.auth.local.email}} + hr + + h5=env.t('changeUsername') + form(ng-submit='changeUser("username", usernameUpdates)', ng-init='usernameUpdates={}', ng-show='user.auth.local', name='changeUsername', novalidate) + //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll') + .form-group + input.form-control(type='text', placeholder=env.t('newUsername'), ng-model='usernameUpdates.username', required) + .form-group + input.form-control(type='password', placeholder=env.t('password'), ng-model='usernameUpdates.password', required) + input.btn.btn-default(type='submit', ng-disabled='changeUsername.$invalid', value=env.t('submit')) + + h5=env.t('changeEmail') + form(ng-submit='changeUser("email", emailUpdates)', ng-show='user.auth.local', name='changeEmail', novalidate) + .form-group + input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.email', required) + .form-group + input.form-control(type='password', placeholder=env.t('password'), ng-model='emailUpdates.password', required) + input.btn.btn-default(type='submit', ng-disabled='changeEmail.$invalid', value=env.t('submit')) + + h5=env.t('changePass') + form(ng-submit='changeUser("password", passwordUpdates)', ng-show='user.auth.local', name='changePassword', novalidate) + .form-group + input.form-control(type='password', placeholder=env.t('oldPass'), ng-model='passwordUpdates.oldPassword', required) + .form-group + input.form-control(type='password', placeholder=env.t('newPass'), ng-model='passwordUpdates.newPassword', required) + .form-group + input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='passwordUpdates.confirmNewPassword', required) + input.btn.btn-default(type='submit', ng-disabled='changePassword.$invalid', value=env.t('submit')) + + + .panel.panel-default + .panel-heading + span=env.t('dangerZone') + .panel-body + a.btn.btn-danger(ng-click='openModal("reset", {controller:"SettingsCtrl"})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('resetAccPop'))= env.t('resetAccount') + a.btn.btn-danger(ng-click='openModal("delete", {controller:"SettingsCtrl"})', popover-trigger='mouseenter', popover=env.t('deleteAccPop'))= env.t('deleteAccount') + +script(type='text/ng-template', id='partials/options.settings.coupon.html') + .container-fluid + .row + .col-md-6 + h2= env.t('coupon') + form.form-inline(role='form',ng-submit='enterCoupon(_couponCode)') + input.form-control(type='text', ng-model='_couponCode', placeholder=env.t('couponPlaceholder')) + button.btn.btn-primary(type='submit') Submit + div + small= env.t('couponText') + div(ng-if='user.contributor.sudo') + hr + h4 Generate Codes + form.form(role='form',ng-submit='generateCodes(_codes)',ng-init='_codes={}') + .form-group + input.form-control(type='text',ng-model='_codes.event',placeholder="Event code (eg, 'wondercon')") + .form-group + input.form-control(type='number',ng-model='_codes.count',placeholder="Number of codes to generate (eg, 250)") + .form-group + button.btn.btn-primary(type='submit') Generate + a.btn.btn-default(href='/api/v2/coupons?_id={{user._id}}&apiToken={{user.apiToken}}') Get Codes + + + +script(type='text/ng-template', id='partials/options.settings.api.html') + .container-fluid + .row + .col-md-6 + h2=env.t('API') + small=env.t('APIText') + h6=env.t('userId') + pre.prettyprint {{user.id}} + h6=env.t('APIToken') + pre.prettyprint {{user.apiToken}} + h6=env.t('qrCode') + img(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{user.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') + + hr + + h2 Webhooks + table.table.table-striped + thead(ng-if='hasWebhooks') + tr + th Enabled + th Webhook URL + th + tbody + tr(ng-repeat="webhook in user.preferences.webhooks | toArray:true | orderBy:'sort'") + td + input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook.$key,webhook)') + td + input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook.$key,webhook)'}") + td + span.pull-left(ng-show='webhook._editing') * + a.checklist-icons(ng-click='deleteWebhook(webhook.$key)') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + tr + td(colspan=2) + form.form-horizontal(ng-submit='addWebhook(_newWebhook.url)') + .form-group.col-sm-10 + input.form-control(type='url', ng-model='_newWebhook.url', placeholder='Webhook URL') + .col-sm-2 + button.btn.btn-sm.btn-primary(type='submit') Add + +script(id='partials/options.settings.export.html', type="text/ng-template") + .container-fluid + .row + .col-md-6 + h2=env.t('dataExport') + small=env.t('saveData') + h4=env.t('habitHistory') + =env.t('exportHistory') + a(href="/export/history.csv")= ' ' + env.t('csv') + h4=env.t('userData') + =env.t('exportUserData') + a(href="/export/userdata.xml")= ' ' + env.t('xml') + ' ' + a(href="/export/userdata.json")= env.t('json') + +mixin subPerks() + table.table.table-striped + tr + td + span.hint(popover=env.t('disableAdsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('disableAds') + tr + td + span.hint(popover=env.t('buyGemsGoldText', {gemCost: "{{Shared.planGemLimits.convRate}}", gemLimit: "{{Shared.planGemLimits.convCap}}"}),popover-trigger='mouseenter',popover-placement='right') #{env.t('buyGemsGold')}  + span.badge.badge-success(ng-show='_subscription.key!="basic_earned"') Cap raised to {{ [25 + user.purchased.plan.consecutive.gemCapExtra + Math.floor(Content.subscriptionBlocks[_subscription.key].months/3*5), 50] | min }} + tr + td + span.hint(popover=env.t('retainHistoryText'),popover-trigger='mouseenter',popover-placement='right')=env.t('retainHistory') + tr + td + span.hint(popover=env.t('doubleDropsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('doubleDrops') + tr + td + span.hint(popover=env.t('mysteryItemText'),popover-trigger='mouseenter',popover-placement='right') #{env.t('mysteryItem')}  + div(ng-show='_subscription.key!="basic_earned"') + .badge.badge-success +{{Math.floor(Content.subscriptionBlocks[_subscription.key].months/3)}} Mystic Hourglass + .small.muted Mystic Hourglasses allow purchasing a previous month's Mystery Item set. + tr + td + span.hint(popover=env.t('supportDevsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('supportDevs') + +script(id='partials/feature-matrix-check.html',type='text/ng-template') + span.task-checker.action-yesno + input.focusable(type='checkbox', checked) + label + +script(id='partials/options.settings.notifications.html', type="text/ng-template") + .container-fluid + .row + .personal-options.col-md-6 + .panel.panel-default + .panel-heading + =env.t('emailNotifications') + .panel-body + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.newPM', ng-change='set({"preferences.emailNotifications.newPM": user.preferences.emailNotifications.newPM ? true: false})') + span=env.t('newPM') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.wonChallenge', ng-change='set({"preferences.emailNotifications.wonChallenge": user.preferences.emailNotifications.wonChallenge ? true: false})') + span=env.t('wonChallenge') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedGems', ng-change='set({"preferences.emailNotifications.giftedGems": user.preferences.emailNotifications.giftedGems ? true: false})') + span=env.t('giftedGems') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedSubscription', ng-change='set({"preferences.emailNotifications.giftedSubscription": user.preferences.emailNotifications.giftedSubscription ? true: false})') + span=env.t('giftedSubscription') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedParty', ng-change='set({"preferences.emailNotifications.invitedParty": user.preferences.emailNotifications.invitedParty ? true: false})') + span=env.t('invitedParty') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedGuild', ng-change='set({"preferences.emailNotifications.invitedGuild": user.preferences.emailNotifications.invitedGuild ? true: false})') + span=env.t('invitedGuild') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.questStarted', ng-change='set({"preferences.emailNotifications.questStarted": user.preferences.emailNotifications.questStarted ? true: false})') + span=env.t('questStarted') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedQuest', ng-change='set({"preferences.emailNotifications.invitedQuest": user.preferences.emailNotifications.invitedQuest ? true: false})') + span=env.t('invitedQuest') + + //.checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.remindersToLogin', ng-change='set({"preferences.emailNotifications.remindersToLogin": user.preferences.emailNotifications.remindersToLogin ? true: false})') + span=env.t('remindersToLogin') + + // These are the recapture emails, important announcements was the previous name used for them + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.importantAnnouncements', ng-change='set({"preferences.emailNotifications.importantAnnouncements": user.preferences.emailNotifications.importantAnnouncements ? true: false})') + span=env.t('inactivityEmails') + + hr + + .checkbox + label + input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})') + span=env.t('unsubscribeAllEmails') + + small=env.t('unsubscribeAllEmailsText') + +script(id='partials/options.settings.subscription.html',type='text/ng-template') + //-h2=env.t('individualSub') + .container-fluid(ng-init='_subscription={key:"basic_earned"}') + .row + .col-md-6 + h3= env.t('benefits') + +subPerks() + + .col-md-6 + table.table.alert.alert-info(ng-if='user.purchased.plan.customerId') + tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning + i.glyphicon.glyphicon-time + | #{env.t('subCanceled')} {{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}} + tr(ng-if='!user.purchased.plan.dateTerminated'): td + h4=env.t('subscribed') + p(ng-if='user.purchased.plan.planId') Recurring ${{Content.subscriptionBlocks[user.purchased.plan.planId].price}} each {{Content.subscriptionBlocks[user.purchased.plan.planId].months}} Month(s) ({{user.purchased.plan.paymentMethod}}) + tr(ng-if='user.purchased.plan.extraMonths'): td + span.glyphicon.glyphicon-credit-card + | You have {{user.purchased.plan.extraMonths | number:2}} months of subscription credit. + tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td + span.glyphicon.glyphicon-forward + |  Consecutive Subscription + ul.list-unstyled + li Consecutive Months: {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}} + li Gem Cap Extra: {{user.purchased.plan.consecutive.gemCapExtra}} + li Mystic Hourglasses: {{user.purchased.plan.consecutive.trinkets}} + div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') + .form-group + .radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit: "discount==true" | orderBy:"months"') + label + input(type="radio", name="subRadio", ng-value="block.key", ng-model='_subscription.key') + span(ng-show='block.original') + span.label.label-success.line-through + | ${{:: block.original }} + =env.t('subscriptionRateText', {price:'{{::block.price}}', months: '{{::block.months}}'}) + span(ng-hide='block.original') + =env.t('subscriptionRateText', {price: '{{::block.price}}', months: '{{block.months}}'}) + + .form-inline + .form-group + input.form-control(type='text', ng-model='_subscription.coupon', placeholder='Promo Code') + .form-group + button.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)') Apply + + h3(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') Resubscribe + a.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key') Card + a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') PayPal + div(ng-if='user.purchased.plan.customerId') + .btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard') + .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') diff --git a/website/views/options/social/boss.jade b/website/views/options/social/boss.jade new file mode 100644 index 0000000000..87194da9a1 --- /dev/null +++ b/website/views/options/social/boss.jade @@ -0,0 +1,114 @@ +mixin boss(tavern, mobile) + //-.panel.panel-default(bindonce='group', ng-if='group.type==="party" && group.quest.key') + div(class=mobile ? '' : 'panel panel-default' ng-if='group.quest.key') + div(class = mobile ? 'item item-divider' : 'panel-heading') + h3.panel-title(ng-if='group.quest.active==false')=env.t('questInvitation') + ' {{::Content.quests[group.quest.key].text()}}' + h3.panel-title(ng-if='group.quest.active==true') {{::Content.quests[group.quest.key].text()}} + .panel-body.modal-fixed-height + div(ng-if='group.quest.active==false') + table.table.table-striped + tr(ng-repeat='member in group.members') + td + span(ng-if=':: group.quest.leader && group.quest.leader==member._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfPendingQuest(group.quest.leader,group)') + |*  + |{{::member.profile.name}} + td {{group.quest.members[member._id] === true ? env.t('accepted') : group.quest.members[member._id] === false ? env.t('rejected') : env.t('pending')}} + + span(ng-if=':: group.quest.leader && isMemberOfGroup(group.quest.leader,group) && isMemberOfPendingQuest(group.quest.leader,group)') + |*  + =env.t('questOwner') + // This is commented-out until we have time to work out why it fails intermittently on the website and always on the mobile app (https://github.com/HabitRPG/habitrpg/issues/4074): + // span(ng-if=':: group.quest.leader && isMemberOfGroup(group.quest.leader,group) && ! isMemberOfPendingQuest(group.quest.leader,group)') + // =env.t('questOwnerNotInPendingQuest') + // span(ng-if=':: ! group.quest.leader || ! isMemberOfGroup(group.quest.leader,group)') + // =env.t('questOwnerNotInPendingQuestParty') + hr + .npc_ian.pull-left + p=env.t('questStart') + + // only the quest owner sees the begin button: + button.btn.btn-sm.btn-warning(ng-if=':: group.quest.leader && group.quest.leader==user._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfPendingQuest(group.quest.leader,group)', ng-click='party.$questAccept({"force":true})')=env.t('begin') + // // only the quest owner sees the cancel button UNLESS the quest owner is no longer in the quest/party and then everyone sees it: + // This is commented-out until we have time to work out why it fails intermittently on the website and always on the mobile app (https://github.com/HabitRPG/habitrpg/issues/4074): + // button.btn.btn-sm.btn-danger(ng-if=':: (group.quest.leader && group.quest.leader==user._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfPendingQuest(group.quest.leader,group)) || (! group.quest.leader || ! isMemberOfGroup(group.quest.leader,group) || ! isMemberOfPendingQuest(group.quest.leader,group))', ng-click='questCancel()')=env.t('cancel') + // only the quest owner sees the cancel button: + button.btn.btn-sm.btn-danger(ng-if=':: (group.quest.leader && group.quest.leader==user._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfPendingQuest(group.quest.leader,group))', ng-click='questCancel()')=env.t('cancel') + + div(ng-if='group.quest.active==true') + div(ng-if='::Content.quests[group.quest.key].boss',ng-init='boss=Content.quests[group.quest.key].boss;progress=group.quest.progress') + if tavern + div(class="quest_{{group.quest.key}} quest_{{group.quest.key}}_#{env.worldDmg.recent}") + else + div(class="quest_{{::group.quest.key}}") + //- + .progress + .bar(style='width: {{Shared.percent(group.quest.hp, Content.quests[group.quest.key].hp)}}%;') + span.meter-text + span.glyphicon.glyphicon-heart + | {{group.quest.hp | number:0}} / {{Content.quests[group.quest.key].hp}} + .hero-stats + .meter-label(tooltip=env.t('bossHP')) + span.glyphicon.glyphicon-heart + .meter.health + .bar(style='width: {{Shared.percent(progress.hp, boss.hp)}}%;') + span.meter-text.value + | {{Math.ceil(progress.hp) | goldRoundThousandsToK}} / {{boss.hp | goldRoundThousandsToK}} + .meter-label(tooltip='Rage', ng-if='boss.rage') + span.glyphicon.glyphicon-fire + .meter.mana(ng-if='boss.rage',popover="{{::boss.rage.description()}}",popover-title="{{::boss.rage.title()}}",popover-trigger='mouseenter',popover-placement='right') + .bar(style='width: {{Shared.percent(progress.rage, boss.rage.value)}}%;') + span.meter-text.value + | {{Math.ceil(progress.rage) | goldRoundThousandsToK}} / {{boss.rage.value | goldRoundThousandsToK}} + div(ng-if='::Content.quests[group.quest.key].collect') + div(class="quest_{{::group.quest.key}}") + h4=env.t('collected') + ':' + table.table.table-striped + tr(ng-repeat='(k,v) in group.quest.progress.collect', class='quest_collected_{{v >= Content.quests[group.quest.key].collect[k].count}}') + td + div.pull-left(class='quest_{{::group.quest.key}}_{{::k}}') + | {{::Content.quests[group.quest.key].collect[k].text()}} + td + |{{v}} / {{Content.quests[group.quest.key].collect[k].count}} + + div(ng-bind-html='::Content.quests[group.quest.key].notes()') + unless tavern + hr + h5=env.t('participants') + table.table.table-striped + tr(ng-repeat='(k,v) in group.members', ng-if='::group.quest.members[v._id]') + td + span(ng-if=':: group.quest.leader && group.quest.leader==v._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfRunningQuest(group.quest.leader,group)') + |*  + |{{::v.profile.name}} + span(ng-if=':: group.quest.leader && isMemberOfGroup(group.quest.leader,group) && isMemberOfRunningQuest(group.quest.leader,group)') + |*  + =env.t('questOwner') + // This is commented-out until we have time to work out why it fails intermittently on the website and always on the mobile app (https://github.com/HabitRPG/habitrpg/issues/4074): + // span(ng-if=':: group.quest.leader && isMemberOfGroup(group.quest.leader,group) && ! isMemberOfRunningQuest(group.quest.leader,group)') + // =env.t('questOwnerNotInRunningQuest') + // span(ng-if=':: ! group.quest.leader || ! isMemberOfGroup(group.quest.leader,group)') + // =env.t('questOwnerNotInRunningQuestParty') + unless tavern + quest-rewards(key='{{::group.quest.key}}') + + div(ng-if='::Content.quests[group.quest.key].boss') + .npc_ian.pull-left + if tavern + p!=env.t('tavernBossInfo') + else + p!=env.t('bossDmg1') + br + p=env.t('bossDmg2') + + div(ng-if='::Content.quests[group.quest.key].collect') + .npc_ian.pull-left + p=env.t('bossColl1') + br + p=env.t('bossColl2') + + unless tavern + // // only the quest owner sees the abort button UNLESS the quest owner is no longer in the quest/party and then everyone sees it: + // This is commented-out until we have time to work out why it fails intermittently on the website and always on the mobile app (https://github.com/HabitRPG/habitrpg/issues/4074): + // button.btn.btn-sm.btn-warning(ng-if=':: (group.quest.leader && group.quest.leader==user._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfRunningQuest(group.quest.leader,group)) || ! group.quest.leader || ! isMemberOfGroup(group.quest.leader,group) || ! isMemberOfRunningQuest(group.quest.leader,group)', ng-click='questAbort()')=env.t('abort') + // only the quest owner sees the abort button: + button.btn.btn-sm.btn-warning(ng-if=':: (group.quest.leader && group.quest.leader==user._id && isMemberOfGroup(group.quest.leader,group) && isMemberOfRunningQuest(group.quest.leader,group))', ng-click='questAbort()')=env.t('abort') diff --git a/website/views/options/social/challenge-box.jade b/website/views/options/social/challenge-box.jade new file mode 100644 index 0000000000..57085c820b --- /dev/null +++ b/website/views/options/social/challenge-box.jade @@ -0,0 +1,24 @@ +// ------ Challenges ------- +.panel.panel-default + .panel-heading + h3.panel-title=env.t('challenges') + |  + a.pull-right(target='_blank', href='https://trello.com/card/challenges-individual-party-guild-public/50e5d3684fe3a7266b0036d6/58') + small=env.t('moreInfo') + .panel-body.modal-fixed-height(bindonce='group.challenges') + div(ng-if='group.challenges.length > 0') + table.table.table-striped + tr(ng-repeat='challenge in group.challenges') + td + a(ui-sref='options.social.challenges.detail({cid:challenge._id})') {{challenge.name}} + div(ng-if='group.challenges.length == 0') + p + |  + =env.t('noChallenges') + |  + a.label.label-primary(ui-sref='options.social.challenges') + span.glyphicon.glyphicon-bullhorn + |  + =env.t('challenges') + |  + =env.t('toCreate') diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade new file mode 100644 index 0000000000..3707c4f0d8 --- /dev/null +++ b/website/views/options/social/challenges.jade @@ -0,0 +1,170 @@ +script(type='text/ng-template', id='partials/options.social.challenges.detail.close.html') + a.btn.btn-sm.btn-danger(ng-click="delete(closingChal)")=env.t('delete') + h5= '- ' + env.t('or') + ' -' + select(ui-select2, ng-required=true, ng-model='closingChal.winner', data-placeholder=env.t('selectWinner'), ng-change='selectWinner(closingChal)', ) + option(value='') + option(ng-repeat='u in closingChal.members', value='{{u._id}}') {{u.profile.name}} + + small.pull-right + a(ng-click='cancelClosing(closingChal)')=env.t('cancel') + +script(type='text/ng-template', id='partials/options.social.challenges.detail.member.html') + .modal.bs-modal-lg(style='display: block') + .modal-dialog.modal-lg + .modal-content + .modal-header + button.close(type='button', ng-click='$state.go("^")', aria-hidden='true') × + h3 {{obj.profile.name}} + .modal-body + habitrpg-tasks(main=false, modal='true') + .modal-footer + a.btn.btn-default(ng-click='$state.go("^")')=env.t('close') + +script(type='text/ng-template', id='partials/options.social.challenges.detail.html') + // Edit button + div(bindonce='challenge', ng-if='challenge.leader==user._id') + div(ng-hide='challenge._locked==false') + button.btn.btn-sm.btn-default(ng-click='challenge._locked = false')=env.t('edit') + button.btn.btn-sm.btn-warning(ng-click='close(challenge, $event)', tooltip=env.t('deleteOrSelect'))=env.t('endChallenge') + + form(ng-show='challenge._locked==false') + .form-group(ng-show='challenge._locked==false') + button.btn.btn-sm.btn-primary(ng-click='save(challenge)')=env.t('save') + button.btn.btn-sm.btn-default(ng-click='challenge._locked=true')=env.t('cancel') + .form-group + input.form-control(type='text', ng-model='challenge.name') + .form-group + input.form-control(type='text', minlength="3", maxlength="16", ng-model='challenge.shortName', placeholder=env.t('challengeTag'), required) + .form-group + textarea.form-control(cols='3', placeholder=env.t('challengeDescr'), ng-model='challenge.description') + hr + + .well(bindonce='challenge', ng-if='challenge.description') + markdown(text='challenge.description') + .modal-body=env.t('challengeDiscription') + habitrpg-tasks(obj='challenge', main=false) + + // Member List + div(bindonce='challenge', ng-if='challenge.members.length > 0') + a.btn.btn-primary.btn-sm.pull-right(ng-href='/api/v2/challenges/{{challenge._id}}/csv') + =env.t('exportChallengeCSV') + h3=env.t('hows') + menu + button.customize-option(ng-repeat='member in challenge.members track by member._id', ng-click='toggleMember(challenge._id, member._id)') {{member.profile.name}} + div(ui-view) + + // Accordion version if we choose that instead + //- div(ng-if='challenge.members') + h4=env.t('hows') + .accordion-group(bindonce='challenge.members', ng-repeat='member in challenge.members') + .accordion-heading + a.accordion-toggle(ng-click='toggleMember(challenge._id, member._id)') {{member.profile.name}} + .accordion-body(ng-class='{collapse: $stateParams.uid != member._id}') + .accordion-inner(ng-if='$stateParams.uid == member._id') + div(ui-view) + +script(type='text/ng-template', id='partials/options.social.challenges.html') + .container-fluid + .row + .col-md-2 + .well#challenges-filters + h3=env.t('filter') + ':' + h4=env.t('groups') + ul.list-unstyled + a.btn.btn-xs.btn-default.pull-left(ng-click='selectAll()')=env.t('all') + a.btn.btn-xs.btn-default(ng-click='selectNone()')=env.t('none') + br + .checkbox(ng-repeat='group in groupsFilter') + label + input(type='checkbox', ng-model='search.group[group._id]') + | {{group.name}} + h4=env.t('membership') + .radio + label + input(type='radio', name='search-participation-radio', ng-click='search._isMember = true') + =env.t('participating') + .radio + label + input(type='radio', name='search-participation-radio', ng-click='search._isMember = false') + =env.t('notParticipating') + .radio + label + input(type='radio', name='search-participation-radio', ng-click='search._isMember = undefined', checked='checked') + =env.t('either') + .col-md-10 + // Creation form + button.btn.btn-success#create-challenge-btn(ng-click='create()', ng-hide='newChallenge')=env.t('createChallenge') + .create-challenge-from.well(ng-if='newChallenge') + form(ng-submit='save(newChallenge)') + div + input.btn.btn-success(type='submit', value=env.t('save')) + input.btn.btn-default(type='button', ng-click='openModal()', value='Invite to challenge') + input.btn.btn-danger(type='button', ng-click='discard()', value=env.t('discard')) + select(ng-model='newChallenge.group', ng-required='required', name='Group', ng-options='g._id as g.name for g in groups') + .challenge-options + .form-group + input.form-control(type='text', ng-model='newChallenge.name', placeholder=env.t('challengeTitle'), required='required') + + .form-group + input.form-control(type='text', minlength="3", maxlength="16", ng-model='newChallenge.shortName', placeholder=env.t('challengeTag'), required) + |  + a.hint.vertical-20(target='_blank', href='http://habitrpg.wikia.com/wiki/Tags', popover=env.t('challengeTagPop'), popover-trigger='mouseenter', popover-placement='right') + =env.t('moreInfo') + + .form-group + textarea.form-control(cols='3', placeholder=env.t('challengeDescr'), ng-model='newChallenge.description') + + //- what's going on here? + br + br + + .form-group + input.form-control(type='number', min="{{newChallenge.group=='habitrpg' ? 1 : 0}}", max="{{maxPrize}}", ng-model='newChallenge.prize', placeholder=env.t('prize')) + span.input-suffix.Pet_Currency_Gem1x.inline-gems + |  + span.hint.vertical-20(popover=env.t('prizePop'), popover-trigger='mouseenter', popover-placement='right') + =env.t('moreInfo') + span(ng-show='newChallenge.group=="habitrpg"') + !=env.t('publicChallenges') + + .form-group(ng-if='user.contributor.admin') + .checkbox + label + input(type='checkbox', ng-model='newChallenge.official') + =env.t('officialChallenge') + + habitrpg-tasks(main=false, obj='newChallenge') + + // Challenges list + .panel-group + .panel.panel-default(ng-repeat='challenge in challenges|filter:filterChallenges track by challenge._id ') + .panel-heading + ul.pull-right.challenge-accordion-header-specs + li.bg-transparent(ng-if='challenge.official') + span.label.label-success=env.t('officialChallenge') + li {{challenge.group.name}} + li + =env.t('by') + ' ' + strong {{challenge.leader.profile.name}} + li + | {{challenge.memberCount}} + = ' ' + env.t('participants') + li(ng-show='challenge.prize') + p + // prize + | {{challenge.prize}}  + span.inline-block.Pet_Currency_Gem1x + |   + =env.t('prize') + li.bg-transparent + // leave / join + a.btn.btn-sm.btn-danger(ng-show='challenge._isMember', ng-click='clickLeave(challenge, $event)') + span.glyphicon.glyphicon-ban-circle + =env.t('leave') + a.btn.btn-sm.btn-success(ng-hide='challenge._isMember', ng-click='join(challenge)') + span.glyphicon.glyphicon-ok + =env.t('join') + a.accordion-toggle(ng-click='toggle(challenge._id)') {{challenge.name}} + .panel-body(ng-class='{collapse: !$stateParams.cid == challenge._id}') + .accordion-inner(ng-if='$stateParams.cid == challenge._id') + div(ui-view) diff --git a/website/views/options/social/chat-box.jade b/website/views/options/social/chat-box.jade new file mode 100644 index 0000000000..c30dc9184e --- /dev/null +++ b/website/views/options/social/chat-box.jade @@ -0,0 +1,25 @@ +div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAccepted') + p If you would like to post messages in the Tavern or any party or guild chat, please first read our + |  + a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') + | and then click the button below to indicate that you accept them. + .chat-controls + div + button.btn.btn-warning(ng-click='acceptCommunityGuidelines()')=env.t('iAcceptCommunityGuidelines') + .chat-buttons + button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) + span.glyphicon.glyphicon-refresh + +form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') + div(ng-controller='AutocompleteCtrl') + textarea.form-control(rows=4, ui-keydown='{"meta-enter":"postChat(group,message.content)"}', ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete placeholder="{{group._id == 'habitrpg' ? env.t('tavernCommunityGuidelinesPlaceholder') : ''}}") + span.user-list(ng-show='!isAtListHidden') + ul.list-at-user + li(ng-repeat='user in response | filter:query.text | limitTo: 5', ng-click='autoComplete(user)') + span.username.label.label-default(ng-class=':: userLevelStyle(user)') {{::user.user}} + .chat-controls + .chat-formatting + include ../../shared/formatting-help + .chat-buttons + input(type='submit', value=env.t('sendChat'), ng-class='{disabled: _sending == true}') + button(type="button", ng-click='sync(group)')=env.t('toolTipMsg') diff --git a/website/views/options/social/chat-message.jade b/website/views/options/social/chat-message.jade new file mode 100644 index 0000000000..48c6d85e7f --- /dev/null +++ b/website/views/options/social/chat-message.jade @@ -0,0 +1,39 @@ +mixin chatMessages(inbox) + ul.list-unstyled.tavern-chat + - var ngRepeat = inbox ? 'message in user.inbox.messages | toArray:true | orderBy:"sort":true' : 'message in group.chat track by message.id' + li.chat-message(ng-repeat=ngRepeat, ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}', ng-if="!message.flagCount || message.flagCount < 2 || user.contributor.admin") + span.pull-right.text-danger(ng-if="user.contributor.admin && message.flagCount > 0") + | {{message.flagCount > 1 ? "Message Hidden" : "1 flag"}} + .scrollable-message(ng-class='{"transparent": message.sent || message.flags[user._id] || (user.contributor.admin && message.flagCount > 1)}') + span(ng-if='::message.user') + a.label.label-default.chat-message.hidden-label + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + |     + span {{::message.user}}  + span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') + // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text + markdown(text='::message.text', remove-watch='true') + | - + span.muted.time(from-now='::message.timestamp' tooltip="{{::message.timestamp | date:user.preferences.dateFormat.concat(' HH:mm:ss')}}") + unless inbox + span + a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} + a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group, message)') +1 + |     + a(ng-click="quickReply(message.uuid)" ng-if=":: message.uuid != 'system'") + span.glyphicon.glyphicon-envelope(tooltip=env.t('sendPM')) + if inbox + a(ng-click="quickReply(message.uuid)") + span.glyphicon.glyphicon-share-alt(tooltip=env.t('pm-reply')) + |     + a(ng-click='#{inbox? "user.ops.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}', ng-if='#{inbox ? "true" : ":: user.contributor.admin || message.uuid == user.id"}') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + |     + a(ng-click="flagChatMessage(group._id, message)", ng-if=':: user.contributor.admin || (!message.sent && user.flags.communityGuidelinesAccepted && message.uuid != user.id && message.uuid != "system")') + span.glyphicon.glyphicon-flag(tooltip="{{message.flags[user._id] ? env.t('abuseAlreadyReported') : env.t('abuseFlag')}}" ng-class='message.flags[user._id] ? "text-danger" : ""') + span.float-label(ng-class='::contribText(message.contributor, message.backer).length > 30 ? "long-title" : ""') + a.label.label-default.chat-message(ng-if=':: message.user', ng-class='::userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + |   + span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}}  + span(ng-class='::userAdminGlyphiconStyleFromLevel(message.contributor.level)') diff --git a/website/views/options/social/create-group.jade b/website/views/options/social/create-group.jade new file mode 100644 index 0000000000..6c0c4c20e8 --- /dev/null +++ b/website/views/options/social/create-group.jade @@ -0,0 +1,28 @@ +form.col-md-12.form-horizontal(ng-submit='create(newGroup)') + .form-group + label.control-label(for='new-group-name')=env.t('newGroupName', {groupType: "{{text}}"}) + input.form-control#new-group-name.input-medium.option-content(required, type='text', placeholder=env.t('newGroupName', {groupType: "{{text}}"}), ng-model='newGroup.name') + .form-group + label(for='new-group-description')=env.t('description') + textarea.form-control#new-group-description.option-content(cols='3', placeholder=env.t('description'), ng-model='newGroup.description') + .form-group(ng-show='type=="guild"') + .radio + label + input(type='radio', name='new-group-privacy', value='public', ng-model='newGroup.privacy') + =env.t('public') + .radio + label + input(type='radio', name='new-group-privacy', value='private', ng-model='newGroup.privacy') + =env.t('inviteOnly') + br + input.btn.btn-default(type='submit', ng-disabled='!newGroup.privacy && !newGroup.name', value=env.t('create')) + span.gem-cost= '4 ' + env.t('gems') + p + small=env.t('gemCost') + .form-group + .checkbox + label + input(type='checkbox', ng-model='newGroup.leaderOnly.challenges') + | Only group leader can create challenges + .form-group(ng-show='type=="party"') + input.btn.btn-default.form-control(type='submit', value=env.t('create')) diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade new file mode 100644 index 0000000000..8c27edfa80 --- /dev/null +++ b/website/views/options/social/group.jade @@ -0,0 +1,124 @@ +a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter', popover-title=env.t('guildBankPop1'), popover=env.t('guildBankPop2'), popover-placement='left') + // + span.task-action-btn.tile.flush.neutral + .Pet_Currency_Gem2x.Gems + | {{group.balance * 4 | number:0 }} + =' ' + env.t('guildGems') + +.container-fluid + .row + .col-md-4 + + // ------ Bosses ------- + +boss(false, false) + + // ------ Information ------- + .panel.panel-default + .panel-heading(bindonce='group') + h3.panel-title + | {{group.name}} + span(ng-if='group') + a.btn.btn-success.pull-right(ng-if=':: !isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join') + span(ng-if='group.leader == user.id') + button.btn.btn-primary.pull-right(ng-click='save(group)', ng-show='group._editing')=env.t('save') + button.btn.btn-default.pull-right(ng-click='group._editing = true', ng-hide='group._editing')=env.t('editGroup') + + .panel-body + form(ng-show='group._editing') + .form-group + label=env.t('groupName') + input.form-control(type='text', ng-model='group.name', placeholder=env.t('groupName')) + .form-group + label=env.t('description') + textarea.form-control(rows=6, ng-model='group.description') + include ../../shared/formatting-help + .form-group + label=env.t('logoUrl') + input.form-control(type='url', placeholder=env.t('logoUrl'), ng-model='group.logo') + .form-group + .checkbox + label + input(type='checkbox', ng-model='group.leaderOnly.challenges') + | Only group leader can create challenges + + h4=env.t('assignLeader') + select#group-leader-selection(ng-model='group._newLeader', ng-options='member.profile.name for member in group.members') + + div(ng-show='!group._editing') + img.pull-right(ng-show='group.logo', ng-src='{{group.logo}}') + markdown(text='group.description') + hr + small.muted Group ID: {{group._id}} + + include ./challenge-box + + // ------ Members ------- + .panel.panel-default + .panel-heading + h3.panel-title + =env.t('members') + button.pull-right.btn.btn-primary(ng-click="openInviteModal(group)") Invite Friends + .panel-body.modal-fixed-height + div.form-group(ng-if='::group.type=="party"') + p=env.t('partyList') + select.form-control#partyOrder( + ng-model='user.party.order', + ng-controller='ChatCtrl', + ng-options='k as v for (k , v) in partyOrderChoices', + ng-change='set({"party.order": user.party.order})' + ) + |  + select.form-control#partyOrderAscending( + ng-model='user.party.orderAscending', + ng-controller='ChatCtrl', + ng-options='k as v for (k , v) in partyOrderAscendingChoices', + ng-change='set({"party.orderAscending": user.party.orderAscending})' + ) + table.table.table-striped(bindonce='group') + tr(ng-repeat='member in group.members track by member._id') + td.media + // allow leaders to ban members + div.pull-left(ng-show='group.leader == user.id && user.id!=member._id') + a.media-object(ng-click='removeMember(group, member, true)') + span.glyphicon.glyphicon-ban-circle(tooltip=env.t('banTip')) + a.media-body + span(ng-class='{"badge badge-info": group.leader==member._id}', ng-click='clickMember(member._id, true)') + | {{member.profile.name}} + tr(ng-if='group.memberCount > group.members.length') + td + |{{group.memberCount - group.members.length}} + = ' ' + env.t('moreMembers') + | + + h4(ng-show='group.invites.length > 0')=env.t('invited') + table.table.table-striped + tr(ng-repeat='invite in group.invites') + td.media + // allow leaders to ban members + div.pull-left(ng-show='group.leader == user.id') + a.media-object(ng-click='removeMember(group, invite, false)') + span.glyphicon.glyphicon-ban-circle(tooltip=env.t('banTip')) + a.media-body + span(ng-click='clickMember(invite._id, true)') + | {{invite.profile.name}} + + div(ng-if="group") + a.btn.btn-danger(ng-if=':: isMemberOfGroup(User.user._id, group)', data-id='{{group.id}}', ng-click='clickLeave(group, $event)')=env.t('leave') + a.btn.btn-success(ng-if=':: !isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join') + + .col-md-8 + div() + textarea.form-control(ng-show='group._editing', rows=6, placeholder=env.t('leaderMsg'), ng-model='group.leaderMessage') + table(ng-show='group.leaderMessage') + tr + td + .popover.static-popover.fade.right.in.wide-popover + .arrow + h3.popover-title {{Members.members[group.leader].profile.name}} + .popover-content + markdown(text='group.leaderMessage') + div(ng-controller='ChatCtrl') + h3=env.t('chat') + include ./chat-box + + +chatMessages() diff --git a/website/views/options/social/hall.jade b/website/views/options/social/hall.jade new file mode 100644 index 0000000000..26d95ed179 --- /dev/null +++ b/website/views/options/social/hall.jade @@ -0,0 +1,110 @@ +script(type='text/ng-template', id='partials/options.social.hall.html') + ul.options-submenu + li(ng-class="{ active: $state.includes('options.social.hall.heroes') }") + a(ui-sref='options.social.hall.heroes') + =env.t('hallHeroes') + li(ng-class="{ active: $state.includes('options.social.hall.patrons') }") + a(ui-sref='options.social.hall.patrons') + =env.t('hallPatrons') + + .tab-content + .tab-pane.active + div(ui-view) + +script(type='text/ng-template', id='partials/options.social.hall.heroes.html') + .well(ng-if='user.contributor.admin') + h2=env.t('rewardUser') + form(ng-submit='loadHero(_heroID)') + .form-group + input.form-control(type='text', ng-model='_heroID', placeholder=env.t('UUID')) + .form-group + input.btn.btn-default(type='submit')=env.t('loadUser') + form(ng-show='hero', ng-submit='saveHero(hero)') + a(ng-click='clickMember(hero._id, true)') + h3 {{hero.profile.name}} + .form-group + input.form-control(type='text', ng-model='hero.contributor.text', placeholder=env.t('contribTitle')) + .form-group + label=env.t('contribLevel') + input.form-control(type='number', ng-model='hero.contributor.level') + small=env.t('contribHallText') + |  + a(target='_blank', href='https://trello.com/c/wkFzONhE/277-contributor-gear')=env.t('moreDetails') + |,  + a(target='_blank', href='https://github.com/HabitRPG/habitrpg/issues/3801')=env.t('moreDetails2') + .form-group + textarea.form-control(cols=5, placeholder=env.t('contributions'), ng-model='hero.contributor.contributions') + include ../../shared/formatting-help + hr + + .form-group + label=env.t('balance') + input.form-control(type='number', step="any", ng-model='hero.balance') + small!= '`user.balance`' + env.t('notGems') + .form-group + .checkbox + label + input(type='checkbox', ng-model='hero.purchased.ads') + =env.t('hideAds') + accordion + accordion-group(heading='Items') + h4 Update Item + .form-group.well + input.form-control(type='text',placeholder='Path (eg, items.pets.BearCub-Base)',ng-model='hero.itemPath') + small.muted Enter the item path. E.g., items.pets.BearCub-Zombie or items.gear.owned.head_special_0 or items.gear.equipped.head. See all paths here. When in doubt, ask Tyler. + br + input.form-control(type='text',placeholder='Value (eg, 5)',ng-model='hero.itemVal') + small.muted Enter the item value. E.g., 5 or false or head_warrior_3 (respectively from above examples). + h4 Current Items + pre {{::toJson(hero.items,true)}} + accordion-group(heading='Auth') + h4 Auth + pre {{::toJson(hero.auth)}} + .form-group + .checkbox + label + input(type='checkbox', ng-model='hero.auth.blocked') + | Blocked + + // h4 Backer Status + // Add backer stuff like tier, disable adds, etcs + .form-group + input.form-control.btn.btn-primary(type='submit')=env.t('save') + + + table.table.table-striped + thead + tr + th=env.t('name') + th(ng-if='user.contributor.admin')=env.t('UUID') + th=env.t('contribLevel') + th=env.t('title') + th=env.t('contributions') + tbody + tr(ng-repeat='hero in heroes') + td + span(ng-if='hero.contributor.admin',popover=env.t('gamemaster'),popover-trigger='mouseenter',popover-placement='right') + a.label.label-default(ng-class='userLevelStyle(hero)', ng-click='clickMember(hero._id, true)') + | {{hero.profile.name}}  + span(ng-class='userAdminGlyphiconStyle(hero)') + span(ng-if='!hero.contributor.admin') + a.label.label-default(ng-class='userLevelStyle(hero)', ng-click='clickMember(hero._id, true)') {{hero.profile.name}} + td(ng-if='user.contributor.admin') {{hero._id}} + td {{hero.contributor.level}} + td {{hero.contributor.text}} + td + markdown(text='hero.contributor.contributions', target='_blank') + +script(type='text/ng-template', id='partials/options.social.hall.patrons.html') + table.table.table-striped(infinite-scroll="loadMore()") + thead + tr + th=env.t('name') + th(ng-if='user.contributor.admin')=env.t('UUID') + th=env.t('backerTier') + tbody + tr(ng-repeat='patron in patrons') + td + a.label.label-default(ng-class='userLevelStyle(patron)', ng-click='clickMember(patron._id, true)') {{patron.profile.name}} + td(ng-if='user.contributor.admin') {{patron._id}} + td {{patron.backer.tier}} diff --git a/website/views/options/social/index.jade b/website/views/options/social/index.jade new file mode 100644 index 0000000000..ead6e5965b --- /dev/null +++ b/website/views/options/social/index.jade @@ -0,0 +1,114 @@ +// FIXME note, due to https://github.com/angular-ui/bootstrap/issues/783 we can't use nested angular-bootstrap tabs +// Subscribe to that ticket & change this when they fix + +include ./challenges +include ./hall +include ./boss +include ./chat-message + +script(type='text/ng-template', id='partials/options.social.inbox.html') + .container-fluid + .row + .col-md-12 + +chatMessages('inbox') + .form-inline + a.btn.btn-xs.btn-danger(popover=env.t('clearAllPopover'), popover-trigger='mouseenter', ng-click='user.ops.clearPMs({})', popover-placement='right')=env.t('clearAll') + .checkbox + label + input(type='checkbox', ng-model='user.inbox.optOut', ng-change='set({"inbox.optOut": user.inbox.optOut?true: false})') + |   + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('optOutPopover'))=env.t('optOut') + +script(type='text/ng-template', id='partials/options.social.tavern.html') + include ./tavern + +script(type='text/ng-template', id='partials/options.social.party.html') + div(ng-if='group._id') + include ./group + div(ng-if='!group._id') + div(ng-show='user.invitations.party.id').container-fluid + h2=env.t('invitedTo', {name: '{{user.invitations.party.name}}'}) + a.btn.btn-success(data-type='party', ng-click='join(user.invitations.party)')=env.t('accept') + a.btn.btn-danger(ng-click='reject()')=env.t('reject') + div(ng-hide='user.invitations.party.id').container-fluid + h2=env.t('createAParty') + p + =env.t('noPartyText') + pre.prettyprint. + {{user.id}} + p!=env.t('LFG', {linkStart: "", linkEnd: ""}) + include ./create-group + +script(type='text/ng-template', id='partials/options.social.guilds.public.html') + div.col-xs-12(ng-repeat='invitation in user.invitations.guilds' style='margin-bottom: 1.5em') + h3=env.t('invitedTo', {name: '{{invitation.name}}'}) + a.btn.btn-success(data-type='guild', ng-click='join(invitation)')=env.t('accept') + a.btn.btn-danger(ng-click='reject(invitation)')=env.t('reject') + // Public Groups + div.container-fluid(style='margin-bottom: 1.5em') + input.form-control(type='text',ng-model='guildSearch', placeholder=env.t('search')) + table.table.table-striped(style='clear:left;') + tr(ng-repeat='group in groups.public | filter:guildSearch track by group._id') + td + ul.pull-right.challenge-accordion-header-specs + li='{{::group.memberCount}} ' + env.t('members') + // join / leave + li.bg-transparent + a.btn.btn-sm.btn-danger(ng-if="::group._isMember", ng-click='clickLeave(group, $event)') + span.glyphicon.glyphicon-ban-circle + =env.t('leave') + a.btn.btn-sm.btn-success(ng-if="::!group._isMember", ng-click='join(group)') + span.glyphicon.glyphicon-ok + =env.t('join') + h4 {{::group.name}} + p {{::group.description}} + +script(type='text/ng-template', id='partials/options.social.guilds.detail.html') + include ./group + +script(type='text/ng-template', id='partials/options.social.guilds.create.html') + div.col-xs-12 + include ./create-group + +script(type='text/ng-template', id='partials/options.social.guilds.html') + ul.options-submenu + li(ng-class="{ active: $state.includes('options.social.guilds.public') }") + a(ui-sref='options.social.guilds.public') + =env.t('publicGuilds') + li(ng-class="{ active: $stateParams.gid == group._id }", ng-repeat='group in groups.guilds track by group._id') + a(ui-sref="options.social.guilds.detail({gid:group._id})") + | {{group.name}} + li(ng-class="{ active: $state.includes('options.social.guilds.create') }") + a(ui-sref='options.social.guilds.create') + =env.t('createGuild') + + .tab-content + .tab-pane.active + div(ui-view) + +script(type='text/ng-template', id='partials/options.social.html') + ul.options-menu + li(ng-class="{ active: $state.includes('options.social.inbox') }") + a(ui-sref='options.social.inbox') + =env.t("inbox") + |   + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} + li(ng-class="{ active: $state.includes('options.social.tavern') }") + a(ui-sref='options.social.tavern') + =env.t('tavern') + li(ng-class="{ active: $state.includes('options.social.party') }") + a(ui-sref='options.social.party') + =env.t('party') + li(ng-class="{ active: $state.includes('options.social.guilds') }") + a(ui-sref='options.social.guilds.public') + =env.t('guilds') + li(ng-class="{ active: $state.includes('options.social.challenges') }") + a(ui-sref='options.social.challenges') + =env.t('challenges') + li(ng-class="{ active: $state.includes('options.social.hall') }") + a(ui-sref='options.social.hall.heroes') + =env.t('hall') + + .tab-content + .tab-pane.active + div(ui-view) diff --git a/website/views/options/social/tavern.jade b/website/views/options/social/tavern.jade new file mode 100644 index 0000000000..b77e66d060 --- /dev/null +++ b/website/views/options/social/tavern.jade @@ -0,0 +1,163 @@ +.container-fluid + .row + .col-md-4 + + +boss(true, false) + + table + tr + td + div(class="#{env.worldDmg.tavern ? 'npc_daniel_broken' : 'npc_daniel'}") + td + .popover.static-popover.fade.right.in + .arrow + h3.popover-title + a(target='_blank', href='http://www.kickstarter.com/profile/2014640723')=env.t('daniel') + .popover-content + =env.t('danielText') + div + button.btn.btn-lg.btn-success(ng-class='{active: user.preferences.sleep}', ng-click='User.user.ops.sleep({})') + span(ng-show='user.preferences.sleep')=env.t('innCheckOut') + span(ng-hide='user.preferences.sleep')=env.t('innCheckIn') + =env.t('danielText2') + .alert.alert-info(ng-show='user.preferences.sleep') + =env.t('innText',{name:"{{user.profile.name}}"}) + + // Resources + .panel.panel-default + .panel-heading + h3.panel-title=env.t('resources') + .panel-body + table.table.table-striped + tr + td + a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') + tr + td + a(href='https://habitrpg.com/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601')=env.t('lfgPosts') + tr + td + a(target='_blank', href='https://vimeo.com/57654086')=env.t('tutorial') + tr + td + a(target='_blank', href='http://habitrpg.wikia.com/wiki/FAQ')=env.t('FAQ') + tr + td + a(target='_blank', href='http://habitrpg.wikia.com/wiki/Glossary')=env.t('glossary') + tr + td + a(target='_blank', href='http://habitrpg.wikia.com/')=env.t('wiki') + tr + td + a(target='_blank', href='https://oldgods.net/habitrpg/habitrpg_user_data_display.html')=env.t('dataTool') + tr + td + a(target='_blank', href='https://github.com/HabitRPG/habitrpg/issues/2760')=env.t('reportAP') + tr + td + a(target='_blank', href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')= ' ' + env.t('requestAF') + tr + td + a(target='_blank', href='http://habitrpg.wikia.com/wiki/Special:Forum')=env.t('community') + + // Player Tiers + .panel.panel-default(popover=env.t('tierPop'), popover-trigger="mouseenter", popover-placement="right") + .panel-heading + h3.panel-title=env.t('playerTiers') + .panel-body + small + | + =env.t('visitHeroes') + |
+ | + =env.t('conLearn') + |
+ | + =env.t('conLearnHow') + | + table.table.table-striped.panel-tiers + tr + td + a.label.label-contributor-1(ng-click='toggleUserTier($event)')=env.t('tier') + ' 1 (' + env.t('friend') + ')' + div + p + span.achievement.achievement-firefox + !=env.t('friendFirst') + tr + td + a.label.label-contributor-2(ng-click='toggleUserTier($event)')=env.t('tier') + ' 2 (' + env.t('friend') + ')' + div + p + span.shop-sprite.item-img(class='shop_armor_special_1') + !=env.t('friendSecond') + tr + td + a.label.label-contributor-3(ng-click='toggleUserTier($event)')=env.t('tier') + ' 3 (' + env.t('elite') + ')' + div + p + span.shop-sprite.item-img(class='shop_head_special_1') + !=env.t('eliteThird') + tr + td + a.label.label-contributor-4(ng-click='toggleUserTier($event)')=env.t('tier') + ' 4 (' + env.t('elite') + ')' + div + p + span.shop-sprite.item-img(class='shop_weapon_special_1') + !=env.t('eliteFourth') + tr + td + a.label.label-contributor-5(ng-click='toggleUserTier($event)')=env.t('tier') + ' 5 (' + env.t('champion') + ')' + div + p + span.shop-sprite.item-img(class='shop_shield_special_1') + !=env.t('championFifth') + tr + td + a.label.label-contributor-6(ng-click='toggleUserTier($event)')=env.t('tier') + ' 6 (' + env.t('champion') + ')' + div + p + div(class='Pet-Dragon-Hydra pull-left') + !=env.t('championSixth') + tr + td + a.label.label-contributor-7(ng-click='toggleUserTier($event)')=env.t('tier') + ' 7 (' + env.t('legendary') + ')' + div + p + !=env.t('legSeventh') + tr + td + a.label.label-contributor-8(ng-click='toggleUserTier($event)')=env.t('moderator') + ' (' + env.t('guardian') + ')' + div + p=env.t('guardianText') + tr + td + a.label.label-contributor-9(ng-click='toggleUserTier($event)')=env.t('staff') + ' (' + env.t('heroic') + ')' + div + p=env.t('heroicText') + tr + td + a.label.label-npc(ng-click='toggleUserTier($event)')=env.t('npc') + div + p=env.t('npcText') + + include ./challenge-box + + .col-md-8.tavern(ng-controller='ChatCtrl') + h3=env.t('tavernTalk') + include ./chat-box + .alert.alert-info.alert-sm + != ' ' + env.t('tavernAlert1') + ' ' + env.t('tavernAlert2') + '.
' + env.t('moderatorIntro1') + span(ng-repeat='mod in env.mods') + |    + span(ng-if='::mod.contributor.admin',popover=env.t('gamemaster'),popover-trigger='mouseenter',popover-placement='right') + a.label.label-default(ng-class='::userLevelStyle(mod)', ng-click='clickMember(mod._id, true)') + | {{::mod.profile.name}}  + span(ng-class='::userAdminGlyphiconStyle(mod)') + p + =env.t('communityGuidelinesRead1') + |   + a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') + |   + =env.t('communityGuidelinesRead2') + + +chatMessages() diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade new file mode 100644 index 0000000000..746d4d1958 --- /dev/null +++ b/website/views/shared/footer.jade @@ -0,0 +1,92 @@ +footer.footer(ng-controller='FooterCtrl') + .container + .row + .col-sm-3 + h4=env.t('footerMobile') + ul.list-unstyled + li + a(href='https://itunes.apple.com/us/app/habitrpg/id689569235?mt=8', target='_blank')=env.t('mobileIOS') + li + a(href='https://play.google.com/store/apps/details?id=com.ocdevel.habitrpg', target='_blank')=env.t('mobileAndroid') + if env.isStaticPage + h4=env.t('language') + select(ng-change='changeLang()', ng-model='selectedLanguage', ng-options='language.name for language in languages') + + .col-sm-3 + h4=env.t('footerCompany') + ul.list-unstyled + if (!env.isStaticPage) + li + .btn.btn-sm.btn-success(ng-click='openModal("buyGems",{track:"Gems > Donate"})') + span.glyphicon.glyphicon-heart + |  + =env.t('companyDonate') + li + a(href='/static/features')=env.t('companyAbout') + li + a(target='_blank', href='http://blog.habitrpg.com/')=env.t('companyBlog') + li + a(href='http://habitrpg.wikia.com/wiki/App_and_Extension_Integrations', target='_blank')=env.t('companyExtensions') + li + a(target='_blank', href='http://habitrpg.wikia.com/wiki/FAQ')=env.t('FAQ') + li + a(href='/static/privacy')=env.t('companyPrivacy') + li + a(href='/static/terms')=env.t('companyTerms') + li + a(href='/static/contact')=env.t('contactUs') + .col-sm-3 + h4=env.t('footerCommunity') + ul.list-unstyled + li + a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') + li + a(target='_blank', href='https://github.com/HabitRPG/habitrpg/issues/2760')=env.t('communityBug') + li + a(target='_blank', href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')=env.t('communityFeature') + li + a(target='_blank', href='https://habitrpg.com/static/api')=env.t('API') + li + a(href='http://habitrpg.wikia.com/wiki/App_and_Extension_Integrations', target='_blank')=env.t('communityExtensions') + li + a(target='_blank', href='http://habitrpg.wikia.com/wiki/Special:Forum')=env.t('communityForum') + li + a(target='_blank', href='http://www.kickstarter.com/projects/lefnire/habitrpg-mobile')=env.t('communityKickstarter') + li + a(target='_blank', href='https://www.facebook.com/Habitrpg')=env.t('communityFacebook') + li + a(target='_blank', href='http://www.reddit.com/r/habitrpg/')=env.t('communityReddit') + .col-sm-3 + if (env.NODE_ENV == 'production' && !env.IS_MOBILE) + h4=env.t('footerSocial') + .addthis_toolbox.addthis_default_style(addthis:url='https://habitrpg.com', addthis:title=env.t('socialTitle')) + table + tr + td + a.addthis_button_facebook_like(fb:like:layout='button_count') + tr + td + a.addthis_button_tweet(tw:via='habitrpg') + tr + td + iframe(src='/bower_components/github-buttons/github-btn.html?user=lefnire&repo=habitrpg&type=watch&count=true', allowtransparency='true', frameborder='0', scrolling='0', width='85px', height='20px') + tr + td + a.addthis_button_google_plusone(g:plusone:size='medium') + else if (env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage + h4 Debug + .btn-group-vertical + a.btn.btn-default(ng-click='setHealthLow()') Health = 1 + a.btn.btn-default(ng-click='addMissedDay()') +1 Missed Day + a.btn.btn-default(ng-click='addTenGems()') +10 Gems + a.btn.btn-default(ng-click='addGold()') +GP + a.btn.btn-default(ng-click='addLevelsAndGold()') +Exp +GP +MP + a.btn.btn-default(ng-click='addOneLevel()') +1 Level + a.btn.btn-default(ng-click='addBossQuestProgressUp()') +1000 Boss Quest Progress Up + + div(ng-init='deferredScripts()') + + // Load audio last + audio#sound(autoplay) + source#oggSource(type="audio/ogg") + source#mp3Source(type="audio/mp3") diff --git a/website/views/shared/formatting-help.jade b/website/views/shared/formatting-help.jade new file mode 100644 index 0000000000..80ef0633df --- /dev/null +++ b/website/views/shared/formatting-help.jade @@ -0,0 +1,2 @@ +small + a(target='_blank', href='http://habitrpg.wikia.com/wiki/Markdown_Cheat_Sheet')=env.t('formattingMarkdown') \ No newline at end of file diff --git a/website/views/shared/header/avatar.jade b/website/views/shared/header/avatar.jade new file mode 100644 index 0000000000..75692866ff --- /dev/null +++ b/website/views/shared/header/avatar.jade @@ -0,0 +1,93 @@ +//- FIXME the commented-out figure.herobox used to have a functioning popover, but angular-ui-bootstrap doesn't support + html in popovers. Figure out what to do here. Also, {isUser:..} class seems to bring the user too far down with mounts. + Removing it will remove the user's name, but will allow more room for mounts & helms. IMO this is the lesser of two evils, revisit +//-figure.herobox(ng-click='clickMember(profile._id)', data-name='{{profile.profile.name}}', ng-class='{isUser: profile.id==user.id, hasPet: profile.items.currentPet}', data-level='{{profile.stats.lvl}}', data-uid='{{profile.id}}', rel='popover', data-placement='bottom', data-trigger='hover', data-html='true', data-content="

Level: {{profile.stats.lvl}}
GP: {{profile.stats.gp | number:0}}
{{count(profile.items.pets)}} / 90 Pets Found
") + +mixin avatar(opts) + + .character-sprites + + .addthis_native_toolbox(ng-if='profile._id==user._id', data-url="#{env.BASE_URL}/static/front/#?memberId={{profile._id}}", data-title="Check out my HabitRPG progress!") + + // Mount Body + if !opts.minimal + span(ng-if='profile.items.currentMount', class='Mount_Body_{{profile.items.currentMount}}') + + span(ng-if='profile.stats.buffs.snowball') + span.snowman + span(ng-if='profile.stats.buffs.spookDust') + span.spookman + span(ng-if='!profile.stats.buffs.snowball && !profile.stats.buffs.spookDust') + // Back Accessory + span(class='{{profile.items.gear.equipped.back}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.back}}', ng-if='profile.preferences.costume') + + // Avatar + span(class='skin_{{profile.preferences.skin}}', ng-if='!profile.preferences.sleep') + span(class='skin_{{profile.preferences.skin}}_sleep', ng-if='profile.preferences.sleep') + + // Shirt + span(class='{{profile.preferences.size}}_shirt_{{profile.preferences.shirt}}') + + // Armor + span(class='{{profile.preferences.size}}_{{profile.items.gear.equipped.armor}}', ng-if='!profile.preferences.costume') + span(class='{{profile.preferences.size}}_{{profile.items.gear.costume.armor}}', ng-if='profile.preferences.costume') + + //- Cape collar + span(class='{{profile.items.gear.equipped.back}}_collar', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.back}}_collar', ng-if='profile.preferences.costume') + + // Body + span(class='{{profile.items.gear.equipped.body}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.body}}', ng-if='profile.preferences.costume') + + // Hair + span(class='head_0') + span(class='hair_base_{{profile.preferences.hair.base}}_{{profile.preferences.hair.color}}') + span(class='hair_bangs_{{profile.preferences.hair.bangs}}_{{profile.preferences.hair.color}}') + span(class='hair_mustache_{{profile.preferences.hair.mustache}}_{{profile.preferences.hair.color}}') + span(class='hair_beard_{{profile.preferences.hair.beard}}_{{profile.preferences.hair.color}}') + + // Eyewear + span(class='{{profile.items.gear.equipped.eyewear}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.eyewear}}', ng-if='profile.preferences.costume') + + // Helm + span(class='{{profile.items.gear.equipped.head}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.head}}', ng-if='profile.preferences.costume') + + // Head Accessory + span(class='{{profile.items.gear.equipped.headAccessory}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.headAccessory}}', ng-if='profile.preferences.costume') + + // Flower + span(class='hair_flower_{{profile.preferences.hair.flower}}') + + // Shield + span(class='{{profile.items.gear.equipped.shield}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.shield}}', ng-if='profile.preferences.costume') + + // Weapon + span(class='{{profile.items.gear.equipped.weapon}}', ng-if='!profile.preferences.costume') + span(class='{{profile.items.gear.costume.weapon}}', ng-if='profile.preferences.costume') + + + // Mount Head + if !opts.minimal + span.current-mount(ng-if='profile.items.currentMount', class='Mount_Head_{{profile.items.currentMount}}') + + // Resting + span(ng-class='{zzz:profile.preferences.sleep}') + if !opts.minimal + span.current-pet(class='Pet-{{profile.items.currentPet}}', ng-show='profile.items.currentPet') + +mixin herobox(opts) + - if (!opts) {opts = {minimal:false,main:false}} + figure.herobox(ng-click='spell ? castEnd(profile, "user", $event) : clickMember(profile._id)', data-name='{{profile.profile.name}}', class='background_{{profile.preferences.background}} #{opts.main ? "isUser" : ""} #{opts.minimal ? "minimal" : ""}', ng-class='{hasPet: (#{!opts.minimal} && profile.items.currentPet), hasMount: (#{!opts.minimal} && profile.items.currentMount), noBackgroundImage: !profile.preferences.background, "cast-target": applyingAction, isLeader: party.leader==profile._id}') + .avatar-name(ng-class='userLevelStyle(profile)') + |{{profile.profile.name}} + +avatar(opts) + .avatar-level(ng-class='userLevelStyle(profile)') + span.glyphicon.glyphicon-circle-arrow-up(ng-show='profile.stats.buffs.str || profile.stats.buffs.per || profile.stats.buffs.con || profile.stats.buffs.int || profile.stats.buffs.stealth', tooltip=env.t('buffed'), tooltip-append-to-body="true") + span(tooltip=env.t('level'), tooltip-append-to-body="true") {{profile.stats.lvl}} + span.glyphicon.glyphicon-plus-sign(ng-show='profile.achievements.rebirths', tooltip=env.t('reborn', {reLevel: "{{profile.achievements.rebirthLevel}}"}), tooltip-append-to-body="true") diff --git a/website/views/shared/header/header.jade b/website/views/shared/header/header.jade new file mode 100644 index 0000000000..001081b729 --- /dev/null +++ b/website/views/shared/header/header.jade @@ -0,0 +1,36 @@ +.header-wrap(ng-controller='HeaderCtrl') + a.label.label-default.undo-button(x-bind='click:undo', ng-show='_undo')=env.t('undo') + + header.site-header(ng-hide='user.preferences.hideHeader', role='banner', data-partysize='{{party.members.length>1 ? truarr(party.members.length) : 0}}') + // avatar + .herobox-wrap.main-herobox(ng-controller='UserCtrl') + +herobox({main:1}) + // stat bars + .hero-stats + .meter-label(tooltip=env.t('health')) + span.glyphicon.glyphicon-heart + .meter.health(tooltip='{{Math.round(user.stats.hp * 100) / 100}}') + .bar(style='width: {{Shared.percent(user.stats.hp, 50)}}%;') + span.meter-text.value + | {{Math.ceil(user.stats.hp)}} / 50 + .meter-label(tooltip=env.t('experience')) + span.glyphicon.glyphicon-star + .meter.experience(tooltip='{{Math.round(user.stats.exp * 100) / 100}}') + .bar(style='width: {{Shared.percent(user.stats.exp,Shared.tnl(user.stats.lvl))}}%;') + span.meter-text.value + span(ng-show='user.history.exp', tooltip=env.t('progress')) + a(ng-click='toggleChart("exp")').glyphicon.glyphicon-signal + span + | {{Math.floor(user.stats.exp) | number:0}} / {{Shared.tnl(user.stats.lvl) | number:0}} + .meter-label(tooltip='Mana', ng-if='user.flags.classSelected && !user.preferences.disableClasses') + span.glyphicon.glyphicon-fire + .meter.mana(ng-if='user.flags.classSelected && !user.preferences.disableClasses', tooltip='{{Math.round(user.stats.mp * 100) / 100}}') + .bar(style='width: {{user.stats.mp / user._statsComputed.maxMP * 100}}%;') + span.meter-text.value + span + | {{Math.floor(user.stats.mp)}} / {{user._statsComputed.maxMP}} + + // party + span(ng-controller='PartyCtrl') + .herobox-wrap(ng-repeat='profile in partyMinusSelf') + +herobox() diff --git a/website/views/shared/header/menu.jade b/website/views/shared/header/menu.jade new file mode 100644 index 0000000000..cd7a08b00f --- /dev/null +++ b/website/views/shared/header/menu.jade @@ -0,0 +1,274 @@ +nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') + button.toolbar-toggle(ng-click='isToolbarHidden = !isToolbarHidden', ng-class='{active: isToolbarHidden}') + span.glyphicon.glyphicon-remove-circle + span.toggle-text.toggle-close=env.t('close') + span.toggle-text.toggle-open=env.t('menu') + .toolbar-container(ng-if='!isToolbarHidden') + ul.toolbar-mobile-nav + li.toolbar-mobile + a(ng-click='expandMenu("mobile")', ng-class='{active: _expandedMenu=="mobile"}') + span ☰ + div(ng-if='_expandedMenu=="mobile"', ng-click='expandMenu(null)') + h4=env.t('menu') + div + ul.toolbar-submenu + li + a(ui-sref='tasks')=env.t('tasks') + ul.toolbar-submenu + li + a(ui-sref='options.profile.avatar')=env.t('avatar') + li + a(ui-sref='options.profile.backgrounds')=env.t('backgrounds') + li + a(ui-sref='options.profile.stats')=env.t('stats') + li + a(ui-sref='options.profile.profile')=env.t('profile') + ul.toolbar-submenu + li + a(ui-sref='options.social.inbox') + =env.t("inbox") + |   + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} + li + a(ui-sref='options.social.tavern')=env.t('tavern') + li + a(ui-sref='options.social.party')=env.t('party') + li + a(ui-sref='options.social.guilds.public')=env.t('guilds') + li + a(ui-sref='options.social.challenges')=env.t('challenges') + li + a(ui-sref='options.social.hall.heroes')=env.t('hall') + ul.toolbar-submenu + li + a(ui-sref='options.inventory.drops')=env.t('market') + li + a(ui-sref='options.inventory.pets')=env.t('pets') + li + a(ui-sref='options.inventory.mounts')=env.t('mounts') + li + a(ui-sref='options.inventory.equipment')=env.t('equipment') + li + a(ui-sref='options.inventory.timetravelers')=env.t('timeTravelers') + li + a(ui-sref='options.inventory.seasonalshop')=env.t('seasonalShop') + ul.toolbar-submenu + li + a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') + li + a(ui-sref='options.settings.export')=env.t('exportData') + ul.toolbar-submenu + li + a(target="_blank" href='http://habitrpg.wikia.com/wiki/FAQ')=env.t('FAQ') + li + a(target="_blank" href='https://github.com/HabitRPG/habitrpg/issues/2760')=env.t('reportBug') + li + a(target="_blank" href='https://habitrpg.com/#/options/groups/guilds/5481ccf3-5d2d-48a9-a871-70a7380cee5a')=env.t('askQuestion') + li + a(target="_blank" href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')=env.t('requestAF') + li + a(target="_blank" href='http://habitrpg.wikia.com/wiki/Contributing_to_HabitRPG')=env.t('contributeToHRPG') + li + a(target="_blank" href='http://habitrpg.wikia.com/wiki/')=env.t('overview') + ul.toolbar-controls + li.toolbar-subscribe-button + button(ng-if='!user.purchased.plan.customerId',ui-sref='options.settings.subscription',popover-trigger='mouseenter',popover-placement='bottom',popover-title=env.t('subscriptions'),popover=env.t('subDescription'),popover-append-to-body='true')=env.t('subscribe') + li.toolbar-controls-button + a(ng-click='expandMenu(null)')=env.t('close') + ul.toolbar-nav + li.toolbar-button + a(ui-sref='tasks') + span=env.t('tasks') + li.toolbar-button-dropdown + a(ui-sref='options.profile.avatar') + span=env.t('user') + a(ng-click='expandMenu("avatar")', ng-class='{active: _expandedMenu == "avatar"}') + span ☰ + div(ng-if='_expandedMenu == "avatar"') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ui-sref='options.profile.avatar')=env.t('avatar') + li + a(ui-sref='options.profile.backgrounds')=env.t('backgrounds') + li + a(ui-sref='options.profile.stats')=env.t('stats') + li + a(ui-sref='options.profile.profile')=env.t('profile') + li.toolbar-button-dropdown + a(ui-sref='options.social.tavern') + span=env.t('social') + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} + a(ng-click='expandMenu("social")', ng-class='{active: _expandedMenu == "social"}') + span ☰ + div(ng-if='_expandedMenu == "social"') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ui-sref='options.social.inbox') + =env.t("inbox") + |   + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} + li + a(ui-sref='options.social.tavern')=env.t('tavern') + li + a(ui-sref='options.social.party')=env.t('party') + li + a(ui-sref='options.social.guilds.public')=env.t('guilds') + li + a(ui-sref='options.social.challenges')=env.t('challenges') + li + a(ui-sref='options.social.hall.heroes')=env.t('hall') + li.toolbar-button-dropdown + a(ui-sref='options.inventory.drops') + span=env.t('inventory') + a(ng-click='expandMenu("inventory")', ng-class='{active: _expandedMenu == "inventory"}') + span ☰ + div(ng-if='_expandedMenu == "inventory"') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ui-sref='options.inventory.drops')=env.t('market') + li + a(ui-sref='options.inventory.pets')=env.t('pets') + li + a(ui-sref='options.inventory.mounts')=env.t('mounts') + li + a(ui-sref='options.inventory.equipment')=env.t('equipment') + li + a(ui-sref='options.inventory.timetravelers')=env.t('timeTravelers') + li + a(ui-sref='options.inventory.seasonalshop')=env.t('seasonalShop') + li.toolbar-button-dropdown + a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}') + span=env.t('data') + a(ng-click='expandMenu("data")', ng-class='{active: _expandedMenu == "data"}') + span ☰ + div(ng-if='_expandedMenu == "data"') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') + li + a(ui-sref='options.settings.export')=env.t('exportData') + li.toolbar-button-dropdown + a(target="_blank" href='http://habitrpg.wikia.com/wiki/') + span=env.t('help') + a(ng-click='expandMenu("help")', ng-class='{active: _expandedMenu == "help"}') + span ☰ + div(ng-if='_expandedMenu == "help"') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(target="_blank" href='http://habitrpg.wikia.com/wiki/FAQ')=env.t('FAQ') + li + a(target="_blank" href='https://github.com/HabitRPG/habitrpg/issues/2760')=env.t('reportBug') + li + a(target="_blank" href='https://habitrpg.com/#/options/groups/guilds/5481ccf3-5d2d-48a9-a871-70a7380cee5a')=env.t('askQuestion') + li + a(target="_blank" href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')=env.t('requestAF') + li + a(target="_blank" href='http://habitrpg.wikia.com/wiki/Contributing_to_HabitRPG')=env.t('contributeToHRPG') + li + a(target="_blank" href='http://habitrpg.wikia.com/wiki/')=env.t('overview') + li(ng-controller='SettingsCtrl') + a(ng-click='showTour()', popover-placement='right', popover-trigger='mouseenter', popover=env.t('restartTour'))= env.t('showTour') + ul.toolbar-subscribe(ng-if='!user.purchased.plan.customerId') + li.toolbar-subscribe-button + button(ui-sref='options.settings.subscription',popover-trigger='mouseenter',popover-placement='bottom',popover-title=env.t('subscriptions'),popover=env.t('subDescription'),popover-append-to-body='true')=env.t('subscribe') + ul.toolbar-options + li.toolbar-notifs + a(ng-click='expandMenu("notifs")') + span.glyphicon(ng-class='iconClasses()') + div(ng-if='_expandedMenu=="notifs"') + h4=env.t('notifications') + div + ul.toolbar-notifs-notifs + li.toolbar-notifs-no-messages(ng-if='hasNoNotifications()')=env.t('noNotifications') + li(ng-if='user.purchased.plan.mysteryItems.length') + a(ng-click='$state.go("options.inventory.drops"); expandMenu(null)') + span.glyphicon.glyphicon-gift + span=env.t('newSubscriberItem') + li(ng-if='user.invitations.party.id') + a(ui-sref='options.social.party', ng-click='expandMenu(null)') + span.glyphicon.glyphicon-user + span=env.t('invitedTo', {name: '{{user.invitations.party.name}}'}) + li(ng-repeat='guild in user.invitations.guilds') + a(ui-sref='options.social.guilds', ng-click='expandMenu(null)') + span.glyphicon.glyphicon-user + span=env.t('invitedTo', {name: '{{guild.name}}'}) + li(ng-if='user.flags.classSelected && !user.preferences.disableClasses && user.stats.points') + a(ui-sref='options.profile.stats', ng-click='expandMenu(null)') + span.glyphicon.glyphicon-plus-sign + span=env.t('haveUnallocated', {points: '{{user.stats.points}}'}) + li(ng-repeat='(k,v) in user.newMessages', ng-if='v.value') + a(ng-click='k==party._id ? $state.go("options.social.party") : $state.go("options.social.guilds.detail",{gid:k}); expandMenu(null)') + span.glyphicon.glyphicon-comment + span {{v.name}} + a(ng-click='Groups.seenMessage(k)', popover=env.t('clear'),popover-placement='right',popover-trigger='mouseenter',popover-append-to-body='true') + span.glyphicon.glyphicon-remove-circle + // li(ng-if='user.items.special.valentineReceived[0] || user.items.special.nyeReceived[0]') + a(ng-click='$state.go("options.inventory.drops"); expandMenu(null)') + span.glyphicon.glyphicon-envelope + span=env.t('holidayCard') + + ul.toolbar-controls + //-li + //-a(ng-click='') Clear all + li.toolbar-controls-button + a(ng-click='expandMenu(null)')=env.t('close') + li.toolbar-audio + a(ng-click='expandMenu("audio")') + span.glyphicon(ng-class="{'glyphicon-volume-off':user.preferences.sound=='off', 'glyphicon-volume-up':user.preferences.sound!='off'}") + div(ng-if='_expandedMenu=="audio"',style='min-width:150px') + h4=env.t('audioTheme') + div + ul.toolbar-submenu(ng-click='expandMenu(null)') + // Using [{k,v}] instead of {k:v,k:v} to maintain order ('off' at top) + for theme in ['off','danielTheBard', 'wattsTheme'] + li + a(ng-class="{'bg-primary':user.preferences.sound=='#{theme}'}", ng-click="set({'preferences.sound':'#{theme}'})")=env.t('audioTheme_'+theme) + + li.toolbar-sync + a(ng-click='User.sync()', popover=env.t('sync'),popover-placement='bottom',popover-trigger='mouseenter') + span.glyphicon.glyphicon-refresh + li.toolbar-settings + a(ng-click='expandMenu("settings")') + span.glyphicon.glyphicon-cog + div(ng-if='_expandedMenu=="settings"') + h4=env.t('settings') + div + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ng-click='logout()')=env.t('logout') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ui-sref='options.settings.settings')=env.t('site') + li + a(ui-sref='options.settings.api')=env.t('API') + li + a(ui-sref='options.settings.export')=env.t('export') + li + a(ui-sref='options.settings.coupon') Coupon + li + a(ui-sref='options.settings.subscription')=env.t('subscription') + li + a(ui-sref='options.settings.notifications')=env.t('notifications') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(href="http://habitrpg.wikia.com/wiki/FAQ", target='_blank')=env.t('FAQ') + li + a(href="https://vimeo.com/57654086", target='_blank')=env.t('tutorials') + ul.toolbar-controls + li.toolbar-controls-button + a(ng-click='expandMenu(null)')=env.t('close') + ul.toolbar-wallet + li.toolbar-gems + a.gem-wallet(ng-click='openModal("buyGems",{track:"Gems > Toolbar"})', popover-trigger='mouseenter', popover-title=env.t('gems'), popover=env.t('gemsWhatFor'), popover-placement='bottom',popover-append-to-body='true') + //-span.task-action-btn.tile.flush.bright.add-gems-btn + + span.Pet_Currency_Gem2x.Gems + span.gem-text {{user.balance * 4 | number:0}} + li.toolbar-currency.gold(popover=env.t('gold') + ' ({{Shared.gold(user.stats.gp)}})', popover-placement='bottom',popover-trigger='mouseenter') + span.shop_gold + span {{Shared.gold(user.stats.gp) | goldRoundThousandsToK}} + li.toolbar-currency.silver(popover=env.t('silver'), popover-placement='bottom',popover-trigger='mouseenter') + span.shop_silver + span {{Shared.silver(user.stats.gp)}} + ul.toolbar-bailey(ng-class='{inactive: !_expandedMenu}') + li.toolbar-bailey-container(ng-if='user.flags.newStuff') + .npc_bailey.npc_bailey_head(popover=env.t('psst'), popover-trigger='mouseenter', popover-placement='right', ng-click='openModal("newStuff",{size:"lg"})') diff --git a/website/views/shared/mixins.jade b/website/views/shared/mixins.jade new file mode 100644 index 0000000000..3db108f4d0 --- /dev/null +++ b/website/views/shared/mixins.jade @@ -0,0 +1,13 @@ +mixin gemButton(isGemsModal) + a.pull-right.gem-wallet(ng-click=( isGemsModal ? '' : 'openModal("buyGems",{track:"Gems > Wallet"})'), popover-trigger='mouseenter', popover-title=env.t('gems'), popover=env.t('gemsWhatFor'), popover-placement='bottom') + if !isGemsModal + span.task-action-btn.tile.flush.bright.add-gems-btn + + span.task-action-btn.tile.flush.neutral + .Pet_Currency_Gem2x.Gems + =env.t('gemButton', {number: '{{user.balance * 4 | number:0}}'}) + +mixin aLink(url, label) + if mobile + a(href="", ng-click="externalLink('#{url}')")= label + else + a(href='#{url}', target='_blank')= label \ No newline at end of file diff --git a/website/views/shared/modals/achievements.jade b/website/views/shared/modals/achievements.jade new file mode 100644 index 0000000000..9d1decd66c --- /dev/null +++ b/website/views/shared/modals/achievements.jade @@ -0,0 +1,76 @@ +// Streak +script(id='modals/achievements/streak.html', type='text/ng-template') + .modal-header + h4=env.t('modalAchievement') + .modal-body + .achievement.achievement-thermometer + =env.t('streakerAchievement') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('ok') + +// Max Gear +script(id='modals/achievements/ultimateGear.html', type='text/ng-template') + .modal-header + h4=env.t('modalAchievement') + .modal-body + .achievement.achievement-armor + =env.t('gearAchievement') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('ok') + +// Beast Master +script(id='modals/achievements/beastMaster.html', type='text/ng-template') + .modal-header + h4=env.t('modalAchievement') + .modal-body + p + .achievement.achievement-rat + =env.t('beastAchievement') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('ok') + + +// Mount Master +script(id='modals/achievements/mountMaster.html', type='text/ng-template') + .modal-header + h4=env.t('modalAchievement') + .modal-body + p + .achievement.achievement-wolf + =env.t('mountAchievement') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('ok') + +// Triad Bingo +script(id='modals/achievements/triadBingo.html', type='text/ng-template') + .modal-header + h4=env.t('modalAchievement') + .modal-body + p + .achievement.achievement-triadbingo + =env.t('triadBingoAchievement') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('ok') + +// Contributor +// activated by user.flags.contributor +script(id='modals/achievements/contributor.html', type='text/ng-template') + .modal-header + h4=env.t('modalContribAchievement') + .modal-body + div(class="#{env.worldDmg.guide ? 'npc_justin_broken.float-left' : 'npc_justin.float-left'}") + p + !=env.t('contribModal', {name: "{{user.profile.name}}", level: "{{user.contributor.level}}"}) + ' ' + a(href='http://habitrpg.wikia.com/wiki/Contributor_Rewards' target='_blank')=env.t('contribLink') + .modal-footer + button.btn.btn-default(ng-click='set({"flags.contributor":false}); $close()')=env.t('ok') + +//Rebirth +script(id='modals/achievements/rebirth.html', type='text/ng-template') + .modal-header + h4=env.t('modalAchievement') + .modal-body + .achievement.achievement-sun + =env.t('rebirthAchievement', {number: "{{user.achievements.rebirths}}", level: "{{user.achievements.rebirthLevel}}"}) + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('ok') diff --git a/website/views/shared/modals/buy-gems.jade b/website/views/shared/modals/buy-gems.jade new file mode 100644 index 0000000000..33862575ab --- /dev/null +++ b/website/views/shared/modals/buy-gems.jade @@ -0,0 +1,29 @@ +script(id='modals/buyGems.html', type='text/ng-template') + .modal-body + .buy-gems + +gemButton(true) + .well + h3=env.t('buyGems') + table.table + tr + td + span.dashed-underline(popover=env.t('donateText1'),popover-trigger='mouseenter',popover-placement='right') + | +20 + tr + td + span.dashed-underline(popover=env.t('donateText3'),popover-trigger='mouseenter',popover-placement='right') + =env.t('donateText2') + tr + td.alert.alert-info $5  + =env.t('USD') + tr + td + p + small.muted=env.t('paymentMethods') + .btn.btn-primary(ng-click='Payments.showStripe({})')=env.t('card') + a.btn.btn-warning(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal + + div(ng-include="'partials/options.settings.subscription.html'") + + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('cancel') diff --git a/website/views/shared/modals/classes.jade b/website/views/shared/modals/classes.jade new file mode 100644 index 0000000000..5eac56aeea --- /dev/null +++ b/website/views/shared/modals/classes.jade @@ -0,0 +1,81 @@ +script(type='text/ng-template', id='modals/chooseClass.html') + .modal-header + h4 + |  + =env.t('chooseClass1') + | + a(href='http://habitrpg.wikia.com/wiki/Class_System' target='_blank')=env.t('chooseClass2') + |  + =env.t('chooseClass3') + .modal-body.select-class + .container-fluid + .row + .col-md-3(ng-click='selectedClass = "warrior"') + h5 + a(href='http://habitrpg.wikia.com/wiki/Warrior' target='_blank')=env.t('warrior') + figure.herobox(ng-class='{"selected-class": selectedClass=="warrior"}') + .character-sprites + span(class='skin_{{user.preferences.skin}}') + span(class='{{user.preferences.size}}_armor_warrior_5') + span(class='head_0') + span(class='hair_base_{{user.preferences.hair.base}}_{{user.preferences.hair.color}}') + span(class='hair_bangs_{{user.preferences.hair.bangs}}_{{user.preferences.hair.color}}') + span(class='hair_beard_{{user.preferences.hair.beard}}_{{user.preferences.hair.color}}') + span(class='hair_mustache_{{user.preferences.hair.mustache}}_{{user.preferences.hair.color}}') + span(class='head_warrior_5') + span(class='shield_warrior_5') + span(class='weapon_warrior_6') + .col-md-3(ng-click='selectedClass = "wizard"') + h5 + a(href='http://habitrpg.wikia.com/wiki/Mage' target='_blank')=env.t('mage') + figure.herobox(ng-class='{"selected-class": selectedClass=="wizard"}') + .character-sprites + span(class='skin_{{user.preferences.skin}}') + span(class='{{user.preferences.size}}_armor_wizard_5') + span(class='head_0') + span(class='hair_base_{{user.preferences.hair.base}}_{{user.preferences.hair.color}}') + span(class='hair_bangs_{{user.preferences.hair.bangs}}_{{user.preferences.hair.color}}') + span(class='hair_beard_{{user.preferences.hair.beard}}_{{user.preferences.hair.color}}') + span(class='hair_mustache_{{user.preferences.hair.mustache}}_{{user.preferences.hair.color}}') + span(class='head_wizard_5') + span(class='shield_wizard_5') + span(class='weapon_wizard_6') + .col-md-3(ng-click='selectedClass = "rogue"') + h5 + a(href='http://habitrpg.wikia.com/wiki/Rogue' target='_blank')=env.t('rogue') + figure.herobox(ng-class='{"selected-class": selectedClass=="rogue"}') + .character-sprites + span(class='skin_{{user.preferences.skin}}') + span(class='{{user.preferences.size}}_armor_rogue_5') + span(class='head_0') + span(class='hair_base_{{user.preferences.hair.base}}_{{user.preferences.hair.color}}') + span(class='hair_bangs_{{user.preferences.hair.bangs}}_{{user.preferences.hair.color}}') + span(class='hair_beard_{{user.preferences.hair.beard}}_{{user.preferences.hair.color}}') + span(class='hair_mustache_{{user.preferences.hair.mustache}}_{{user.preferences.hair.color}}') + span(class='head_rogue_5') + span(class='shield_rogue_6') + span(class='weapon_rogue_6') + .col-md-3(ng-click='selectedClass = "healer"') + h5 + a(href='http://habitrpg.wikia.com/wiki/Healer' target='_blank')=env.t('healer') + figure.herobox(ng-class='{"selected-class": selectedClass=="healer"}') + .character-sprites + span(class='skin_{{user.preferences.skin}}') + span(class='{{user.preferences.size}}_armor_healer_5') + span(class='head_0') + span(class='hair_base_{{user.preferences.hair.base}}_{{user.preferences.hair.color}}') + span(class='hair_bangs_{{user.preferences.hair.bangs}}_{{user.preferences.hair.color}}') + span(class='hair_beard_{{user.preferences.hair.beard}}_{{user.preferences.hair.color}}') + span(class='hair_mustache_{{user.preferences.hair.mustache}}_{{user.preferences.hair.color}}') + span(class='head_healer_5') + span(class='shield_healer_5') + span(class='weapon_healer_6') + br + .well(ng-show='selectedClass=="warrior"')=env.t('warriorText') + .well(ng-show='selectedClass=="wizard"')=env.t('mageText') + .well(ng-show='selectedClass=="rogue"')=env.t('rogueText') + .well(ng-show='selectedClass=="healer"')=env.t('healerText') + + .modal-footer + button.btn.btn-sm.btn-danger(ng-click='user.ops.disableClasses({}); $close()', popover-placement='top', popover-trigger='mouseenter', popover=env.t('optOutText'))=env.t('optOut') + button.btn.btn-primary(ng-disabled='!selectedClass' ng-click='changeClass(selectedClass); $close()')=env.t('select') diff --git a/website/views/shared/modals/death.jade b/website/views/shared/modals/death.jade new file mode 100644 index 0000000000..4ab50771a3 --- /dev/null +++ b/website/views/shared/modals/death.jade @@ -0,0 +1,16 @@ +//div(modal='user.stats.hp <= 0', options='{backdrop:true, keyboard:false, backdropClick:false}') +script(type='text/ng-template', id='modals/death.html') + .modal-body.death-modal + .container-fluid + .row + .col-md-3 + figure + .GrimReaper + .col-md-9 + h2=env.t('youDied') + .row + .col-md-3 + p + a.btn.btn-danger.btn-lg(ng-click='user.ops.revive({}); $close()')=env.t('continue') + .col-md-9 + p=env.t('dieText') diff --git a/website/views/shared/modals/drops.jade b/website/views/shared/modals/drops.jade new file mode 100644 index 0000000000..ffba52e088 --- /dev/null +++ b/website/views/shared/modals/drops.jade @@ -0,0 +1,51 @@ +script(type='text/ng-template', id='modals/dropsEnabled.html') + .modal-header + h4=env.t('dropsEnabled') + .modal-body + p + figure + .item-drop-icon(class='Pet_Egg_Wolf') + span!=env.t('firstDrop', {eggText: "{{Content.eggs.Wolf.text()}}", eggNotes: "{{Content.eggs.Wolf.notes()}}"}) + br + p + figure + .item-drop-icon(class='Pet_Currency_Gem') + span!=env.t('useGems') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('close') + +script(type='text/ng-template', id='modals/pet-key.html') + .modal-header + // Checks if user has 90 pets and 90 mounts. If so, displays "Beast Master/Mount Master". + // Else if user has 90 pets, displays "Beast Master". Else, displays "Mount Master" + - var masterTitle='{{petCount >= 90 && mountCount >= 90 ? env.t("beastMountMasterName") : petCount >= 90 ? env.t("beastMasterName") : env.t("mountMasterName")}}' + h4=env.t('petKeyBegin', {title: masterTitle}) + .modal-body + +gemButton + figure + .npc_matt + p=env.t('petKeyInfo') + br + p=env.t('petKeyInfo2') + br + p=env.t('petKeyInfo3') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('petKeyNeverMind') + span(ng-if='user.balance < 1') + a.btn.btn-success(ng-click='openModal("buyGems")')=env.t('buyMoreGems') + span.gem-cost=env.t('notEnoughGems') + span(ng-if='user.balance >= 1 && petCount >= 90', ng-controller='SettingsCtrl') + a.btn.btn-danger(ng-click='$close(); releasePets()') + =env.t('petKeyPets') + | : 4  + span.Pet_Currency_Gem1x.inline-gems + span(ng-if='user.balance >= 1 && mountCount >= 90', ng-controller='SettingsCtrl') + a.btn.btn-danger(ng-click='$close(); releaseMounts()') + =env.t('petKeyMounts') + | : 4  + span.Pet_Currency_Gem1x.inline-gems + span(ng-if='user.balance >= 1.5 && petCount >= 90 && mountCount >= 90', ng-controller='SettingsCtrl') + a.btn.btn-danger(ng-click='$close(); releaseBoth()') + =env.t('petKeyBoth') + | : 6  + span.Pet_Currency_Gem1x.inline-gems diff --git a/website/views/shared/modals/index.jade b/website/views/shared/modals/index.jade new file mode 100644 index 0000000000..f8f5433996 --- /dev/null +++ b/website/views/shared/modals/index.jade @@ -0,0 +1,13 @@ +include ./achievements +include ./reroll +include ./death +include ./new-stuff +include ./buy-gems +include ./members +include ./settings +include ./drops +include ./classes +include ./quests +include ./rebirth +include ./limited +include ./invite-friends diff --git a/website/views/shared/modals/invite-friends.jade b/website/views/shared/modals/invite-friends.jade new file mode 100644 index 0000000000..13f954167f --- /dev/null +++ b/website/views/shared/modals/invite-friends.jade @@ -0,0 +1,48 @@ +script(type='text/ng-template', id='modals/invite-friends.html') + .modal-header + h4 Invite Friends + .modal-body + p.alert.alert-info Invite friends by User ID here. + + form.form-inline(ng-submit='invite()') + //-.alert.alert-danger(ng-show='_groupError') {{_groupError}} + .form-group + input.form-control(type='text', placeholder=env.t('userId'), ng-model='invitee') + |  + button.btn.btn-primary(type='submit') Invite Existing User + + hr + + p.alert.alert-info Invite friends by email. If they join via your email, they'll automatically be invited to your party. + + form.form-horizontal(ng-submit='inviteEmails()') + table.table.table-striped + thead + tr + th Name + th Email + tbody + tr(ng-repeat='email in emails') + td + input.form-control(type='text', ng-model='email.name') + td + input.form-control(type='email', ng-model='email.email') + tr + td(colspan=2) + a.btn.btn-xs.pull-right(ng-click='emails = emails.concat([{name:"",email:""}])') + i.glyphicon.glyphicon-plus + tr + td.form-group(colspan=2) + label.col-sm-1.control-label By: + .col-sm-7 + input.form-control(type='text', ng-model='inviter') + .col-sm-4 + button.btn.btn-primary(type='submit') Invite New User(s) + //- + hr + p.alert.alert-info Or share this link (copy/paste): + input.form-control(type='text', ng-value='inviteLink({id: party._id, inviter: user._id, name: party.name})') + + .modal-footer + button.btn.btn-default(ng-click='$close()') Close + diff --git a/website/views/shared/modals/limited.jade b/website/views/shared/modals/limited.jade new file mode 100644 index 0000000000..e9889e88e3 --- /dev/null +++ b/website/views/shared/modals/limited.jade @@ -0,0 +1,49 @@ +//Valentine +script(id='modals/valentine.html', type='text/ng-template') + .modal-header + h4 + .inventory_special_valentine.pull-right + =env.t('valentineCard') + .modal-body + .bg-info(style='padding:10px') + p To: {{user.profile.name}}, From: {{user.items.special.valentineReceived[0]}} + hr + ul.list-unstyled(ng-switch='::Math.floor(Math.random()*4)') + li(ng-switch-when='0') + !=env.t('valentine0', {lineBreak:"
"}) + li(ng-switch-when='1') + !=env.t('valentine1', {lineBreak:"
"}) + li(ng-switch-when='2') + !=env.t('valentine2', {lineBreak:"
"}) + li(ng-switch-default) + !=env.t('valentine3', {lineBreak:"
"}) + p + small For enduring such a saccharine poem, you both receive the "Adoring Friends" badge! + .modal-footer + button.btn.btn-default(ng-click='user.ops.readValentine({});$close()')=env.t('ok') + +// New Year's +script(id='modals/nye.html', type='text/ng-template') + .modal-header + h4 + .inventory_special_nye.pull-right + =env.t('nyeCard') + .modal-body + .bg-info(style='padding:10px') + p To: {{user.profile.name}}, From: {{user.items.special.nyeReceived[0]}} + hr + ul.list-unstyled(ng-switch='::Math.floor(Math.random()*5)') + li(ng-switch-when='0') + =env.t('newYear0') + li(ng-switch-when='1') + =env.t('newYear1') + li(ng-switch-when='2') + =env.t('newYear2') + li(ng-switch-when='3') + =env.t('newYear3') + li(ng-switch-default) + =env.t('newYear4') + p + small For celebrating the new year together, you both receive the "Auld Acquaintance" badge! + .modal-footer + button.btn.btn-default(ng-click='user.ops.readNYE({});$close()')=env.t('ok') diff --git a/website/views/shared/modals/members.jade b/website/views/shared/modals/members.jade new file mode 100644 index 0000000000..bafe6c62f5 --- /dev/null +++ b/website/views/shared/modals/members.jade @@ -0,0 +1,103 @@ +script(type='text/ng-template', id='modals/member.html') + .modal-header + h4 + span {{::profile.profile.name}} + span(ng-if='profile.contributor.level') - {{::contribText(profile.contributor, profile.backer)}} + .modal-body + .container-fluid + .row + .col-md-6 + img(ng-show='::profile.profile.imageUrl', ng-src='{{::profile.profile.imageUrl}}') + markdown(ng-show='::profile.profile.blurb', text='::profile.profile.blurb') + ul.muted.list-unstyled(ng-if='::profile.auth.timestamps') + li {{profile._id}} + li(ng-show='::profile.auth.timestamps.created') + |  + =env.t('memberSince') + |  + | {{::profile.auth.timestamps.created | date:user.preferences.dateFormat}} - + li(ng-show='::profile.auth.timestamps.loggedin') + |  + =env.t('lastLoggedIn') + |  + | {{::profile.auth.timestamps.loggedin | date:user.preferences.dateFormat}} - + h3=env.t('stats') + .label.label-info {{:: {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer")}[profile.stats.class] }} + include ../profiles/stats + .col-md-6 + .row + +herobox() + .row + h3=env.t('achievements') + include ../profiles/achievements + .modal-footer + .btn-group.pull-left(ng-if='::user') + button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + span.glyphicon.glyphicon-plus + button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + span.glyphicon.glyphicon-ban-circle + button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right') + span.glyphicon.glyphicon-envelope + button.btn.btn-md.btn-default(tooltip="Send Gift", ng-click="openModal('send-gift',{controller:'MemberModalCtrl'})", tooltip-placement='right') + span.glyphicon.glyphicon-gift + button.btn.btn-default(ng-click='$close()')=env.t('close') + +script(type='text/ng-template', id='modals/private-message.html') + .modal-header + h4=env.t('pmHeading', {name: "{{profile.profile.name}}"}) + .modal-body + textarea.form-control(type='text',rows='5',ui-keydown='{"meta-enter":"sendPrivateMessage(profile._id, _message)"}',ng-model='_message') + .modal-footer + button.btn.btn-primary(ng-click='sendPrivateMessage(profile._id, _message)')=env.t("send") + button.btn.btn-default(ng-click='$close()')=env.t('cancel') + +script(type='text/ng-template', id='modals/send-gift.html') + .modal-header + h4 Send Gift to {{::profile.profile.name}} + .modal-body + .panel.panel-default(class="{{gift.type=='gems' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="gems"') + .panel-heading + .pull-right + span(ng-show='gift.gems.fromBalance') From {{user.balance*4}} Gems + span(ng-hide='gift.gems.fromBalance') Total: ${{gift.gems.amount/4}} USD + | Gems + .panel-body + .row + .col-md-6 + .form-group + input.form-control(type='number', placeholder='Number of Gems', min='0', max='{{gift.gems.fromBalance ? user.balance*4 : ""}}', ng-model='gift.gems.amount') + .col-md-6 + .btn-group + a.btn.btn-default(ng-class="{active:gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=true") From Balance + a.btn.btn-default(ng-class="{active:!gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=false") Purchase + + .panel.panel-default(class="{{gift.type=='subscription' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="subscription"') + .panel-heading Subscription + .panel-body + .form-group + .radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit:"discount==true" | orderBy:"months"') + label + input(type="radio", name="subRadio", ng-value="block.key", ng-model='gift.subscription.key') + | {{block.months}} Month(s): ${{block.price}} + + textarea.form-control(rows='3', ng-model='gift.message', placeholder='Personal message (optional)') + + .modal-footer + - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" + button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") + a.btn.btn-primary(ng-hide=fromBal, ng-click='Payments.showStripe({gift:gift, uuid:profile._id})')=env.t('card') + a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{Payments.encodeGift(profile._id, gift)}}') PayPal + button.btn.btn-default(ng-click='$close()')=env.t('cancel') + +script(type='text/ng-template', id='modals/abuse-flag.html') + .modal-header + h4!=env.t('abuseFlagModalHeading', {name: "{{profile.profile.name}}"}) + .modal-body + blockquote + markdown(text="abuseObject.text") + p!=env.t('abuseFlagModalBody', {firstLinkStart: "", secondLinkStart: "", linkEnd: ""}) + .modal-footer + button.pull-left.btn.btn-danger(ng-click='clearFlagCount(abuseObject, groupId)', ng-if='user.contributor.admin && abuseObject.flagCount >= 2') + | Reset Flag Count + button.btn.btn-primary(ng-click='$close()')=env.t('cancel') + button.btn.btn-default(ng-click='reportAbuse(user, abuseObject, groupId)')=env.t("abuseFlagModalButton") diff --git a/website/views/shared/modals/new-stuff.jade b/website/views/shared/modals/new-stuff.jade new file mode 100644 index 0000000000..e27ecc0e7f --- /dev/null +++ b/website/views/shared/modals/new-stuff.jade @@ -0,0 +1,12 @@ +script(type='text/ng-template', id='modals/newStuff.html') + .modal-header + | #{env.t('newStuff')} by  + a(target='_blank', href='https://twitter.com/Mihakuu') Bailey + .modal-body.new-stuff-modal.modal-fixed-height + div(class="#{env.worldDmg.bailey ? 'npc_bailey_broken' : 'npc_bailey'}") + br + br + include ../new-stuff + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('cool') + button.btn.btn-warning(ng-click='dismissAlert(); $close()')=env.t('dismissAlert') diff --git a/website/views/shared/modals/quest-rewards.jade b/website/views/shared/modals/quest-rewards.jade new file mode 100644 index 0000000000..f5dc816501 --- /dev/null +++ b/website/views/shared/modals/quest-rewards.jade @@ -0,0 +1,12 @@ +script(id='partials/options.social.party.quest-rewards.html', type='text/ng-template') + hr + h5 {{header}} + table.table.table-striped + tr(ng-repeat='drop in quest.drop.items') + td {{drop.text()}} + tr + td {{quest.drop.exp}} + =env.t('experience') + tr + td {{quest.drop.gp}} + =env.t('gold') diff --git a/website/views/shared/modals/quests.jade b/website/views/shared/modals/quests.jade new file mode 100644 index 0000000000..8e7152f10a --- /dev/null +++ b/website/views/shared/modals/quests.jade @@ -0,0 +1,91 @@ +include ./quest-rewards + +script(type='text/ng-template', id='modals/questCompleted.html') + .modal-header + h4 "{{::Content.quests[user.party.quest.completed].text()}}" + =env.t('completed') + .modal-body + .col-centered(ng-class='::Content.quests[user.party.quest.completed].completion() ? "pull-right-sm" : ""', class='quest_{{user.party.quest.completed}}') + p(ng-bind-html='::Content.quests[user.party.quest.completed].completion()') + quest-rewards(key='{{user.party.quest.completed}}', header=env.t('youReceived')) + .modal-footer + button.btn.btn-primary(ng-click='set({"party.quest.completed":""}); $close()')=env.t('ok') + +script(type='text/ng-template', id='modals/showQuest.html') + .modal-header + h4 {{::selectedQuest.text()}} + .modal-body + .pull-right-sm.text-center + .col-centered(class='quest_{{::selectedQuest.key}}') + div(ng-if='::selectedQuest.boss') + h4 {{::selectedQuest.boss.name()}} + p + strong=env.t('bossHP') + ': ' + | {{::selectedQuest.boss.hp}} + p + strong=env.t('bossStrength') + ': ' + | {{::selectedQuest.boss.str}} + div(ng-if='::selectedQuest.collect') + p(ng-repeat='(k,v) in ::selectedQuest.collect') + strong=env.t('collect') + ': ' + | {{::selectedQuest.collect[k].count}} {{::selectedQuest.collect[k].text()}} + + div(ng-bind-html='::selectedQuest.notes()') + quest-rewards(key='{{::selectedQuest.key}}', header=env.t('rewards')) + hr + .npc_ian.pull-left + p=env.t('questSend') + p=env.t('questWarning') + .modal-footer + button.btn.btn-default(ng-click='closeQuest(); $close()')=env.t('cancel') + button.btn.btn-primary(ng-click='questInit(); $close()')=env.t('inviteParty') + +script(type='text/ng-template', id='modals/buyQuest.html') + .modal-header + h4 {{::selectedQuest.text()}} + .modal-body + .pull-right-sm.text-center + .col-centered(class='quest_{{::selectedQuest.key}}') + div(ng-if='::selectedQuest.boss') + h4 {{::selectedQuest.boss.name()}} + p + strong=env.t('bossHP') + ': ' + | {{::selectedQuest.boss.hp}} + p + strong=env.t('bossStrength') + ': ' + | {{::selectedQuest.boss.str}} + div(ng-if='::selectedQuest.collect') + p(ng-repeat='(k,v) in ::selectedQuest.collect') + strong=env.t('collect') + ': ' + | {{::selectedQuest.collect[k].count}} {{::selectedQuest.collect[k].text()}} + div(ng-bind-html='::selectedQuest.notes()') + quest-rewards(key='{{::selectedQuest.key}}', header=env.t('rewards')) + .modal-footer + button.btn.btn-default(ng-click='closeQuest(); $close()')=env.t('neverMind') + button.btn.btn-primary(ng-click='purchase("quests", quest); closeQuest(); $close()')=env.t('buyQuest') + +script(type='text/ng-template', id='modals/questInvitation.html') + .modal-header + h4=env.t('questInvitation') + | {{::Content.quests[party.quest.key].text()}} + .modal-body + .pull-right-sm.text-center + .col-centered(class='quest_{{::Content.quests[party.quest.key].key}}') + div(ng-if='::Content.quests[party.quest.key].boss') + h4 {{::Content.quests[party.quest.key].boss.name()}} + p + strong=env.t('bossHP') + ': ' + | {{::Content.quests[party.quest.key].boss.hp}} + p + strong=env.t('bossStrength') + ': ' + | {{::Content.quests[party.quest.key].boss.str}} + div(ng-if='::Content.quests[party.quest.key].collect') + p(ng-repeat='(k,v) in ::Content.quests[party.quest.key].collect') + strong=env.t('collect') + ': ' + | {{::Content.quests[party.quest.key].collect[k].count}} {{::Content.quests[party.quest.key].collect[k].text()}} + div(ng-bind-html='::Content.quests[party.quest.key].notes()') + quest-rewards(key='{{::party.quest.key}}', header=env.t('rewards')) + .modal-footer + button.btn.btn-default(ng-click='questHold = true; $close()')=env.t('askLater') + button.btn.btn-default(ng-click='party.$questReject(); $close()')=env.t('reject') + button.btn.btn-primary(ng-click='party.$questAccept(); $close()')=env.t('accept') diff --git a/website/views/shared/modals/rebirth.jade b/website/views/shared/modals/rebirth.jade new file mode 100644 index 0000000000..81fb43fe8d --- /dev/null +++ b/website/views/shared/modals/rebirth.jade @@ -0,0 +1,63 @@ +// Created by Sabe on 12/22/13. + +script(type='text/ng-template', id='modals/rebirthEnabled.html') + .modal-header + h4=env.t('rebirthNew') + .modal-body + figure + .rebirth_orb + p + span=env.t('rebirthUnlock') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('close') + +script(type='text/ng-template', id='modals/rebirth.html') + .modal-header + h4=env.t('rebirthBegin') + .modal-body + span(ng-if='user.stats.lvl < 100') + +gemButton + figure + .rebirth_orb + p=env.t('rebirthStartOver') + br + ul.list-unstyled + li=env.t('rebirthAdvList1') + li=env.t('rebirthAdvList2') + li=env.t('rebirthAdvList3') + li=env.t('rebirthAdvList4') + br + p=env.t('rebirthInherit') + ul.list-unstyled + li=env.t('rebirthInList1') + li=env.t('rebirthInList2') + li=env.t('rebirthInList3') + li=env.t('rebirthInList4') + li=env.t('rebirthInList5') + br + + p + span.vertical-align.inline-block.achievement-sun + |  + =env.t('rebirthEarnAchievement') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('neverMind') + span(ng-if='user.balance < 2 && user.stats.lvl < 100') + a.btn.btn-success(ng-click='openModal("buyGems",{track:"Gems > Rebirth"})')=env.t('buyMoreGems') + span.gem-cost=env.t('notEnoughGems') + span(ng-if='user.balance >= 2 || user.stats.lvl >= 100', ng-controller='SettingsCtrl') + a.btn.btn-danger(ng-click='$close(); rebirth()')=env.t('beReborn') + span.gem-cost(ng-if='user.stats.lvl < 100') + | 8  + =env.t('gems') +script(type='text/ng-template', id='modals/freeRebirth.html') + .modal-header + h4=env.t('welcome100') + figure + .rebirth_orb + p=env.t('intro100') + p=env.t('followup100') + p=env.t('rebirth100Info') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('rebirthWait') + a.btn.btn-danger(ng-click='$close(); rebirth()')=env.t('rebirthNow') diff --git a/website/views/shared/modals/reroll.jade b/website/views/shared/modals/reroll.jade new file mode 100644 index 0000000000..cd144d8252 --- /dev/null +++ b/website/views/shared/modals/reroll.jade @@ -0,0 +1,19 @@ +// Re-Roll modal +script(type='text/ng-template', id='modals/reroll.html') + .modal-header + h4=env.t('fortify') + .modal-body + +gemButton + p=env.t('fortifyText') + + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('neverMind') + span(ng-if='user.balance < 1') + a.btn.btn-success(ng-click='openModal("buyGems",{track:{"Gems > Reroll"}})')=env.t('buyMoreGems') + span.gem-cost=env.t('notEnoughGems') + span(ng-if='user.balance >= 1', ng-controller='SettingsCtrl') + a.btn.btn-danger(ng-click='$close(); reroll()')=env.t('fortify') + span.gem-cost + | 4  + = env.t('gems') + diff --git a/website/views/shared/modals/settings.jade b/website/views/shared/modals/settings.jade new file mode 100644 index 0000000000..b5728fb551 --- /dev/null +++ b/website/views/shared/modals/settings.jade @@ -0,0 +1,65 @@ +script(type='text/ng-template', id='modals/reset.html') + .modal-header + h4=env.t('resetAccount') + .modal-body + p=env.t('resetText1') + p=env.t('resetText2') + .modal-footer + button.btn.btn-default(ng-click='$close();')=env.t('neverMind') + button.btn.btn-danger(ng-click='$close(); reset()')=env.t('resetDo') + +script(type='text/ng-template', id='modals/restore.html') + .modal-header + h4=env.t('fixValues') + .modal-body + p=env.t('fixValuesText1') + p=env.t('fixValuesText2') + form.form-horizontal + h3=env.t('stats') + .form-group + .col-md-6 + input.form-control(type='number', step="any", data-for='stats.hp', ng-model='$parent.restoreValues.stats.hp') + label.control-label=env.t('health') + .form-group + .col-md-6 + input.form-control(type='number', step="any", data-for='stats.exp', ng-model='$parent.restoreValues.stats.exp') + label.control-label=env.t('experience') + .form-group + .col-md-6 + input.form-control(type='number', step="any", data-for='stats.gp', ng-model='$parent.restoreValues.stats.gp') + //input.form-control(type='number', step="any", data-for='stats.gp', ng-model='restoreValues.stats.gp',disabled) + label.control-label=env.t('gold') + //-p.alert + small=env.t('disabledWinterEvent') + .form-group + .col-md-6 + input.form-control(type='number', step="any", data-for='stats.mp', ng-model='$parent.restoreValues.stats.mp') + label.control-label=env.t('mana') + .form-group + .col-md-6 + input.form-control(type='number', data-for='stats.lvl', ng-model='$parent.restoreValues.stats.lvl') + label.control-label=env.t('level') + h3=env.t('achievements') + .form-group + .col-md-6 + input.form-control(type='number', data-for='achievements.streak', ng-model='$parent.restoreValues.achievements.streak') + label.control-label=env.t('fix21Streaks') + //- This is causing too many problems for users + h3=env.t('other') + a.btn.btn-sm.btn-warning(ng-controller='FooterCtrl', ng-click='addMissedDay()')=env.t('triggerDay') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('discardChanges') + button.btn.btn-primary(ng-click='restore(); $close();')=env.t('saveAndClose') + +script(type='text/ng-template', id='modals/delete.html') + .modal-header + h4=env.t('deleteAccount') + .modal-body + p!=env.t('deleteText', {deleteWord: 'DELETE'}) + br + .row + .col-md-6 + input.form-control(type='text', ng-model='_deleteAccount') + .modal-footer + button.btn.btn-default(ng-click='$close()')=env.t('neverMind') + button.btn.btn-danger(ng-disabled='_deleteAccount != "DELETE"', ng-click='$close(); delete()')=env.t('deleteDo') diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade new file mode 100644 index 0000000000..4fa59a4f80 --- /dev/null +++ b/website/views/shared/new-stuff.jade @@ -0,0 +1,1800 @@ +h5 3/3/2015 - MARCH BACKGROUNDS, ANDROID APP NOTIFICATIONS, AND MARCH MYSTERY BOX + hr + tr + td + h5 March Backgrounds Revealed + p There are three new avatar backgrounds in the Background Shop! Now your avatar can dance in the Spring Rain, admire some Stained Glass, or frolic through the Rolling Hills! + p.small.muted by (in order) Sunstroke, Kiwibot, and Uncommon Criminal + tr + td + h5 Android App Notifications + p The Android app can now remind you to log in! Simply go to Settings and select the time that you want the reminder. + p.small.muted by Negue + tr + td + .inventory_present.pull-right + h5 March Mystery Box + p Wow! What could it be? All Habiticans who are subscribed during the month of March will receive the March Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness + +hr +a(href='/static/old-news', target='_blank') Read older news + +mixin oldNews + h5 2/24/2015 - FEBRUARY SUBSCRIBER ITEM AND ADD MULTIPLE TASKS! + tr + td + .promo_mystery_201502.pull-right + h5 February Subscriber Item + p The February Subscriber Item has been revealed: the Winged Enchanter Item Set! All February subscribers will receive the Wings of Thought and the Shimmery Winged Staff of Love and Also Truth. You still have four days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + p.small.muted by Lemoness + tr + td + h5 Add Multiple Tasks + p Got a bunch of tasks you need to add all at once? No problem! Now you can add a batch of tasks all at once by clicking "Add Multiple" under the task entry bar. We hope that this will save you time! + p.small.muted by ChimericDream + h5 2/24/2015 - SITE DOWNTIME EXPLANATION (AND SLEEPING AVATARS) + tr + td + h5 + p As most of you probably noticed, the site was down yesterday. We got a surge of new users from Imgur who absolutely flattened the servers by registering all at once, and it proved very difficult to start up again. You can read the technical details in this Github ticket. We're sorry about all of the frustration! + br + p At about midnight PST we checked all active users into the inn, "freezing" their accounts so that their incomplete Dailies would not hurt them, in hopes that this would prevent most of the undeserved deaths due to server troubles. That's why your avatar is sleeping! To check yourself out of the inn, go to Social > Tavern > Check out of inn. + br + p If you died before we could check you into the Inn, you can restore your streaks under task edit, and all other stats under Settings > Site > Fix Character Values. You should be able to buy back all missing items under Rewards. If you cannot, please post in Social > Tavern and a moderator will help you! + br + p Thank you so much to all of our intrepid users who answered questions on social media and in the chat rooms, and to everyone who sent us kind messages while we were scrambling to save the site. You guys are incredible, and we feel constantly lucky to have such a caring and positive community. + br + p And welcome, Imgurians! Sorry your introduction was so rocky, but we can't wait to get to know you. There is a Camp Imgur Guild that you might enjoy. + br + p Now, back to productivity! + h5 2/17/2015 - NEW PET QUEST AND COMMUNITY GUIDELINE UPDATES + hr + tr + td + .quest_rock.pull-right + h5 New Pet Quest: Rocks! + p It seemed like a simple hike... until we discovered that the cave was alive! You can get the newest Pet Quest, "Escape the Cave Creature," in the Market. If you defeat it, you'll get some cuddly pet rocks! + p.small.muted by itokro, Pfeffernusse, Painter de Cluster, and intone + tr + td + h5 Community Guideline Updates + p We've updated the Community Guidelines to include the following: + ul + li Blade, a new mod, is listed! + li Private Messages have been added to the guidelines for Private Spaces. + li Spamming is now expressly forbidden. + li Sexism has been added to the list of unacceptable behaviors. + li Making duplicate accounts to circumvent consequences is now expressly forbidden. + + h5 2/12/2015 + tr + td + h5 Happy Valentine's Day! + p Help motivate all of the lovely people in your life by sending them a caring valentine. Valentines can be purchased for 10 gold from the Market. For spreading love and joy throughout the community, both the giver AND the receiver get a coveted "adoring friends" badge. Hooray! + p.small.muted by Lemoness and SabreCat + tr + td + h5 New Hairstyles! + .promo_updos.pull-right + p There are a new set of updo hairstyles available in the Avatar Customization page! Have fun customizing your characters. + p.small.muted by Crystalphoenix, Mariahm, Painter de Cluster, Leephon, Beffymaroo, Sungabraverday, Lemoness, and Bailey + h5 2/8/2015 + tr + td + h5 Email Notifications + p We've implemented email notifications for a variety of events, including receiving a Private Message, being invited to a Party, Guild, or Quest, and receiving a gift of Gems or a Subscription! We've got some more coming up, too, including the much-requested check-in reminders. + p Don't want to receive a certain type of notification? No problem! Just go to Notification Settings to tell us exactly which ones you do and do not want to receive. Our messenger dragons will be happy to comply! + p.small.muted by paglias and Lemoness + tr + td + h5 Login Type Switching + p Want to change your email address, or switch from Facebook login to email login? Good news! Now you can switch it yourself, under Settings! + p.small.muted by Lefnire + h5 2/3/2015 + tr + td + h5 February Backgrounds Revealed + .background_distant_castle.pull-right + p There are three new avatar backgrounds in the Background Shop! Now your avatar can survey a Distant Castle, toil in the Blacksmithy, or explore a Crystal Cave! + p.small.muted by Holseties, Hanztan, and Twitching + h5 2/2/2015 + tr + td + h5 February Mystery Box + .inventory_present.pull-right + p Ooh... What could it be? All Habiticans who are subscribed during the month of February will receive the February Mystery Item Set! It will be revealed on the 24th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness + tr + td + h5 New Quest Descriptions + p We've updated quest descriptions so that when you hover over them, you can now see the Boss or Collection stats and the Rewards that you will gain when you complete the quest! + p.small.muted by Blade + tr + td + h5 Spread the Word Challenge Has Ended + p The Spread the Word Challenge has ended! Thank you to all the participants. It will be some time before the winners are announced because we have to go over all the entries ourselves. Thanks for your patience! + h5 1/30/2015 + tr + td + .npc_alex.pull-left + h5 HabitRPG Birthday Bash + p January 31st is HabitRPG's Birthday! All of the NPCs are celebrating, and we've awarded you a bunch of cake for your pets and mounts! + tr + td + h5 Party Robes + .shop_armor_special_birthday.pull-right + .shop_armor_special_birthday2015.pull-right + p Until February 1st only, there are Party Robes available for free in the Rewards store! If this is your first Birthday bash with us, you can find some Absurd Party Robes; if you already got some last year, then you will find the Silly Party Robes. + tr + td + .promo_mystery_201501.pull-left + h5 Last Chance for Starry Knight Item Set + p Reminder: this is the final day to subscribe and receive the Starry Knight Item Set! If you want the Starry Helm or the Starry Armor, now's the time! Thanks so much for your support <3 + tr + td + h5 Last Chance for Winter Wonderland Outfits + Hair Colors + .promo_winterclasses2015.pull-right + p Tomorrow everything will be back to normal in Habitica, so if you still have any remaining Winter Wonderland Items that you want to buy, you'd better do it now! The Seasonal Edition items and Hair Colors won't be back until next December, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot! + h5 1/26/2015 + tr + td + h5 Subscriber Outfit Revealed + .promo_mystery_201501.pull-right + p The January Subscriber Item has been revealed: the Starry Knight Item Set! All January subscribers will receive the Starry Helm and the Starry Armor. You still have five days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + p.small.muted by Lemoness + tr + td + h5 New Audio Theme + p A new audio theme is available: Watts' Theme! You can toggle between Watts' Theme and Daniel the Bard's Theme by selecting the megaphone in the upper right-hand corner. Watts' Theme was created by Harry Pepe. You can visit his LinkedIn page here. + p.small.muted by Hpepe4 and Blade + tr + td + h5 Quest Scroll Redesign + p We've redesigned the quest scrolls so that they are visually unique! Quest type and difficulty is determined by the scroll lining (Easy Boss = Green, Medium Boss = Yellow, Hard Boss = Red, Collection Quest = Blue, Rage Bar Boss = Purple speckles), and an icon symbolizing the quest is located in the lower left. + p.small.muted by UncommonCriminal and Rattify + tr + td + h5 Spread the Word Challenge Ending Soon + p Reminder: January 31st is the last day to enter the Spread the Word Challenge for your chance at winning 100 gems! We will stop accepting new applications on February 1st, but it will be some time before the winners are announced because we have to go over all the entries ourselves. Good luck! + h5 1/23/2015 + tr + td + h5 The Abominable Stressbeast is DEFEATED! + p We've done it! With a final bellow, the Abominable Stressbeast dissipates into a cloud of snow. The flakes twinkle down through the air as cheering Habiticans embrace their pets and mounts. Our animals and our NPCs are safe once more! + tr + td + h5 Stoïkalm is Saved! + p SabreCat speaks gently to a small sabertooth. "Please find the citizens of the Stoïkalm Steppes and bring them to us," he says. Several hours later, the sabertooth returns, with a herd of mammoth riders following slowly behind. You recognize the head rider as Lady Glaciate, the leader of Stoïkalm. + p "Mighty Habiticans," she says, "My citizens and I owe you the deepest thanks, and the deepest apologies. In an effort to protect our Steppes from turmoil, we began to secretly banish all of our stress into the icy mountains. We had no idea that it would build up over generations into the Stressbeast that you saw! When it broke loose, it trapped all of us in the mountains in its stead and went on a rampage against our beloved animals." Her sad gaze follows the falling snow. "We put everyone at risk with our foolishness. Rest assured that in the future, we will come to you with our problems before our problems come to you." + .Pet-Mammoth-Base.pull-right + p She turns to where @Baconsaur is snuggling with some of the baby mammoths. "We have brought your animals an offering of food to apologize for frightening them, and as a symbol of trust, we will leave some of our pets and mounts with you. We know that you will all take care good care of them." + p.small.muted by everyone who completed a Daily or a To-Do during the battle! + h5 1/21/2015 + tr + td + h5 THIRD STRESS STRIKE! + .npc_justin_broken.pull-right + p The World Boss in the Tavern has used a third Stress Strike! + p Justin the Guide is trying to distract the Stressbeast by running around its ankles, yelling productivity tips! The Abominable Stressbeast is stomping madly, but it seems like we're really wearing this beast down. I doubt it has enough energy for another strike. Don't give up... we're so close to finishing it off! + p Complete Dailies and To-Dos to damage the World Boss! A World Boss will never damage individual players or accounts in any way. Only active accounts who are not resting in the inn will have their incomplete Dailies tallied. + p.small.muted by Lemoness, Kiwibot, and SabreCat + h5 1/20/2015 + tr + td + h5 Mount Master and Triad Bingo Achievements + .achievement.achievement-wolf + .achievement.achievement-triadbingo + p There are two new achievements you can earn: Mount Master and Triad Bingo! Mount Master is awarded to users who have collected all 90 standard mounts, and Triad Bingo is for those who have collected all 90 standard pets, grown all 90 into mounts, and then rehatched 90 more standard pets. Wow! + + P Note that Quest Pets and Quest Mounts do not count towards Mount Master or Triad Bingo. If you currently meet the criteria, you will be awarded the badge, but unfortunately, if you already released your Mounts, you will not receive the badge until you collect them again. + p.small.muted by Taldin, Blade, Lorian, Aiseant, and Hanztan + tr + td + h5 Party Sorting Options + p In the Party Page you can now sort your friends' avatars in ascending or descending order! To make the change take effect, you'll have to refresh the page. + + p.small.muted by Blade and Viirus + tr + td + h5 Dated To-Dos + p Now you can use To-Do tabs to sort and see your dated To-Dos! Simply click the "Dated" tab and only To-Dos with a due date will be displayed. They are not currently sorted by date, but we will be implementing that feature in the future. + + p.small.muted by Alys + tr + td + h5 Stressbeast Desperation Triggered + + p We're almost there, Habiticans! With diligence and Dailies, we've whittled the Stressbeast's health down to only 500K! The creature roars and flails in desperation, rage building faster than ever. The monster is --- AHHH! --- swinging me and Matt around at a terrifying pace, raising a blinding snowstorm that makes it harder to hit. + p We'll have to redouble our efforts, but take heart - this is a sign that the Stressbeast knows it is about to be defeated. Don't give up now! Please? + + p The Stressbeast raises its Rage and Defense! Complete Dailies and To-Dos to damage the World Boss. A World Boss will never damage individual players or accounts in any way. Only active accounts who are not resting in the inn will have their incomplete Dailies tallied. + p.small.muted by Lemoness, Kiwibot, and SabreCat + h5 1/19/2015 + tr + td + h5 WORLD BOSS: SECOND STRESS STRIKE! + p AHHHHHHHH!!!!! IT'S GOT ME!!!!! Oh, Habiticans, why didn't you do your Dailies?! + p The World Boss in the Tavern has used another Stress Strike, and this time it's attacked me, Bailey the Town Crier! To save me and the other NPCs, complete Dailies and To-Dos to damage the World Boss! Incomplete Dailies fill the Stress Strike Bar. When the Stress Strike bar is full, the World Boss will attack an NPC and regain some health. A World Boss will never damage individual players or accounts in any way. Only active accounts who are not resting in the inn will have their incomplete Dailies tallied. + p.small.muted by Lemoness, Kiwibot, and SabreCat + + h5 1/15/2015 + tr + td + h5 Tyrannosaur Pet Quest + p In the Market there are now two new pet quests: King of the Dinosaurs and The Dinosaur Unearthed! They both give out the same rewards, including pet Tyrannosaur eggs. The difference is that "King of the Dinosaurs" is a normal pet quest, like all the others, whereas "The Dinosaur Unearthed" has less HP - but also a Rage bar (a la World Bosses) that allows it to heal if you skip too many of your Dailies. Both bosses still attack your party based on how many Dailies are incomplete. Users will be able to buy Tyrannosaur eggs after defeating either boss twice or both bosses once. + p Have fun! + p.small.muted by Baconsaur, Urse, Lemoness, and SabreCat + tr + td + h5 Spread the Word Challenge Reminder + p In case you missed it, we're running our second Spread the Word Challenge! The rules are simple: make a post some time between December 31st 2014 and January 31st 2015 on some form of blog or social media that tells people about HabitRPG. The top post will be awarded 100 GEMS, and the next nineteen top posts will be awarded 80 GEMS each. Learn more and join in here! + tr + td + h5 World Boss: First Stress Strike! + p The World Boss in the Tavern has used its first Stress Strike! + p Despite our best efforts, we've let some Dailies get away from us, and their dark-red color has infuriated the Abominable Stressbeast and caused it to regain some of its health! The horrible creature lunges for the Stables, but Matt the Beast Master heroically leaps into the fray to protect the pets and mounts. The Stressbeast has seized Matt in its vicious grip, but at least it's distracted for the moment. + p Complete Dailies and To-Dos to damage the World Boss! Incomplete Dailies fill the Stress Strike Bar. When the Stress Strike bar is full, the World Boss will attack an NPC. A World Boss will never damage individual players or accounts in any way. Only active accounts who are not resting in the inn will have their incomplete Dailies tallied. + p.small.muted by Lemoness, Kiwibot, and SabreCat + h5 1/8/2015 + tr + td + h5 World Boss: The Abominable Stressbeast! + .quest_stressbeast.pull-right + p A new World Boss has appeared in the Tavern! All of the completed Dailies and To-Dos of Habiticans damage the World Boss. Incomplete Dailies fill the Stress Strike Bar. When the Stress Strike bar is full, the World Boss will attack an NPC. + p A World Boss will never damage individual players or accounts in any way. Only active accounts who are not resting in the inn will have their incomplete Dailies tallied. Read on for the details! + tr + td + h5 Winter Plot-Line: The Abominable Stressbeast Attacks! + p The first thing we hear are the footsteps, slower and more thundering than the stampede. One by one, Habiticans look outside their doors, and words fail us. + p We've all seen Stressbeasts before, of course - tiny vicious creatures that attack during difficult times. But this? This towers taller than the buildings, with paws that could crush a dragon with ease. Frost swings from its stinking fur, and as it roars, the icy blast rips the roofs off our houses. A monster of this magnitude has never been mentioned outside of distant legend. + p "Beware, Habiticans!" SabreCat cries. "Barricade yourselves indoors - this is the Abominable Stressbeast itself!" + p "That thing must be made of centuries of stress!" Kiwibot says, locking the Tavern door tightly and shuttering the windows. + p "The Stoïkalm Steppes," Lemoness says, face grim. "All this time, we thought they were placid and untroubled, but they must have been secretly hiding their stress somewhere. Over generations, it grew into this, and now it's broken free and attacked them - and us!" + p There's only one way to drive away a Stressbeast, Abominable or otherwise, and that's to attack it with completed Dailies and To-Dos! Let's all band together and fight off this fearsome foe - but be sure not to slack on your tasks, or our undone Dailies may enrage it so much that it lashes out... + p.small.muted by Lemoness, Kiwibot, and SabreCat + h5 1/5/2015 + tr + td + h5 January Backgrounds + p There are three new avatar backgrounds in the Background Shop! Now your avatar can summit a Frigid Peak, shiver in an Ice Cave, or wander through the Snowy Pines! + p.small.muted by Kiwibot, Sunstroke, and Rattify + tr + td + h5 Testing Fix for Cron Bug + p Today we will be testing a possible fix for a bug that sometimes causes Dailies to not reset correctly on the following day. It's a big change, so we will be keeping a close watch on the site to make sure that it doesn't break anything. If you experience any problems with day start or Dailies reseting in the next few days, please let us know immediately on GitHub. Thanks! + tr + td + h5 Date Format Adjustment + p There's a new option under Settings that lets you adjust the date format. Now you can list dates as MM/DD/YYYY, DD/MM/YYYY, or YYYY/MM/DD. + p.small.muted by Verabird + h5 1/3/2015 + tr + td + h5 January Mystery Item Set + .inventory_present.pull-right + p Sparkly! What could it be? All Habiticans who are subscribed during the month of January will receive the January Mystery Item Set! It will be revealed on the 26th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness + tr + td + h5 Spread the Word Challenge + p In honor of the season of New Year's resolutions, we're running our second Spread the Word Challenge! The rules are simple: make a post some time between December 31st 2014 and January 31st 2015 on some form of blog or social media that tells people about HabitRPG. The top post will be awarded 100 GEMS, and the next nineteen top posts will be awarded 80 GEMS each. Learn more and join in here! + tr + td + h5 Winter Plot-Line Continues + p After a fun-filled New Year's Eve, Habiticans wake to a rumbling that shakes them out of their Absurd Party Hats. Running to their windows reveals.... a stampede? + p A thundering herd of mammoths charges past, sabertooths roar, and dinosaurs both feathery and scaly slither by at top speed. Habiticans stare open-mouthed, but before anyone can react, the stampede has swept through Habit City and is gone into the distance, leaving only pawprints in the snow, the howling wind, and some trampled New Year's cards. + p Habiticans are advised to keep calm and not give in to stress during this confusing and difficult time. We've sent SabreCat after the frightened animals from the Stoïkalm Steppes, and he is working to calm them down so that we can bring them back to the safety of the Stables. We hope to have an explanation for this strangeness soon. In the meantime, keep all of your own pets and mounts indoors. + p.small.muted Read the previous installments of the Winter Plot-Line here! + h5 12/31/2014 + tr + td + h5 Party Hats + .promo_partyhats.pull-right + p In honor of the new year, some free Party Hats are available in the Rewards store! New users get the ever-handsome Absurd Party Hat, and users who already received one last year get the Silly Party Hat. These hats will be available to purchase until January 31st, but once you've bought them, you'll have them forever. Enjoy! + p.small.muted by Lemoness and SabreCat + tr + td + h5 New Year's Cards (Until Jan 1st Only!) + .inventory_special_nye.pull-right + p Until January 1st only, the Seasonal Shop is stocking New Year's Cards! Now you can send cards to your friends (and yourself) to wish them a Happy Habit New Year. All senders and recipients will receive the Auld Acquaintance badge! When you receive a card, it will appear in your Inventory. Click it to receive a seasonal message! + p.small.muted by Lemoness and SabreCat + tr + td + h5 Snowballs + .inventory_special_snowball.pull-right + p The Seasonal Shop is also stocking Snowballs for gold! Throw them at your friends to have an exciting effect. Anyone hit with a snowball earns the Annoying Friends badge. The results of being hit with a Snowball will last until the end of your day, but you can also reverse them early by buying Salt from the Rewards column. Snowballs are available until January 31st. + p.small.muted by Shaner, Lemoness, and SabreCat + h5 12/25/2014 + tr + td + h5 December Subscriber Item Set + .promo_mystery_201412.pull-right + p The December Subscriber Item has been revealed: the Penguin Item Set! All December subscribers will receive the Penguin Hat and the Penguin Suit. You still have six days to subscribe) and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + p.small.muted by Lemoness + tr + td + h5 Seasonal Shop: Seasonal Outfits and Quests + .seasonalshop_winter2015.pull-right + p The Seasonal Shop has opened! The Seasonal Sorceress is stocking the seasonal edition versions of last year's winter outfits, now available for Gems instead of Gold, and the two winter quests, Trapper Santa and Find the Cub. The Seasonal Shop will only be open until January 31st, so don't wait! + p.small.muted by SabreCat and Lemoness + tr + td + h5 Flagging Posts + p You can now report inappropriate posts to moderators simply by clicking the new flag button next to the post. You should only report posts that violate the Community Guidelines and/or Terms of Service. Thanks for helping us to keep Habitica safe and pleasant for everybody! + p.small.muted by Alys, Blade, and Matteo + tr + td + h5 Winter Plot-Line Continues + p SabreCat's news is dire. "Most of my sabertooth friends have been impossible to reach, but one thing is clear: the prides have been disappearing from the Steppes. There are also reports that something drove the mammoths to early migration and disturbed the hibernation of the terrible lizards." + p He wraps his cloak around himself as another blast of frigid wind roars through the streets. An icy winter gale has been blowing from the north, rattling the window panes and setting the pets and mounts to trembling and howling. + p "I've never seen anything like it!" says Matt the Beast Master. "Something is terrifying all my animals - even the cacti, who are normally so mighty and brave! For something to frighten a cactus..." He shakes his head. + p The stress level in Habitica is mounting. + p.small.muted Missed the previous Winter Plot-line? Catch up on the story here! + p.small.muted by Lemoness + h5 12/21/2014 - Winter Wonderland Begins, Winter Class Outfits, Wintery Hair Colors, and NPC Decorations! + tr + td + h5 Winter Wonderland Begins! + p Winter has arrived, and the snow is gently drifting down over Habit City. Come celebrate with us! + tr + td + h5 Winter Class Outfits + .promo_winterclasses2015.pull-right + p From now until January 31st, limited edition outfits are available in the Rewards column. Depending on your class, you can be a Soothing Skater, Mage of the North, Gingerbread Warrior, or Icicle Drake! You'd better get productive to earn enough gold before they disappear. Good luck! + p.small.muted by Lemoness + tr + td + h5 Wintery Hair Colors + .promo_winteryhair.pull-right + p The Seasonal Edition Wintery Hair Colors are now available for purchase in the avatar customizations page! Now you can dye your avatar's hair Holly Green, Winter Star, Snowy, Peppermint, Aurora, or Festive. + p Seasonal Edition items recur unchanged every year, but they are only available to purchase during a short period of time. This is different from Limited Edition Items, which only recur if something is changed, such as the art or the price. These hair colors may remind some of you of the Holiday Hair Colors that were available last winter. The Holiday Hair Colors have been Retired in favor of the similar Seasonal Edition Wintery Hair Colors. Read more about the difference between Seasonal and Limited Edition items here! + p.small.muted by Lemoness, crystalphoenix, and mariahm + tr + td + h5 NPC Decorations + .npc_alex.pull-right + p Looks like the NPCs are really getting in to the cheery winter mood around the site. Who wouldn't? After all, there's plenty more to come! + p.small.muted by Lemoness + h5 12/17/2014 - Android App Update, Seasonal Shop, and Winter Plot-Line Continues + tr + td + h5 Android App Update: December Art and Buying Gems! + p The December backgrounds and penguin pet quest are now visible in the Android mobile app! Also, we’ve made it possible to buy gems directly from the app. Now you don’t have to switch to the website to stock up! + p You can get the Android app here! We will announce when the iOS app is available as well. + p.small.muted by negue + tr + td + h5 Seasonal Shop Tab + .seasonalshop_closed.pull-right + p Looks like a new tab has appeared under Inventory - the Seasonal Shop! It's still closed, but I've heard a rumor that it will open soon... + p.small.muted by SabreCat and Lemoness + tr + td + h5 Winter Plot-Line Continues + p Lemoness bursts into the Tavern, shaking icicles off her hat. "The Stoïkalm Steppes are completely abandoned!" she says, gulping the cup of tea that Daniel the Barkeep offers her. "No people milling about, no mounts and pets playing in the snow - and when I tried to fly closer, my dragon spooked and refused to land!" + p A cloaked figure in the corner steps into the fire light - SabreCat, a powerful adventurer from the north. "The Stoïkalm Steppes are the last home of many animals that have long since gone extinct elsewhere," he says. "The stoic Stoïkalmers would never flee their lands unless something was threatening their pets and mounts!" + p He turns to Lemoness. "I can speak the language of the northern beasts. I'll try to contact the roaming sabertooth prides to see if they know what happened." As he lopes off into the distance, a cold wind begins to blow. + p.muted Missed the first part of the Winter Plot-Line? Read it here. + h5 12/9/2014 - Penguin Pet Quest and Winter Plot-Line + tr + td + h5 Penguin Pet Quest + .quest_penguin.pull-right + p Habiticans wanted to go ice-skating, but instead, a giant penguin is freezing everything in sight! All we wanted was to go ice-skating... Can you get this penguin to chill out? If so, you'll be rewarded with some penguins of your own! + p.small.muted by Melynnrose, Breadstrings, Rattify, Painter de Cluster, Daniel the Bard, and Leephon + tr + td + h5 Winter Plot-Line + p Lemoness enters the Tavern with worrying news from the far north of Habitica. "Nobody's heard from the Stoïkalm Steppes for over a week," she says. "It's hard to imagine anything troubling the citizens there, since it's such a placid part of the continent... But just in case, maybe I should pay a visit." Sounds like a good plan to us! + + h5 12/3/2014 - Gifting Subscriptions And Gems, New Subscription Benefits, Mysterious Time Travelers, Steampunk Item Sets, And Block Subscriptions! + table.table.table-striped + tr + td + h5 Gifting Subscriptions And Gems + p You can now gift subscriptions and gems to other people (bottom-left in a user's profile window)! If you need holiday present ideas for the awesome Habiticans in your life, or just want to do something nice for someone, consider getting them a subscription to our fair site or tossing a few gems their way. They'll thank you, and so will we <3 + p.small.muted by Lefnire + tr + td + h5 New Subscription Benefits! + p We've added new benefits for long-term subscribers! Now for every 3 months that you are subscribed consecutively, your monthly gold-to-gem conversion cap will increase by 5, up to a total of 50 gems per month! Plus, for each three months of consecutive subscription, you will receive 1 Mystic Hourglass. What does that do? Read on! + p.small.muted by Lefnire + tr + td + h5 Mysterious Time Travelers + .npc_timetravelers.pull-right + p If you've received a Mystic Hourglass for being subscribed for 3 consecutive months, you can now summon the Mysterious Time Travelers to get you one Mystery Item Set from the past! Being subscribed for multiple consecutive months is the only way to get these past items if you missed them. They will never be available to non-subscribers. + p.small.muted by Lemoness, Megan, Lefnire + tr + td + h5 Steampunk Item Sets + .promo_mystery_3014.pull-right + p The Mysterious Time Travelers are also offering two brand-new Item Sets - the Steampunk Standard Item Set and the Steampunk Accessory Item Set! These Item Sets can only be obtained if you have a Mystic Hourglass. + p.small.muted by Megan + tr + td + h5 Block Subscriptions + p Don't want to wait for your consecutive months to stack up? You can now subscribe in a fixed block period of 1 month, 3 months, 6 months, or 1 year! If you subscribe for a block period of 1 year, you get a 20% discount. PLUS, you'll instantly get all the benefits of consecutive subscription for that time period (e.g. getting a block subscription for 6 months will instantly raise your monthly gold-to-gem cap by 10)! + p.small.muted by Lefnire + + h5 12/1/2014 - SITE OUTAGE EXPLANATION, DECEMBER BACKGROUNDS, AND DECEMBER MYSTERY ITEM SET + table.table.table-striped + tr + td + h5 Site Outage Explanation + p Many of you may have noticed that you could not access HabitRPG for a large portion of December 1st. This wasn't a problem on our end - it was due to an outage by DNSimple, the service that provides us with our domain. We're very sorry about any frustration that this caused! If you lost any stats, you can restore them using Settings > Site > Fix Character Values. For future reference, if you ever have trouble accessing HabitRPG, be sure to follow our official Twitter, @HabitRPG, for updates! Thank you for all of your supportive messages <3 + tr + td + h5 December Backgrounds + p There are three new avatar backgrounds in the Background Shop! Now your avatar can explore the South Pole, drift on an Iceberg, or admire the Winter Party Lights! + p.small.muted by McCoyly, RosieSully, and Holseties + tr + td + h5 December Mystery Item Set + p Hmmm! What could it be? All Habiticans who are subscribed during the month of December will receive the December Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness + + h5 11/26/2014 - Happy Thanksgiving! + table.table.table-striped + tr + td + h5 Happy Thanksgiving! + p It's Thanksgiving in Habitica! On this day Habiticans celebrate by spending time with loved ones, giving thanks, and riding their glorious turkeys into the magnificent sunset. Some of the NPCs are celebrating the occasion! + p.small.muted by Lemoness + tr + td + h5 Turkey Pet and Mount! + p Those of you who weren't around last Thanksgiving have received an adorable Turkey Pet, and those of you who got a Turkey Pet last year have received a handsome Turkey Mount! Thank you for using HabitRPG - we really love you guys <3 + p.small.muted by Lemoness + + h5 11/25/2014 + table.table.table-striped + tr + td + h5 November Item Set Revealed + p The November Subscriber Item has been revealed: the Feast and Fun Set! All November subscribers will receive the Pitchfork of Feasting and the Steel Helm of Sporting. You still have five days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + p.small.muted by Lemoness + tr + td + h5 Private Messaging Version 1.0 + p We're excited to announce a new feature: Private Messaging! Now you can send someone a PM by clicking the envelope icon in the bottom-left of their profile window . You can check your messages under Social > Inbox! This is a very rudimentary feature so far, only containing the ability to send messages, block people, and opt out. To read about some of the planned features for the future and make suggestions, check out this Trello card! + p.small.muted by Lefnire + + h5 11/18/2014 + table.table.table-striped + tr + td + h5 New Pet Quest: The Night-Owl! + p Habiticans are in the dark when a giant Night-Owl blots out the Tavern light! Can you drive it away in time to finish your all-nighter? If so, you may find some cute pet owls in the morning... + p.small.muted by Twitching, Lemoness, and Arcosine + + h5 11/13/2014 - Share Avatar To Social Media, Email Invites, First Mini Quest, And Data Tab + table.table.table-striped + tr + td + h5 Share Avatar To Social Media + p You can now automatically share your avatar and public profile to social media! Just hover over the picture and click the "Share" button in the right-hand corner. Show off your outfit, your achievements, and your profile picture! Note that your tasks, as always, remain 100% private. + p.small.muted by Lefnire + tr + td + h5 Invite Friends To Party Via Email + p Do you want to invite friends to join your party without inputting their User ID? Now you can send them an email directly from the party page - even if they don't have an account yet! + p.small.muted by Lefnire + tr + td + h5 Mini Quest: The Basi-List! + p Now when someone accepts your party invitation and joins your party, you will be given a Mini Quest: The Basi-List! Battle the Basi-List with your friends for an XP and GP reward. + p.small.muted by Arcosine and Redphoenix + tr + td + h5 Data Tab + p Now you can access the Data Display Tool and Export Data from the toolbar! + p.small.muted by ShilohT + + h5 11/12/2014 + table.table.table-striped + tr + td + h5 New Equipment Quest Line: The Golden Knight! + p The Golden Knight believes that she is the perfect Habitican, and that anyone who slips up in their quest for self-improvement is a lazy failure. Can you talk some sense into her - or will it come to blows? If you complete the entire quest line, you'll be rewarded with a legendary weapon... + p The first scroll in this quest line, "A Stern Talking-to," drops automatically at Level 40! If you're already over Level 40, you will automatically be awarded this quest - just check off a task and then check your inventory. + + h5 11/09/2014 - Facebook Login Fixed For Mobile And Community Guidelines To Chat + table.table.table-striped + tr + td + h5 Facebook Login Fixed For Mobile! + p Great news! If you use Facebook to log in to the mobile app, we've released an update so you no longer have to type in your UUID/API manually, misspelling things on your tiny keyboard and bemoaning your fate. Thank goodness! The Android update is out now, and the iOS update has been submitted and should be out soon. + tr + td + h5 Community Guidelines To Chat + p Before you can use any of the public chat features, you now have to agree to our Community Guidelines. We know they're long, but they're important, so please do read them if you haven't already. Plus, we worked hard to make them entertaining, and they were illustrated by many of our excellent artisans! + + h5 11/06/2014 + table.table.table-striped + tr + td + h5 Bailey: Costume Challenge Badges Awarded! + p The HabitRPG Costume Challenge Badges have been awarded! Thanks for your patience while we went through all the entries individually. You can see some of the entries on the HabitRPG blog already, and more will be added every week. + p IMPORTANT: some of the links that people provided did not work. If you entered the Challenge but even after refreshing the page you still don't have your badge, email leslie@habitrpg.com with the link to your costume and your avatar. (The costume and avatar must have been posted prior to November 1st to count.) + p Thanks to all our amazing participants! + + h5 11/05/2014- November Backgrounds And Beeminder Integration + table.table.table-striped + tr + td + h5 November Backgrounds + p There are three new avatar backgrounds in the Background Shop! Now your avatar can enjoy a Harvest Feast, admire a Sunset Meadow, or gaze at the Starry Skies! + p.small.muted by Kiwibot, Holsety1, and Draayder + tr + td + h5 Beeminder Integration + p We've integrated with Beeminder! Now you can beemind your To-Dos automatically :) Check it out! + p If you've never heard of Beeminder or want to learn more about what we've integrated so far, check out our blog post about it. Enjoy! + p.small.muted by Alys and Alice Monday + + h5 11/01/2014 + table.table.table-striped + tr + td + h5 November Mystery Item Set + .pull-right.inventory_present + p Cool! What could it be? All Habiticans who are subscribed during the month of November will receive the November Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + + h5 10/31/2014 - Monster Npcs, Last Day For Fall Festival Items, Last Day Of Community Costume Challenge, Last Day For Winged Goblin Item Set + table.table.table-striped + tr + td + h5 Last Day For Fall Festival Items + p Tomorrow everything will be back to normal in Habitica, so if you still have any remaining Fall Festival Items that you want to buy, you'd better do it now! The Seasonal Edition items won't be back until next fall, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot! + tr + td + h5 Last Day For Winged Goblin Item Set + p Reminder: this is the final day to subscribe and receive the Winged Goblin Item Set! If you want the Goblin Wings or the Goblin Gear, now's the time! Thanks so much for your support <3 + tr + td + h5 Last Day Of Community Costume Challenge + p It's the last day to post your pictures of yourself dressed up as your HabitRPG avatar if you want to get the Costume Challenge Badge! You can join the Challenge here. + tr + td + h5 Monster Npcs + p The NPCs have dressed up in their Halloween costumes! Be sure to stop by and check them all out. + + h5 10/27/2014 - Increased Gems For Contributors And Community Guidelines + table.table.table-striped + tr + td + h5 Community Guidelines + p Our community has grown and evolved over this past year and a half, and we realized that none of the community expectations had been codified anywhere. This has now changed with the implementation of the Community Guidelines. The Guidelines have been written by the staff and mods and illustrated by many of our talented artisans. We know they're long, but they contain all the expectations for participating in the public social side of HabitRPG, so please do read them carefully! Soon you'll have to agree to them to participate in any of the Public Chat. + p.small.muted by Alys, Lemoness, lefnire, redphoenix, SabreCat, paglias, Bailey, Ryan, Breadstrings, Megan, Daniel the Bard, Draayder, Kiwibot, Leephon, Luciferian, Revcleo, Shaner, Starsystemic, UncommonCriminal + tr + td + h5 Increased Gems For Contributors + p When we first started rewarding contributors, we decided to give them 2 gems per contributor tier. Since then, however, we've introduced many more things to buy, so we've decided to increase this number. All contributors now receive 3 gems/tier for tiers 1-3, and then 4 gems/tier for tiers 4-7, bringing the total number of gems you can earn by contributing to the site to 25. + p If you've already contributed, you've been given the gems that you're owed according to the new system. (For example, if you are a tier 3 contributor, you received 6 gems in the past and would receive 9 gems under the new system, so you've been awarded 3 gems to account for the difference.) + p Enjoy! + p.small.muted by Alys + + h5 10/25/2014 - October Item Set Revealed And Community Costume Challenge Reminder + table.table.table-striped + tr + td + h5 October Item Set Revealed + .promo_mystery_201410.pull-right + The October Subscriber Item has been revealed: the Winged Goblin Item Set! All October subscribers will receive the Goblin Gear and the Goblin Wings. You still have six days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + by Lemoness + h5 Community Costume Challenge Reminder + .achievement-costumeContest.pull-right + p Don't forget about the Community Costume Challenge! We've had some really amazing entries so far, and we're looking forward to seeing more over the next six days! All participants will receive the 2014 Costume Challenge Badge. + p You can view some of the awesome costumes here! + + h5 10/23/2014 + table.table.table-striped + tr + td + h5 Level 60 Equipment Quest: Recidivate Quest Line! + p All over Habitica, Bad Habits thought long-dead are rising up again - it must be the work of Recidivate, the wicked Necromancer! Can you complete your Dailies and fight down your Bad Habits to lay her to rest once more? If so, you'll reap some fine spoils... including some legendary armor! + p This quest line contains the hardest Boss Battle that we've released to date, so the first quest scroll drops for free at Level 60. If you're already Level 60 or over, you can unlock it for free, too - just check off any task and it will drop for you :) Good luck! You'll need it. + p.small.muted by Lemoness, Tru_, aurakami, Inventrix, and Baconsaur + + h5 10/15/2014 - Spider Pet Quest, Mobile App Update, Hide Grey Dailies, And Sortable Checklists! + table.table.tables-striped + tr + td + h5 New Pet Quest: The Icy Arachnid! + p Yikes, what's leaving these icy webs all over Habitica? It must be the Frost Spider from the newest Pet Quest: The Icy Arachnid! You can buy this quest in the Market. Don't worry, it will be around even after the Fall Festival ends :) + p.small.muted by Arcosine + tr + td + h5 Mobile App Update! + p The newest mobile app update is available on iOS and Android! Now when you're on your phone you can see Fall Festival items, get drop notifications, and view the pixel art of the bosses that you're battling! + p.small.muted By lefnire, negue, huarui, and paglias + tr + td + h5 Hide Grey Dailies + p You can now hide grey Dailies to de-clutter your list! There are tabs at the bottom of the Dailies column that you can toggle to see only which Dailies are still active. + p.small.muted by Gaelan, and Alys + tr + td + h5 Sortable Checklists + p Have you ever wanted to rearrange checklist order? Now you can! Simply drag and drop to sort your checklist points. + p.small.muted By gjoyner + + h5 10/7/2014 - Back-To-School Advice Challenge Winners And Jack-O-Lantern Pet! + table.table.table-striped + tr + td + h5 Back-To-School Advice Challenge Winners + p We had a ton of participants in our Back-To-School Advice Challenge, and we've finally sorted through and chosen the winners! Congratulations to + p DJ Ringis, The Writer, San Condor, Tavi Wright, Stepharuka, Clyc, samaeldreams, LitNerdy, Tritlo, Shansie, Han Solo, FrauleinNinja, Nortya, itsallaboutfalling, TomFrankly, [TGL] Dogg, Amanda, InfH, Evan950, and Mizuokami! You've all received your gems :) + p Thanks so much for participating! If you had fun, don't forget that the Community Costume Challenge is happening all October :) + tr + td + h5 Jack-O-Lantern Pet + p Habiticans have been carving lots of pumpkins recently - and it looks like one has followed you home! Everyone has received a pet Jack-O-Lantern! You can find it in the Stables :) + p.small.muted by Lemoness + + h5 10/3/2014- Spooky Sparkles, New Backgrounds, And Memory Leaks Almost Fixed! + table.table.table-striped + tr + td + h5 Spooky Sparkles + .pull-right + .inventory_special_spookDust + .achievement-spookDust + .spookman + p There's a new gold-purchasable item in the Market: Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do? + br + p If you have Spooky Sparkles cast on you, you will receive the "Alarming Friends" badge! Don't worry, any mysterious effects will wear off the next day.... or you can cancel them early by buying an Opaque Potion! + br + p Spooky Sparkles will only be in the Rewards store until October 31st, so stock up! + p.small.muted by Lemoness, lefnire + tr + td + h5 New Backgrounds Revealed: Haunted House, Graveyard, And Pumpkin Patch + p There are three new avatar backgrounds in the Background Shop! Now your avatar can sneak through a Haunted House, visit a creepy Graveyard, or carve jack-o-lanterns in a Pumpkin Patch! + p.small.muted by cecilyperez, Kiwibot, and Sooz + tr + td + h5 Memory Leaks Almost Fixed + p It took a ton of effort, but Tyler has fixed the largest memory leak that was crashing our servers! There are a few smaller ones that he’s still conquering one by one, but the fiercest monster has been slain. Ten thousand cheers for Tyler! You can read the technical description of how we’re fixing the leaks here, and for any JavaScript developers out there: we'd love your help! We’ll let you all know when we’ve fixed the problem for once and for all. + p.small.muted by lefnire + + h5 10/1/2014 - Seasonal Edition Skins, Seasonal Edition Hair Colors, Community Costume Challenge, Release Pets, and October Mystery Item! + table.table.table-striped + tr + td + h5 Seasonal Edition Hair + p The Seasonal Edition Haunted Hair Colors are now available for purchase in the avatar customizations page! Now you can dye your avatar's hair Pumpkin, Midnight, Candy Corn, Ghost White, Zombie, or Halloween. + p Seasonal Edition items recur unchanged every year, but they are only available to purchase during a short period of time. This is different from Limited Edition Items, which only recur if something is changed, such as the art or the price. Read more about the difference between Seasonal and Limited Edition items here! + p.small.muted by Lemoness, mariahm, and crystal phoenix + tr + td + h5 Seasonal Edition Skins + p The Supernatural Skin Set is here! Now your avatar can become an Ogre, Skeleton, Pumpkin, Candy Corn, Reptile, or Dread Shade. You can buy them from now until October 31st! + p These skins may remind some of you of the Spooky Skin set that was available briefly last fall. This is because we've received many requests for these Limited Edition skins from more recent players who were unable to purchase those skins. As a compromise, we have decided to Retire the Spooky Skin Set and release some similar but unique skins as part of the Supernatural Skin Set. That way, anyone who wants their avatar to be a pumpkin can have their way, but the original owners of the skin sets still have the unique items that they were promised. You can read more about the new Item Availability categories here. + p.small.muted by Lemoness + tr + td + h5 Community Costume Challenge + p The Community Costume Challenge has begun! Between now and October 31st, dress up as your avatar in real life and post a photo on social media to get the coveted Costume Challenge badge! Read the full rules on the Challenge page here. + p.small.muted by Lemoness + tr + td + h5 Release Pets and Mounts + p If you find collecting pets highly motivating and want to start over from zero, you're in luck! You can now release all your pets and mounts so that you can collect them again - and stack your Beastmaster achievement! + p.small.muted By Ryan + tr + td + h5 October Mystery Item + p Spooky! What could it be? All Habiticans who are subscribed during the month of October will receive the October Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness + + h5 9/25/2014 + table.table.table-striped + tr + td + h5 Update: Diagnosing Server Problems + p Our servers have been under a massive strain recently, and so we've created a Github ticket that you can follow for updates on the things we're doing to fix the problem. We've also written a blog post. We'll keep you updated with new developments as we strive to solve this problem. + p If you've lost any of your stats during this time, you can restore them using Settings > Site > Fix Character Values. Thank you so much for your patience and encouragement as we work to fight this fearsome foe! + p.small.muted by lefnire, Lemoness + tr + td + h5 September Item Set Revealed + .promo_mystery_201409.pull-right + p In happier news, the September Subscriber Item has been revealed: the Autumn Strider Item Set. All people who are subscribed before the end of September will receive the Autumn Antlers and the Strider Vest. Thank you so much for your support - it means a lot to us, especially right now. + p.small.muted by Lemoness + + h5 9/22/2014 - Fall Festival! Limited-Edition Outfits, Candy Food Drops, And Npc Dress-Up + p Autumn is upon us! The air is crisp, the leaves are red, and Habitica is feeling spooky. Come celebrate the Fall Festival with us... if you dare! + table.table.table-striped + tr + td + h5 Limited Edition Class Outfits + p Habiticans everywhere are dressing up. From now until October 31st, limited edition outfits are available in the Rewards column. Depending on your class, you can be a Witchy Wizard, Monster of Science, Vampire Smiter, or Mummy Medic! You'd better get productive to earn enough gold before your time runs out... + tr + td + h5 Candy Food Drops! + p You've received some Candy in your inventory in honor of the Fall Festival! Plus, for the duration of the Event, Habiticans may randomly find candy drops when they complete their tasks. These candies function just like normal food drops - can you guess which flavor your pet will like best? + tr + td + h5 NPC Dress-Up + p Looks like the NPCs are really getting in to the spooky autumnal mood around the site. Who wouldn't? + + h5 9/17/2014 - Rooster Pets, Party Sorting, And Back-To-School Challenge + table.table.table-striped + tr + td + h5 New Pet Quest: Rooster Rampage! + p There's a new pet quest in the Market! This monstrous rooster can't be quieted, and Habiticans are unable to sleep. Can you and your Party calm down this foul fowl? You'll be rewarded with Rooster eggs if you do! + p.small.muted by LordDarkly, Pandoro, EmeraldOx, extrajordanary, and playgroundgiraffe + tr + td + h5 Party Sorting! + p We've improved the preexisting party sort feature. Now you can sort your party members' avatars by level, backgrounds, and more! Simply go to Social > Party > Members and select from the drop-down menu. + p.small.muted by Alys and Viirus + tr + td + h5 Back-To-School Challenge! + p Don't forget that the 2nd Official HabitRPG Challenge is running right now - the Back-To-School Advice Challenge! Post your best tips for using HabitRPG during the Back-To-School season on social media for a chance at winning 60 gems. If you want to share it with the maximum number of people, you can use the #habitrpg and #backtoschool tags. You only have thirteen more days to enter. Good luck! + + h5 9/12/2014 - Official Back-To-School Challenge, Markdown In Checklists, And Help Tab + table.table.table-striped + tr + td + h5 Official Back-To-School Challenge + p We've launched our 2nd Official HabitRPG Challenge: the Back-To-School Advice Challenge! Use social media to tell us how you use HabitRPG to improve study habits, share stories of scholarly success with the app, or just give us your advice on using HabitRPG to be the best you can be. + p The contest ends on September 30th, and the 20 winners will each get 60 Gems! For the full rules, check out the challenge here. + h5 Markdown In Checklists + p Previously, you've been able to use markdown in your task names and in chat. Now you can also use it in checklists! Fill every aspect of your tasks with emoji, bolding, italics, or links. NOTE: If your checklists look strange, it's probably because they're accidentally using markdown now, so just edit them accordingly! Check out this Cheat Sheet for an explanation of how to use markdown. + p.small.muted By @negue + h5 Help Tab + p There's a new tab on the top bar that contains some helpful links. If you're confused about something, want to request a feature, or wonder if your question was asked before, you can now use the Help Tab's drop down menu! + p.small.muted By @Alys + + h5 9/10/2014 + table.table.table-striped + tr + td + h5 Get Ready For The Community Costume Challenge! + p We've got an exciting event coming up this October - the first-ever Community Costume Challenge! In the spirit of the season, Habiticans who dress up in real-life versions of their avatar's armor (or in any HabitRPG costume) will receive a special badge. (No, just wearing a colored shirt doesn't count. Where's the fun in that?) + p The Community Costume Challenge will start on October 1st, but we're announcing it early so that people have time to get their costumes together. + p Instructions on how to participate in the CCC will be posted on October 1st. We can't wait to see your costumes! + + h5 9/3/2014 + table.table.table-striped + tr + td + h5 New Backgrounds Revealed: Thunderstorm, Autumn Forest, Harvest Fields + p There are three new avatar backgrounds in the Background Shop! Now your avatar can conduct lightning in a Thunderstorm, stroll through an Autumn Forest, or cultivate their Harvest Fields! + p.small.muted by krajzega and Uncommon Criminal + + h5 9/1/2014 + table.table.table-striped + tr + td + h5 September Mystery Item + p Hmm, intriguing... All Habiticans who are subscribed during the month of September will receive the September Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + + h5 8/31/2014 + table.table.table-striped + tr + td + h5 Last Day For Sun Sorcerer Item Set + p Reminder: this is the final day to subscribe and receive the Sun Sorcerer Item Set! If you want the Sun Crown or the Sun Robes, now's the time! Thanks so much for your support <3 + + h5 8/26/2014 - August Mystery Item, Sortable Tags, Push To Top + table.table.table-striped + tr + td + h5 August Item Set Revealed! + .promo_mystery_201408.pull-right + p The August Subscriber Item has been revealed: the Sun Sorcerer Item Set! All August subscribers will receive the Sun Crown and the Sun Robes. You still have five days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + p.small.muted by Lemoness + tr + td + h5 Sortable Tags + p You can now sort your tags. Drag left-to-right and drop them into place. + p.small.muted by Fandekasp, lefnire + tr + td + h5 Push to Top + p We've added a small button in your tasks' one-click actions: Push to Top. This will help easily you sort your day's priorities, which may change from day-to-day. + p.small.muted by negue + + h5 8/19/2014 - Parrot Quest, Audio, And Mobile App Update! + table.table.table-striped + tr + td + h5 New Pet Quest: Help! Harpy! + p There's a new Pet Quest available in the Market! @UncommonCriminal is being held hostage by a Parrot-like Harpy. If you can find a way to help, you'll definitely get your hands on some coveted Parrot Eggs.... + p After you've purchased the scroll, battle the Boss by completing Habits and To-Dos. Be careful - every Daily that you skip will cause the Boss to attack your party! + p.small.muted by Uncommon Criminal and Token + tr + td + h5 Audio + p You can now enable sound effects for various website actions. Click the volume icon () and choose an "Audio Theme". For now, the only theme available is "Daniel The Bard" (@DanielTheBard designed this set); however, we'll release more themes over time (get involved here). We'll also add more sound effects, and possibly music, to the current set. + p.small.muted by DanielTheBard, Fandekasp + + tr + td + h5 New Mobile Update: Backgrounds and Guilds! + p We've updated the mobile app to include Backgrounds and Guilds! Now you can use the mobile app to join common interest groups, chat with like-minded people, and swap your avatar’s background. The iOS app is here, and the Android app is here. If you enjoy the direction that we’ve been taking the app, we would really appreciate it if you would leave us a review <3 Thank you! + p.small.muted by huarui, paglias + + h5 8/12/2014 + table.table.table-striped + tr + td + h5 New Equipment Quest: Attack Of The Mundane! + p There's a new Quest that will drop automatically for all users level 15 and up: the Dish Disaster, first quest in the Attack of the Mundane Questline! Scrub enchanted dirty dishes, battle the SnackLess Monster, and face off against the Evil Laundromancer. You might just be rewarded with a new piece of armor... + p As you complete each quest in this questline, you will be awarded with the quest scroll for the next part. There are three parts in total. Good luck! + small.muted by Arcosine, Kiwibot, Lemoness, Daniel the Bard, itokro + + h5 8/6/2014 + table.table.table-striped + tr + td + h5 New Backgrounds Revealed: Volcano, Dusty Canyon, Clouds + p There are three new avatar backgrounds in the Background Shop! Now your avatar can heat up inside a Volcano, wander through a Dusty Canyon, or soar through the Clouds! + + h5 8/4/2014 + table.table.table-striped + tr + td + h5 New Mobile Update: Checklist Editing And Bug Fixes! + p In case you missed it, we’ve released a new mobile update! You can edit checklists from the mobile app now. We also fixed some bugs, including the image problems on iOS! The Android app is here and the iOS app is here. + p You may have noticed that we've been releasing lots of updates recently. This is greatly due to two awesome members of our team! + p The first is superstar contributor Matteo, aka paglias. In addition to the mobile app, he contributes tons of code to the site, runs translations, and fixes bugs without blinking. We are so thankful to have him on the team! + p We also have another new mobile app contributor who has rocketed to Level 7 in record time: huarui! Huarui has been an absolute whirlwind with mobile app improvements. + p Give them both a giant round of applause! + + h5 8/2/2014 + table.table.table-striped + tr + td + h5 Dread Drag'on Defeated! Prizes: Mantis Shrimp Pet, Mantis Shrimp Mount, Food, and Badge + p We've done it! + p With a final last roar, the Dread Drag'on collapses and swims far, far away. Crowds of cheering Habiticans line the shores! We've helped Daniel rebuild his Tavern. + p But what's this? + p THE CITIZENS RETURN! + p Now that the Drag'on has fled, thousands of sparkling colors are ascending through the sea. It is a rainbow swarm of Mantis Shrimp... and among them, hundreds of merpeople! + p "We are the lost citizens of Dilatory!" explains their leader, Manta. "When Dilatory sank, the Mantis Shrimp that lived in these waters used a spell to transform us into merpeople so that we could survive. But in its rage, the Dread Drag'on trapped us all in the dark crevasse. We have been imprisoned there for hundreds of years - but now at last we are free to rebuild our city!" + p "As a thank you," says his friend @Ottl, "Please accept this Mantis Shrimp pet and Mantis Shrimp mount, this feast, and our eternal gratitude!" + tr + td + h5 August Mystery Item + p Ooh, mysterious! All Habiticans who are subscribed during the month of August will receive the August Mystery Item Set! It will be revealed on the 26th, so keep your eyes peeled. Thanks for supporting the site <3 + + h5 7/31/2014 + table.table.table-striped + tr + td + h5 Last Day for July Subscriber Set + .promo_mystery_201407.pull-right + p Reminder: this is the final day to subscribe and receive the Undersea Explorer Item Set! If you want the Undersea Explorer Helm or the Undersea Explorer Suit, now's the time! Thank you so much for your support <3 + tr + td + h5 Final Day for Limited Edition Summer Outfits + p Today is the last day of the Summer Splash Event, so it is the last day to buy the Limited Edition Outfits and the Rainbow Warrior Armor from the Rewards store. Get productive and spend that gold! + + hr + h5 7/25/2014 + table.table.table-striped + tr + td + h5 July Subscriber Item + .promo_mystery_201407.pull-right + p The July Subscriber Item has been revealed: the Undersea Explorer Item Set! All July subscribers will receive the Undersea Explorer Helm and the Undersea Explorer Suit. You still have six days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + + hr + h5 7/16/2014 + table.table.table-striped + tr + td + h5 Mobile App Update + p We’ve released another update to the mobile app! Now you can feed and select pets from the app. Carry your cute pets with you everywhere you go! The app is available for iOS here, and Android here. We’re continuing to release updates on a regular basis, so if you like the direction that we’ve been taking the app, please do consider leaving us a review. Thank you! + tr + td + h5 Neglect Strike: Tavern Art Swap + p The Dread Drag'on's Rage Bar has filled, and it has unleashed its Neglect Strike, leading to a new look for the Tavern! As a reminder, the Drag'on's rage will NEVER hurt any users or interfere with their ability to be productive, so the chat and inn are still functional. Even so... poor Daniel! + p All users are automatically damaging the Drag'on with their tasks. There is nothing bad that can happen to you or your account by being in this fight! + tr + td + h5 Dread Drag'on Prize Change: Food Reward! + p We've received a lot of feedback due to the weekend's confusion, and it seems that awarding GP and XP for defeating the world boss significantly unbalanced the game for newer players. Based on your feedback, XP and GP will no longer be awarded. Instead, players will receive an assortment of food! The Mantis Shrimps will still be awarded. + p If you were looking forward to receiving the 900XP and 90 GP upon completion of the battle, feel free to award it to yourself using Settings > Site > Fix Character Values when the battle is done! + p Thank you for bearing with us through the confusion. We love you guys. + hr + h5 7/12/2014 + table.table.table-striped + tr + td + h5 Wow, What'S Going On?! + p You may have noticed some strange things happening - extra gold? Drag'on defeated? No quest damage? + br + p Turns out the Dread Drag'on of Dilatory was harder to handle than we expected, and wreaked havoc on us last night by unexpectedly completing due to a glitch, throwing off party quest damage, and granting all of its rewards early! *shakes fist at terrible beast* + br + p The Drag’on is now back in the battle (read about how to fight it here), and the Mantis Shrimp pet/mount were removed until it is defeated for good. We are so sorry about the confusion! + br + p If you don’t want the 900 XP and 90 Gold, you can delete it using Settings > Site >Fix Character Values. You can also keep it as an apology from the devs for all the confusion! Do whatever is most motivating for you :) It will be granted again when the Drag'on is truly vanquished. + br + p The Drag’on also caused some glitches with party boss damage, but they should be repaired now. + br + p For a detailed breakdown of what happened, follow the issue here! + br + p Now let's fight this monster for real. + + hr + table.table.table-striped + tr + td + h5 July 11th: GaymerX reminder + p Reminder: Vicky (aka redphoenix) is at GaymerX at the InterContinental in San Francisco this weekend! She will have lots of promo codes for the Unconventional Armor Set. Our champion moderator Ryan will be there, too, and would love to meet you guys! Vicky will be wearing a dinosaur hoodie and a red shirt, and Ryan has a partially-shaved head and is in a wheelchair. + br + p There will be an official HabitRPG meet-up on Saturday 3:15-4:30 outside GX Panel Room A (Grand Ballroom AB (3F)). Come get your promo codes there! If you can't make it at that time, contact Vicky via email (vicky@habitrpg.com) or Twitter (@caffeinatedvee) to coordinate an alternative time and place to meet up at the convention! + small.muted 7/11/2014 + hr + + h5 7/9/2014 + table.table.table-striped + tr + td + h5 Happy Derby Day! + p In celebration of Derby Day, all Habiticans have received a seahorse egg! On this day, the worst of Habitica's ancient bugs were defeated, and so every year we celebrate. Let's ride through Dilatory on this fun day. + h5 New Pet Quest: Seahorse! + p But oh, no - it looks like a wild Sea Stallion is disrupting the races! Quickly, battle the Sea Stallion to calm him down, and you might just get your hands on some additional seahorse eggs... + p.small.muted - by Kiwibot and Lemoness + h5 Updated Stats Bars + p Based on your feedback, we’ve updated the design of the new status bars with an 8-bit style and improved accessibility. + p.small.muted - by BenManley + + hr + h5 7/3/2014 + table.table.table-striped + tr + td + h5 New backgrounds available: Coral Reef, Open Waters, Seafarer Ship + p Three new avatar backgrounds are available in the Background Shop! Now your avatar can swim in a coral reef, enjoy the open waters, or sail aboard a Seafarer Ship. Thanks so much for supporting the site! + tr + td + h5 Next Convention: GaymerX! + p HabitRPG's own Vicky Hsu will be at GaymerX, a game convention celebrating LGBTQ and gaming which is open to everyone, at the InterContinental in downtown San Francisco on July 11-13. (For more information, check out gaymerx.com!) Vicky will be giving away promo codes for the UnConventional Armor Set, so if you want to meet up with her (and snag some awesome capes), send a message to vicky@habitrpg.com or @caffeinatedvee on Twitter! + tr + td + h5 Rainbow Warrior Set! + p Even if you can't make it to the convention, you can still enjoy the two new armor pieces available for free in the Rewards Store: the Rainbow Warrior Helm and the Rainbow Warrior Armor! They were designed by our GaymerX friends and they look awesome. They'll be available until the end of the month, so enjoy! + + hr + h5 7/1/2014 + table.table.table-striped + tr + td + h5 WORLD BOSS: The Dread Drag'on of Dilatory! + p We should have heeded the warnings. + p Dark shining eyes. Ancient scales. Massive jaws, and flashing teeth. We've awoken something horrifying from the crevasse: **the Dread Drag'on of Dilatory!** Screaming Habiticans fled in all directions when it reared out of the sea, its terrifyingly long neck extending hundreds of feet out of the water as it shattered windows with its searing roar. + p "This must be what dragged Dilatory down!" yells Lemoness. "It wasn't the weight of the neglected tasks - the Dark Red Dailies just attracted its attention!" + p "It's surging with magical energy!" @Baconsaur cries. "To have lived this long, it must be able to heal itself! How can we defeat it?" + p Why, the same way we defeat all beasts - with productivity! Quickly, Habitica, band together and strike through your tasks, and all of us will battle this monster together. (There's no need to abandon previous quests - we believe in your ability to double-strike!) It won't attack us individually, but the more Dailies we skip, the closer we get to triggering its Neglect Strike - and I don't like the way it's eyeing the Tavern.... + hr + h5 6/30/2014 + table.table.table-striped + tr + td + h5 Last day for June Item Set! + p Reminder: this is the final day to subscribe and receive the Octomage Item Set! If you want the Octopus Robe or the Tentacle Helm, now's the time! Thanks so much for your support <3 + h5 Dilatory Update + p PLEASE! Habiticans, stop exploring the dark crevasse!!! Lemoness is really getting worried. There have been.... reports. + p Reports of something big. + p Reports of something terrifying. + p Reports of mysterious aftershocks, growing in intensity. + p Besides, exploring the dark and dangerous crevasse has become a source of procrastination. Let's get back to work, people! + + hr + h5 6/25/2014 + table.table.table-striped + tr + td + h5 June Subscriber Item + .pull-right.promo_mystery_201406.png + p The June Subscriber Item has been revealed: the Octomage Item Set! All June subscribers will receive the Octopus Robe and the Crown of Tentacles. You still have six days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + h5 Mobile App Update + p There's a new mobile app update available! In addition to bug fixes, there are many improvements, including a new button-based menu, tap-and-hold to edit tasks, and the return of stats and in-app avatar customization! Working on the mobile app is our biggest To-Do this summer, so expect more in the coming months. If you feel that the app is improving, we'd love it if you would take the time to give us a review and let us know what you think! + h5 Dilatory Update + p It's great to see Habiticans having fun exploring the ruins! There's just one small thing Lemoness wants us to avoid. She's noticed a lot of Habiticans trying to explore the fallen palace of the other side of the dark crevasse. She really doesn't feel that the crevasse is safe, so please don't swim so close. Other than that, enjoy your explorations! + + hr + h5 6/21/2014 + table.table.table-striped + tr + td + h5 Summer Mystery Update + p Lady Lemoness has returned at last! She startled beach-goers by charging up out of the waves and onto the shore, shouting "I found it!!! I found it!!! Oh, I just KNEW that citing it as impossible would make it a narrative probability!" + p Wait - found what? + h5 Summer Splash Event: The Lost City Of Dilatory! + p Dilatory was a lovely island city of ancient Habitica. It was a prosperous place, but as the wealth of the city grew, the inHabitants grew lazy and procrastinated on their Dailies and To-Dos... until the combined weight of their dark red tasks triggered a massive earthquake that sunk the city. Legends say that all of the inHabitants were transformed into sea creatures. + p The location of this city was lost to time... until now! + h5 Limited Edition Outfits! + p What's the fun of an underwater city if you can't explore it? Luckily, from now until July 31st, special Limited Edition Outfits are available for gold in the Rewards store! Spellcasters can transform themselves into Emerald Mermages and Reef Seahealers to swim among the ruins, while fighters may prefer to dress as Roguish Pirates and Daring Swashbucklers, riding above the city on magnificent ships. Work hard, and you can join them! + h5 NPC Dress-up + p The NPCs got so excited about the discovery of Dilatory that they've moved over there for the summer! Daniel the Innkeeper has opened a beachside tavern, and Alex is also selling by the shore! Meanwhile, Justin the Guide is giving tours aboard boats, Ian is dispensing quest wisdom from the deep ocean, Matt has opened stables for aquatic pets, and I am swimming about keeping everyone informed! + h5 But what caused the Earthquake? + p Only one piece of the mystery remains unsolved - what caused the second earthquake that unearthed the ancient Dailies? After all, the earthquake that destroyed Dilatory was caused by a build up of undone Dailies and To-Dos, wasn't it? + p But *we've* all been doing our tasks... + + hr + h5 6/14/2014 + table.table.table-striped + tr + td + h5 New Feature: Backgrounds! + p We're debuting a brand-new feature - backgrounds for your avatar! Stroll through a Summer Forest, lounge upon a warm Beach, or dance in a Fairy Ring. You can buy the backgrounds in the new Background tab, under User. Have fun! + h5 Summer Mystery Update + p It's been a while since we've seen Lemoness around - she's been a bit scarce since she started trying to decipher those ancient Dailies. We just stopped by her hut to check on her and found her..... missing? + br + p It looked like she'd taken her armor-enchanting crochet hook, but little else. There was a single scrawled note on the table: "I think I've translated it!!!! If I'm right, this is going to be QUITE the summer. Verifying claims - be back soon!!!" + br + p The only other thing on the table was an ancient map... with the corner ripped off. + small.muted 6/14/2014 + + hr + h5 6/10/2014 + table.table.table-striped + tr + td + h5 New Pet Quest: The Call Of Octothulu! + p There's a new pet in town! The dreaded Octothulu, sticky spawn of the stars, has emerged from a whirlpool in a dark cave by the sea. It's up to you and your party to banish the foul beast by being extra-productive! If you manage to defeat it, you might just find some octopus eggs... + h5 Earthquake Update + p Remember the strange earthquake we had recently? Well, this probably isn't related in any way, but Habiticans have recently noticed some mysterious black Dailies strewn along the beaches. Lemoness happily reports that they are scrawled upon with an ancient language, and that she is hard at work deciphering the script. More news as this develops! + + hr + h5 6/5/2014 + table.table.table-striped + tr + td + h5 June Mystery Item + p Wow, what could it be? All Habiticans who are subscribed during the month of June will receive the June Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + tr + td + h5 What Was That? + p Yikes! A mysterious earthquake has rocked Habitica! Luckily, nobody was hurt and there was no real damage, but our scholars are baffled. "We're not even IN a seismic zone," Lady Lemoness was heard muttering as she paged through an enormous tome. "There hasn't been an earthquake since.... but no, that's impossible." Well, if Lemoness says so, it must be true! Seems like it was just a false alarm. + + hr + h5 5/23/2014 + table.table.table-striped + tr + td + h5 May Mystery Outfit Revealed! + .pull-right.promo_mystery_201405.png + p The May Mystery Item Set has been revealed for all subscribers... Flame Wielder Item Set! All people who are subscribed this May will receive two items: + ul + li Flame of Mind (helm) + li Flame of Heart (armor) + p You still have eight more days to subscribe and get the item set. Thank you all for supporting us! We love you <3 + + + hr + h5 5/14/2014 + table.table.table-striped + tr + td + h5 The Rat King + p Habitica's streets are filled with the skittering of little paws... looks like there's a new Pet Quest available in the Market! Can you and your party defeat the Rat King? If so, there will be some eggs to reward you... + small.muted By: Pandah and Token + tr + td + h5 Level Cap Lifted + p You can now level up beyond 100, the 100-cap has been lifted! + small.muted By: Ryan + + hr + h5 5/5/2014 + table.table.table-striped + tr + td + h5 Mobile Update + p The new iOS update is live! You can download it here. If you have Android, the update is available here. + br + p Note: to edit a task or view checklists, swipe left on the task. We're working on click-to-view, we'd love some developer help! + br + p If you think the new app is an improvement, please consider rating us - many of our old reviews were (justifiably!) pretty low, especially on Apple, but we feel that this update is the first in a line of major improvements. Thanks for sticking with us! + tr + td + h5 The HabitRPG Chrome Extension + p Great news - we've fixed our Chrome Extension! Many thanks to new contributor @GoldBattle. Now you can set the times and dates you want to only browse productive sites. If you're procrastinating, it will automatically start docking your character's health; if you're hard at work, it will reward you with GP and XP! Read more about it here. + tr + td + p Also, a quick change - May's mystery item will now be revealed on the 23rd, instead of the 25th. Rejoice, impatient Habiticans! + + hr + h5 4/30/2014 + table.table.table-striped + tr + td + h5 May Mystery Item + p Ooh, how mysterious! All Habiticans who are subscribed during the month of May will receive the May Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 + hr + h5 4/30/2014 + table.table.table-striped + tr + td + h5 Mobile Update + p Great news! We've just released a big upgrade to our mobile app. One of our biggest priorities right now is improving the HabitRPG mobile experience, so this is an important first step. We've upgraded the framework to Ionic, which means a cleaner look and smoother feel, and best of all, it is now easier for the developers to add new updates and features! Read more about the upgrade here. + p The Android App is available here! The iOS app was submitted to the App Store, but Apple always takes a while to process things, so it may be a few more days. Let's hope they're quick this time around! We'll let you know when it goes through. + p Have a productive day! + tr + td + h5 Spread the Word Challenge + p Also, at long last the staff has finished sorting through the 1.5K+ participants in the Spread The World Challenge, and we are pleased (and so, so relieved) to finally announce a winner! + p Congratulations to ALEX KRALIE, the winner of the Spread The Word Challenge! 47K+ notes is truly momentous. + p A warm congratulation is also due to the runner-ups: sarahtyler, HannahAR, Raiyna, thefandomsarecool, Chickenfox, Anrisa Ryn, frabajulous, galdrasdottir, Judith Meyer, jazzmoth, RavenclawKiba, daraxlaine, Phiso, Billieboo, Victor Fonic, nikoftime, Aedra, amBarthes, and thaichicken! You guys are great <3 Thanks for helping to get the word out about HabitRPG! + tr + td + + h5 Spring Fling + p Reminder that today, 4/30, is the LAST DAY of the Spring Fling event! After today, you will no longer be able to purchase the Pastel Hair Set or the Limited-Edition class items. Additionally, the Egg Hunt scroll will no longer be available in the Market, although if you have started the quest, it will NOT disappear and you will be able to complete it at your leisure. + p It is also the last day to get the Twilight Butterfly Item Set before it disappears forever! If you want the Twilight Butterfly Wings or the Twilight Butterfly head accessory, this is your last chance to subscribe and get them. + p Happy Spring! + + hr + h5 4/25/2014 + table.table.table-striped + tr + td + h5 April Mystery Outfit Revealed! + //-img.pull-right(src='/marketing/promos/April14SAMPLE2.png') + p The April Mystery Item Set has been revealed for all subscribers... Twilight Butterfly Armor Set! All people who are subscribed this April will receive two items: + ul + li Twilight Butterfly Antennae + li Twilight Butterfly Wings! + p You still have five more days to subscribe and get the item set. Thank you all for supporting us! We love you <3 + + hr + h5 4/6/2014 + table.table.table-striped + tr + td + h5 The Great Egg Hunt + p A new quest is available in the Market between now and April 30th. Anyone who signed up before April 7th has one in their inventory free! + + hr + h5 4/3/2014 + table.table.table-striped + tr + td + h5 Limited Edition Pastel Hair Color Set + p A new set of hair colors has been released: the Pastel Set! Now your avatar can have flowing locks in Pastel Blue, Pastel Pink, Pastel Purple, Pastel Orange, Pastel Green, or Pastel Yellow! You will only be able to purchase these hair colors until April 30th, so don't miss out! + + hr + h5 4/2/2014 + table.table.table-striped + tr + td + h5 April Mystery Item + p What could it be? All people who are subscribed during the month of April will receive the April Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. + + hr + h5 April F... irst + table.table.table-striped + tr + td + p Hiya, folks! I'm Mrs. Carrot the Carroty Carrot, and I am your new announcer here at HabitRPG! I'm pleased to say that we've released several important updates that we are convinced will drastically improve user experience. Be sure to click around to admire our completely warranted and not at all arbitrary changes! In short, we were worried that the fantasy role-playing-game theme was getting somewhat overplayed, so we've decided unanimously to take the app in a different, more nutritious direction. + br + p After all, talking vegetables NEVER get old. + small.muted By @lemoness and @baconsaur + + hr + h5 03/31/2014 + table.table.table-striped + tr + td + p Reminder that today is the last day to get the Forest Walker Subscriber Set before it disappears forever! If you want the Forest Walker Armor or the Forest Walker Antler head accessory, this is your last chance to subscribe and get it. + + + hr + h5 03/25/2014 + table.table.table-striped + tr + td + h5 March Mystery Item Set + //-img.pull-right(src='/marketing/promos/201403_Forest_Walker.png') + p The March Mystery Item Set has been revealed for all subscribers... The Forest Walker Set! All people who are subscribed this March will receive two items: Forest Walker Armor and Forest Walker Antlers! + br + p The antlers are a head accessory, so they can be worn with any helmet. + br + p You still have five more days to subscribe and get the item set. Thank you all for supporting us! We love you <3 + tr + td + h5 PayPal Subscriptions + p We've added PayPal as a payment method for subscriptions. We still recommend the Card method, as Stripe (the processor we use) has a more stable API and better account management tools. However, we realize not everyone owns a credit/debit card, so there's PayPal for ya! + + hr + h5 03/22/2014 + table.table.table-striped + tr + td + h5 Spring Fling Event + p Spring has come to Habitica, and flowers have sprouted everywhere: in the Stables, in the Marketplace... and even in your character customization pages! + tr + td + h5 Head Accessories + p That's right - we've introduced Head Accessories! Your avatar can now bedeck their helms with colorful flowers. And that's not the only place to get head accessories…. + tr + td + h5 Limited Edition Class Outfits + p The Spring 2014 Limited Edition Class Outfits have been released! + p From now until April 30th, you will be able to use your gold to buy your current class' armor set from the Rewards store! You can be a Stealthy Kitty, a Mighty Bunny, a Magic Mouse, or a Loving Pup. If you switch classes (system unlocked at level 10), you will gain access to your new classes' armor set. Make sure to collect yours first, though! + p What are you waiting for? Go be productive and earn some gold! + tr + td + h5 New Un-Equip Mechanic + p Now to un-equip your gear, click the same item that you have currently equipped. We removed the "Base Equipment" tier for consistency with how un-equipping pets & mounts is handled, and to easily support adding new gear types. + tr + td + h5 Pet Quest: The Ghost Stag + p The meadows of Habitica are bursting with flowers, sunshine, and.... ominous mist? Looks like a ghost stag is keeping winter alive! Defeat him, and maybe you'll get an egg or three.... + tr + td + h5 And More To Come... + p This is only the beginning of all the treats that we've got in store for you. Stay tuned - and happy Spring Fling! + + hr + h5 03/18/2014 + table.table.table-striped + tr + td + h5 New Pet Quest Mechanics + p Great news - now it is easier to complete the Quest Pet sets! Pet Quest Bosses will now drop 3 eggs instead of 2. Additionally, after you have defeated a Pet Quest Boss two times, those eggs will be gem-purchasable in the market like all other eggs, so that your party doesn't have to replay the same quest over and over :) + tr + td + h5 WonderCon + p HabitRPG will be attending WonderCon from April 18th-20th! Come say hi to Tyler, Leslie, and Vicky, and chat about productivity and games. Tickets are available here. + p All the users who visit our booth will receive the Unconventional Armor Accessory Set! (It will also be available if we attend other cons in the future.) + tr + td + h5 LifeHacker Poll + p HabitRPG is in the running to be Lifehacker's #1 To-Do list manager! We've got some tough competition, so if you like our site, please help us out by voting for us here <3 + + hr + h5 03/02/2014 + table.table.table-striped + tr + td + h5 March Mystery Item + p Happy March! The awesome people who subscribe to HabitRPG will now receive the limited-edition March mystery item! The mystery item set will contain a stats-free costume piece that will only be available to the people who are subscribers this March. The set will be revealed on the 25th to everyone, but all people who are subscribers during the month of March will receive it. Get excited - and thank you so much for helping to support HabitRPG! We love you. + tr + td + h5 Hedgehog Quest + p A new pet has been introduced, the Hedgehog. You can find some eggs by battling the Hedgebeast Boss, a quest scroll available in the market. + + hr + h5 02/22/2014 + table.table.table-striped + tr + td + .pull-right.character-sprites(style='clear:both;width:90px;height:90px') + span.back_mystery_201402 + span.slim_armor_mystery_201402 + span.head_mystery_201402 + p The February Mystery Item Set has been revealed for all subscribers... The Winged Messenger Set! All people who are subscribed this February will receive three items: + ul(style='margin-left:15px') + li Winged Helm + li Messenger Robes + li and... Golden Wings! + p The wings are a brand-new type of item, called a Back Accessory! These items appear behind your avatar, so you can wear the wings with any outfit. You still have five more days to subscribe and get the item set. Thank you all so, so much for supporting HabitRPG! + + + hr + h5 02/18/2014 + table.table.table-striped + tr + td + h5 Translations + p Translations are well underway! Many of you should already be seeing HabitRPG in your own languages. If not, head here to see your language's progress or to help translate. + p + small.muted by @paglias, @Sinza-, @Luveluen, and more. + tr + td + h5 BountySource + p We’ve started using BountySource, a service which lets users post bounties on bug fixes and feature requests. Any features or bugs in HabitRPG you’ve been dying to see resolved? Post a bounty to attract contributor attention. Read more here. + p + small.muted by @Cole, @lefnire, @Ryan + + hr + h5 02/13/2014 + table.table.table-striped + tr + td + h5 Happy Valentine's Day! + p Help motivate all of the lovely people in your life by sending them a caring valentine. Valentines can be purchased for 10 gold from the Item Store. For spreading love and joy throughout the community, both the giver AND the receiver get a coveted "adoring friends" badge. Hooray! + p + small.muted By Lemoness and zoebeagle + + + hr + h5 02/12/2014 + table.table.table-striped + tr + td + h5 Chat & Invite Notifications + p Chat & group-invitation notifications are back! Miss them? They currently work for all chat updates in parties & guilds. Any devs willing to jump into @tagging in Tavern, see here. + tr + td + h5 Toolbar + p In order to make room for these notifs, we added a toolbar above the header. You can collapse the toolbar (far-right icon), but take care as Bailey notifs are inside the toolbar! + hr + h5 02/07/2014 + table.table.table-striped + tr + td + h5 February Mystery Item + p + .pull-right.inventory_present + | We're excited to announce a new feature a s a big thank-you to the awesome people who subscribe to HabitRPG! Every month, all subscribers will now receive a limited-edition mystery item! The mystery item will be a stats-free costume piece (like the Absurd Party Robes) that will only be available to the people who are subscribers each month. The February 2014 item will be revealed on the 23rd to everyone, but all people who are subscribers during the month of February will receive it. Subscribe now, get excited, and thank you so much for helping to support HabitRPG! We love you. + tr + td + h5 Critical Hammer Of Bug-Crushing + p + .pull-right.weapon_special_critical + | Some of you may have noticed that we periodically have some bugs that are nastier than the norm - the dreaded critical bugs. These monstrous apparitions have been snapping at the heels of many a player. For updates on what we're currently working on to improve site stability, read this link - and then jump in to help! Not only will programming assistance reward you with the usual contributor levels, but if you actually manage to fix a bug marked "critical," you will now receive the Critical Hammer of Bug-Crushing as your reward! + tr + td + + h5 Rainbow Hair Colors + p + .pull-right.customize-option.hair_bangs_1_rainbow + | Want to spruce up your avatar? Rainbow hair colors are now available! Dye your luscious locks purple, green, or even rainbow-striped, and passersby will look at you with envy. + tr + td + h5 Stability Update + p We've stabilized the site a lot (we're still working out kinks, but we're way better now). Follow the progress here, but here are some workarounds for now: + ul + li Click slower. VersionError is caused by clicking things off too fast (we're working on a fix). + li If you see an error, refresh before proceeding. + + p + small.muted By Lemoness, mariahm, crystalphoenix, aiseant, zoebeagle, cole, lefnire + + hr + h5 02/01/2014 + table.table.table-striped + tr + td + h5 Vice + p You awaken after the Winter Wonderland festivities and birthday celebrations with a smile. It's been a snowy, cheerful couple months, and the NPCs have finally returned to their normal attire. But today something is very wrong. Shadowy whisps cover the ground of Habitica, the sky has darkened. At the tavern you hear @DanielTheBard struming dark tales on his lute, and @Baconsaur peering into a mug, grumbling about her mounts swallowed in the shadows. They speak of the same thing: Vice, a dark an terrible foe. This new boss arc is a 3-part quest that requires level 30 to begin. Bring your strongest party members, and don't miss your dailies - there's a powerful weapon at the end! + p + small.muted by @baconsaur & @DanielTheBard + + hr + h5 01/30/2014 + table.table.table-striped + tr + td + h5 Happy Birthday, HabitRPG! + p The fair land of Habitica is two years old on January 31st! The NPCs are celebrating in style, and it looks like some of the staff is, too! Won't you join in? + tr + td + h5 Absurd Party Robtes + p As part of the festivities, Absurd Party Robes are available free of charge in the Item Store! Swath yourself in those silly garbs and don your matching hats to celebrate this momentous day. + tr + td + h5 Delicious Cake + p What would a birthday be without birthday cake in a myriad of flavors? Of course, pets are very picky, but luckily Lemoness and her team of bakers have plenty of slices to go around. Mmm, delicious! + tr + td + h5 Last Day of Winter Wonderland Event + p Also, just a reminder - January 31st is the final day of the Winter Wonderland event, so it's your last day to get the Limited Edition Winter Hair Colors, the Winter Outfits, the snowballs, and the Trapper Santa and Find the Cub quest scrolls. Remember that mid-progress Trapper Santa and Find the Cub quests will not abort, nor will you lose your scrolls - they will simply be removed from Alexander's marketplace. We hope that you've had a wonderful winter! + tr + td + h5 Birthday Bash Badge + p Finally, to commemorate the fun, all party participants receive a birthday badge! Polish it frequently and wear it fondly. + + p Thanks so much for being a part of the HabitRPG community. We love you guys, and we can't wait to have you at our sides in the upcoming year! Stay productive, Habiteers, and have an awesome day. + + p.muted By @lemoness + + hr + h5 01/28/2014 + table.table.table-striped + tr + td + h5 Group Plans + p We've begun adding plans for groups (parents, teachers, health & wellness administrators, etc). These plans will provide group leaders with more control, privacy, security, and support. Currently only the Organization Plan (top tier) is available (due to tech limitations believe it or not), and we'll be releasing the Family & Group plans later. Click the "Contact Us" buttons if you're interested, and we'll keep you updated! + tr + td + h5 Individual Plan + p We've introduced a $5/mo basic subscription plan. It comes with a number of perks, which you can see here. We'll likely add more benefits over time, follow the conversation here. + tr + td + h5 Perfect Day Achievement + p Now when you complete all your dailies, you stack this badge, plus and additional perk: you get a +(level/2) buff to all stats! + tr + td + h5 Spread The Word Challenge Update + p We have 1k+ submissions, holy cow! Great job everyone! Now, we need to go through these manually, so it will take a few days to a couple weeks to process. The challenge will stay open until we're done choosing our winners, but be sure to edit the To-Do with your submission URL before 1/31, as that's the cut-off date for processing. We'll send a Tweet out when the winner has been selected, so follow @habitrpg and stay tuned. + + hr + h5 01/25/2014 + table + tr + td + h5 Gryphon Quest + p A new pet has been introduced, the Gryphon. You can find some eggs by battling the Fiery Gryphon Boss, a quest scroll available in the market. + p + small.muted Note: we'll be fixing the beast-master achievement to work from the original 90 in coming days. Fear not current beast-masters, you'll get sorted soon! + p + small.muted By @baconsaur, @danielthebard + + + hr + h5 01/16/2014 + table.table.table-striped + tr + td + h5 "Spread The Word" Challenge Updates + p If you're not yet participating, check out the Spread The Word Challenge, which has a large prize and many winners. We've made some updates: upped the prize to 80 Gems for the top 20 posts, 100 Gems for the winner. Note: some people are listing their submission as a Tumblr reblog of someone else's post, often with added commentary. Though reblogs are greatly appreciated, we can only count original submissions. Read more challenge guidelines here. + tr + td + h5 Quest Deadlines + p To clear some confusion, you have until Jan 31, 2014 to purchase your quest scrolls, after 1/31 Alexander no longer sells them. You can still begin / finish your quests any time after. Thanks to @Cole, you're now allowed to purchase the Cub quest even if you haven't finished Trapper. Stock up! + + hr + h5 01/06/2014 + h4 WWE Part 4: Winter Classes + table.table.table-striped + tr + td + h5 Limited-Edition Winter Class Outfits + p Happy winter! Instead of a boring pair of earmuffs, why not use the gold that you earned with all your hard work to buy a Limited Edition class outfit? + p From now until January 31st, you will be able to use your gold to buy your current class' armor set from the Rewards store! You can be a Yeti Tamer, a Ski-Sassin, a Candy Cane Mage, or a Snowflake Healer. If you switch classes (system unlocked at level 10), you will gain access to your new classes' armor set. Make sure to collect yours first, though! + p What are you waiting for? Go be productive and earn some gold! + small.muted by @lemoness + tr + td + h5 Chat +1 + p You can now +1 chat messages in Tavern, Guilds, & Parties + tr + td + h5 Halls + p We've added the "Hall of Heroes" and "Hall of Patrons" here, which list our project contributors and Kickstarter backers. Want be amongst those immortalized in the Hall of Heroes? Lend us your sword! + + hr + h5 12/31/2013 + h4 Winter Wonderland Event Part 3: Party! + table.table.table-striped + tr + td + h5 Happy New Year! + p Happy New Year! Join the NPCs and Staff in showing off your new Absurd party hat.... and have a great night! + small.muted by @lemoness + tr + td + h5 Rebirth + p Nothing says New Year like a fresh start. Now when you reach level 50, Ultimate Gear, or BeastMaster, you can begin anew with the most prestigious of achievements: Rebirth. Read more here. But take heed! Scouts have reported monster sightings, harbinged by Trapper Santa. You may need all the strength you can muster come late January, Rebirth is for the hard-core. + small.muted by @SabreCat + tr + td + h5 Checklists + p Checklists are here! You can break your Dailies and To-Dos down into bite-size chunks. Their game mechanic takes some learning, so read more here. + small.muted by @lefnire + tr + td + h5 Task Icons & Markdown + p Task titles now support Markdown and Emoji, so you can create something like this. Read more here. + small.muted by @lefnire + + hr + h5 12/25/2013 + h4 Winter Wonderland Event Part 2: Rescue the Bears + table.table.table-striped + tr + td + h5 Quests & Bosses! + p A beast is roaring in the distant mountains, mysterious tracks have appeared in the snow. A new feature has been unlocked, Quests & Bosses. As a holiday present, HabitRPG gives you your first quest: "Trapper Santa". Check your inventory, you have until Jan 31 to complete it! + + p By @lefnire, @pandoro, @Shaners + + hr + + + h5 12/20/2013 + h4 Winter Wonderland Event Part 1: The Great Snowball Fight + p It's time for HabitRPG's biggest event yet - Winter Wonderland! The fun starts today, on the first day of winter, and ends on January 31st - HabitRPG's birthday. + p Get prepared to build new habits, earn fun drops, hold your party members accountable for their tasks, and decorate your avatar. Various features will be rolling out over the course of the event, so expect many updates! For starters... + table.table.table-striped + tr + td + h5 NPC Decorations + p Looks like everyone is really getting into the winter spirit! Check out the new NPC sprites. (And I heard a rumor that the final NPC might show up, just in time for the new year...) + tr + td + .customize-option.hair_bangs_1_winternight.pull-right + h5 Limited-Edition Holiday Hair-Colors + p Now your avatar can dye their hair Candy Cane, Frost, Winter Sky, or Holly! You'll only be able to purchase these hair colors until January 31st, when they will be retired. + tr + td + .shop_snowball.pull-right + h5 The Great HabitRPG Snowball Fight + p Yes, you can now buy snowballs and hurl them at all your friends... to, uh, help them improve their habits. How? Weeeeellll, let's just say that after getting walloped, they might find themselves needing some extra gold to escape their predicament... + //-span.shop_head_special_candycane.item-img.shop-sprite + tr + td + h5 More to Come + p A beast is roaring in the distant mountains, mysterious tracks have appeared in the snow, and Lemoness is furiously crocheting something sparkly. + p It's going to be a wild winter. + + p By @lemoness + + hr + + h5 12/16/2013 + p Good gracious, where do I start... + br + table.table.table-striped + tr + td + h4 Classes + p You can now be a Warrior, Rogue, Wizard, or Healer. See details here. + tr + td + h4 Armory & Costumes + p Once you select your new class, you're now equipped with your new class's apprentice gear. Fear not, your old gear is still available in your inventory! You can switch gear at any time, and wear a different costume than your equipment. See Armory & Costumes + tr + td + h4 New Customizations + p We now have a much wider selection of hair, shirt, facial-hair, body-size, etc. customizations. See Customizations v2 + tr + td + h4 300 Tier Gear + p All you $300 backers who have been waiting patiently, your gear is now in! Currently, only available to $300+ backers, but we'll add them as drops to the Boss system once that's released. See 300-tier + tr + td + h4 API v2 + p The API has been completely overhauled, and v2 comes with many more routes for a *full featured* API. v1 is no longer supported, take heed ye 3rd-party-ists! For the time being, basic routes are supported (such as up/down -scoring). v2 will be documented soon, and I'll ping you when. see APIv2 + hr + p By @lemoness @sabrecat @danielthebard @fuzzytrees @crystalphoenix @rosemonkeyct @fandekasp, and many more. (Who am I missing? We'll put up a CONTRIBUTORS.md soon) + + h5 12/7/20132 + table.table.table-striped + tr + td + h4 Mounts! + p You can now feed your pets and they'll grow into trusty steeds. Obtain food as new random drops, or you can hasten the process buy buying a saddle from Alexander. + // We may want to use their twitter handles, or something they prefer instead + hr + p. + By @lemoness @Shaners @baconsaur @RandallStanhope @ashjolliffe @fuzzytrees + + h5 11/27/2013 + table.table.table-striped + tr + td + h4 Turkey Event (by @lemoness) + p Say hi to our NPCs, dressed to impressed for Turkey day! Also - check your stable, you'll find a fun new pet. + tr + td + h4 Chat Enhancements (by @Nick Gordon) + p. + Chat can now use markdown, Emoji, and @-tagging. Some pointers on using markdown & Emoji at here. To use @-tagging, simply type '@' in chat. + tr + td + h4 Party Sorting (by @Fandekasp) + p. + You can now adjust the way you view your party members in the top bar. They can be sorted by level, number of pets, the date they joined the party, or just randomly. Also, level colors now reflect your contributor status. + tr + td + h4 Wiki Updates (by @bobbyroberts99) + p. + The HabitRPG wiki is being speedily updated. If you’re confused about anything, go check it out - it’s a treasure trove. + + h5 11/08/2013 + table.table.table-striped + tr + td. + Contrib Gear. You can now unlock new a top-tier gear set and pet by contributing (code, art, docs, etc) to HabitRPG. Read more + + h5 11/01/2013 + table.table.table-striped + tr + td. + Challenges! Compete with your party, guilds, or the tavern on certain tasks. Win gem prizes. Read more. + tr + td Backend overhaul, including bookmark-able paths throughout the application. Will pave the way towards improved performance. + + h5 10/22/2013 + table.table.table-striped + tr + td TRICK OR TREAT! It's Habit Halloween! Some of the NPCs have decorated for the occasion. Can you spot us? + tr + td Two gem-purchasable skin tones are now available! The Rainbow Skin Set is here to stay, but in honor of Halloween, we also have the LIMITED EDITION SPOOKY SKIN SET. You will only be able to purchase the Spooky Skin Set until November 10th, so if you want a monstrous avatar, now's the time to act! + tr + td Do note, skins won't work on mobile until the app is updated. We'll update Android ASAP, iPhone usually takes ~1wk to approve. + + h5 10/19/2013 + table.table.table-striped + tr + td New custom skin colors are now available! Go check them out in the Profile section. Also, the new mobile update, 0.0.10, is now available to download! It includes the new skin tones and the ability to hide or show your helm, among other things. + tr + td You can now sell un-wanted drops to Alex the Merchant. Trade those troves of eggs for gold! + + h5 09/01/2013 + table.table.table-striped + tr + td. + We re-wrote the website from the ground up + And in case you missed it, Android & iOS Apps are out! + Both apps and the website are open source, and we desparately need your help porting the rest of the features, and polishing off the bugs. Read this guide to getting started. + We're working on a system of Contributor Gear to reward the awesome people who help out, so stay tuned! + + h5 The Rewrite! (Mid August) + table.table.table-striped + tr + td. + Hello my Habiteers! I have some amazing news to share with you, it's huge! + Has Habit ever crashed for you? (Joke). Well we re-wrote the website from the ground up + to conquor those critical bugs once and for all (more from Tyler in a bit). If you haven't seen me for a while (due to a bug in the old site), be sure to catch up with me on the right side of the screen for any missed news. Importantly: + Android & iOS Apps are out!
+ tr + td. + They're open source, so help us make them awesome. As for the rewrite: not all features are yet ported, but don't worry - you're still getting drops and streak-bonuses in the background, even if you can't see them yet. + We'll be working hard to bring in all the missing features. And if you're not already, be sure to follow our updates on Tumblr (there are some fun member highlights recently). One more thing: if you are a Veteran of the old site, I have granted you a Veteran Wolf! Check your inventory :) + tr + td. + JavaScript developers! To me! We must finish vanquishing the old site, as not all features have been ported. + We rewrote Habit on AngularJS + Express. + We desparately need your help porting the rest of the features, and polishing off the bugs. Read this guide to getting started. + Thanks everyone for all your support and patience! + + h5 8/20/2013 + table.table.table-striped + tr + td. + Timezone + custom day start issues fixed, your dailies should now reset properly and in your own timezone. (This was vexing Android users particularly). If you're still experiencing issues, chime in here. + tr + td. + API developers, the above means that cron is automatically run for your users! Weee, they no longer have to log into the website to reset their dailies! + + h5 8/18/2013 + table.table.table-striped + tr + td. + The Mobile Apps are out! iOS app and Android. There's a bug with Android 2.3, follow the progress here. For more details, see our Tumblr post + tr + td + | Hey guys! Long time no see :) We want to make sure you guys have a better idea of what's going on behind the scenes, so we're going to be releasing + b weekly status reports + | of what we're currently working on! This weekend, we are working hard to fix the "Not Enough GP" bug, a cruel and greedy monster that has wrapped itself around the rewards box and is refusing to let anyone purchase anything. Rest assured that our heroic Tyler will slay this beast soon! Then it wiil be full steam ahead on the new site upgrade process. + a(target='_blank', href='http://habitrpg.tumblr.com/post/57627483715/news-about-upgrade-and-app') Read more about how that will work in this post here + + h5 6/03/2013 + table.table.table-striped + tr + td + a(target='_blank', href='https://trello.com/card/groups-guilds/50e5d3684fe3a7266b0036d6/84') Guilds! + | You can now belong to multiple groups, not just your party. There are public and private guilds, think "Subreddits" v "multiple friend groups". + + h5 5/27/2013 + table.table.table-striped + tr + td + | Get the "Helped Habit Grow" badge by + a(href='http://community.habitrpg.com/node/290', target='_blank') filling out this survey. + tr + td + a(href='http://habitrpg.tumblr.com/post/51476277225/upcoming-features-bugs-update-user-survey', target='_blank') New blog post + | about upcoming Guilds & Challenges features, & huge bug-fixes on the horizon. + + h5 5/25/2013 + table.table.table-striped + tr + td + | Code logic migrated to + a(target='_blank', href='https://github.com/habitrpg/habitrpg-shared') habitrpg-shared + | . See + a(target='_blank', href='https://github.com/lefnire/habitrpg/issues/1039') details here + | , but two takeaways: (1) keep an eye out and + a(href='http://community.habitrpg.com/content/submitting-bugs', target='_blank') report a problem + | if you experience any issues, (2) this is going to allow for much less buggy code (read previous link for reasoning). + + h5 5/12/2013 + table.table.table-striped + tr + td Renamed "Tokens" to "Gems". Tokens caused confusion. + h5 5/10/2013 + table.table.table-striped + tr + td + | Less harsh death: Used to be you lose everything, now you lose GP & one random gear piece, 1 level. We're working on a + a(_target='blank', href='https://trello.com/card/death-mechanic/50e5d3684fe3a7266b0036d6/204') really cool death mechanic here. + | , but this is a stop-gap so people don't lose heart presently. + tr + td Chat messages: can delete your own message, fix the duplicate messages issue. + + h5 5/9/2013 + table.table.table-striped + tr + td + a(_target='blank', href='https://trello.com/card/backer-gear/50e5d3684fe3a7266b0036d6/213') Backer Gear + | : There's a new top-tier gear set for Kickstarter Backers. $45+ gets new Shield, Helm, Armor. $70+ that plus Weapon. $80+ that plus Pet. Keep leveling my friends, get that gear! Discuss gear-unlocking mechanic + a(href='https://trello.com/card/backer-items-availability-mechanic/50e5d3684fe3a7266b0036d6/188', target='_blank') here + | , and if you're top-gear but not seeing backer stuff, message me from your KS profile. + + h5 5/7/2013 + table.table.table-striped + tr + td + a(_target='blank', href='https://trello.com/card/tags-categories/50e5d3684fe3a7266b0036d6/43') Tags + | . You can now categorize your tasks, eg "Work", "Home", "Morning", "Taxes", etc. + + h5 5/4/2013 + table.table.table-striped + tr + td + a(_target='blank', href='https://trello.com/card/streaks-consecutive-bonus/50e5d3684fe3a7266b0036d6/182') Streaks + | . You get a GP & drop-% increase the longer you hold daily streaks (they stack). You also get a stacking badge for each 21-day streak. + + h5 5/3/2013 + table.table.table-striped + tr + td + | Two new achievements: Beast Master & Ultimate Gear. Got ideas for more achievements? + a(target='_blank', href='https://trello.com/card/awards-badges/50e5d3684fe3a7266b0036d6/19') chime in here + + h5 5/2/2013 + table.table.table-striped + tr + td + a(target='_blank', href='https://trello.com/card/party-chat/50e5d3684fe3a7266b0036d6/267') Party Chat! + | also, Tavern Chat (LFG) + tr + td + a(target='_blank', href='https://trello.com/card/rest-in-tavern/50e5d3684fe3a7266b0036d6/14') Rest in Tavern + | (basic implementation, more to come) + tr + td. + NPCs! Bailey the Town Crier, Alexander the Merchant, Daniel the Tavern Keep. + tr + td + a(href='https://github.com/lefnire/habitrpg/issues/828') New "Game Options" layout + | (click your avatar to see) + + h5 3/27/2013 + table.table.table-striped + tr + td + | Drop system + pets overhaul ( + a(href='http://www.kickstarter.com/projects/lefnire/habitrpg-mobile/posts/439433') Blog Post + | | + a(href='https://trello.com/card/pets/50e5d3684fe3a7266b0036d6/166') Trello Card + | ) + + h5 3/21/2013 + table.table.table-striped + tr + td + a(href='https://github.com/lefnire/habitrpg/issues/585') More design tweaks to header & avatars + + h5 3/20/2013 + table.table.table-striped + tr + td + a(href='https://github.com/lefnire/habitrpg/issues/585') New Design + tr + td + a(href='https://trello.com/card/toggle-helm-visible/50e5d3684fe3a7266b0036d6/153') Toggle helm visible + tr + td + a(href='https://trello.com/card/toggle-header/50e5d3684fe3a7266b0036d6/241') Toggle Header + tr + td + a(href='https://trello.com/card/deletable-accounts/50e5d3684fe3a7266b0036d6/69') Deletable Accounts + tr + td + a(href='https://trello.com/card/undo-button/50e5d3684fe3a7266b0036d6/20') Undo Button + + h5 3/3/2013 + table.table.table-striped + tr + td + a(href='https://trello.com/card/custom-day-start/50e5d3684fe3a7266b0036d6/15') Add custom day start diff --git a/website/views/shared/profiles/achievements.jade b/website/views/shared/profiles/achievements.jade new file mode 100644 index 0000000000..b83385032f --- /dev/null +++ b/website/views/shared/profiles/achievements.jade @@ -0,0 +1,223 @@ +//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+ +if mobile + .item.item-divider=env.t('achievements') + +div(ng-if='::profile.backer.npc') + .achievement.achievement-helm + h5 + span.label.label-npc + | {{::profile.backer.npc}} + =env.t('npc') + small=env.t('npcText') + hr + +div(ng-if='::profile.contributor.level || user._id == profile._id') + .achievement.achievement-firefox(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') + |  + +aLink('http://habitrpg.wikia.com/wiki/Contributing_to_HabitRPG', env.t('readMore')) + | . + hr + +div(ng-if='::profile.backer.tier') + .achievement.achievement-heart + h5=env.t('kickstartName', {tier: "{{::profile.backer.tier}}"}) + small=env.t('kickstartText') + hr + +div(ng-if='profile.achievements.streak || user._id == profile._id') + .achievement.achievement-thermometer(ng-show='profile.achievements.streak') + div(ng-class='{muted: !profile.achievements.streak}') + h5(ng-show='profile.achievements.streak > 1 || !profile.achievements.streak') + + | {{profile.achievements.streak || 0 }}  + =env.t('streakName') + small(ng-show='profile.achievements.streak > 1 || !profile.achievements.streak')=env.t('streakText', {streaks: "{{profile.achievements.streak || 0 }}"}) + h5(ng-show='profile.achievements.streak == 1') + =env.t('streakSingular') + small(ng-show='profile.achievements.streak == 1')=env.t('streakSingularText') + hr + +div(ng-if='profile.achievements.perfect || user._id == profile._id') + .achievement.achievement-perfect(ng-show='profile.achievements.perfect') + div(ng-class='{muted: !profile.achievements.perfect}') + h5(ng-show='profile.achievements.perfect > 1 || !profile.achievements.perfect') + + | {{profile.achievements.perfect || 0 }}  + =env.t('perfectName') + small(ng-show='profile.achievements.perfect > 1 || !profile.achievements.perfect')=env.t('perfectText', {perfects: "{{profile.achievements.perfect || 0 }}"}) + h5(ng-show='profile.achievements.perfect == 1') + =env.t('perfectSingular') + small(ng-show='profile.achievements.perfect == 1')=env.t('perfectSingularText') + hr + +//-div(ng-if='profile.achievements.ultimateGear || user._id == profile._id') + .achievement.achievement-armor(ng-show='profile.achievements.ultimateGear') + div(ng-class='{muted: !profile.achievements.ultimateGear}') + h5=env.t('ultimGearName') + small=env.t('ultimGearText') + hr +// Remove the following when ultimate gear is fixed (https://github.com/HabitRPG/habitrpg/issues/2232): +div(ng-if='::user._id == profile._id') + div.muted + h5=env.t('ultimGearName') + small + +aLink('https://github.com/HabitRPG/habitrpg/issues/2232', 'Returning soon') + hr + +div(ng-if='profile.achievements.beastMaster || user._id == profile._id') + .achievement.achievement-rat(ng-show='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-show='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-show='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 + =env.t('rebirthOrb') + | {{profile.achievements.rebirthLevel}}. + hr + +div(ng-if='::profile.achievements.helpedHabit') + .achievement.achievement-tree + h5=env.t('helped') + small + =env.t('helpedText1') + |  + +aLink('http://community.habitrpg.com/node/290', env.t('helpedText2')) + 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 || user._id == profile._id') + // This is a very strange icon to use. revisit + .achievement.achievement-karaoke(ng-show='profile.achievements.challenges') + div(ng-class='{muted: !profile.achievements.challenges}') + h5=env.t('challengeWinner') + table.table.table-striped + tr(ng-repeat='chal in profile.achievements.challenges track by $index') + td {{::chal}} + hr + +div(ng-if='profile.achievements.quests || user._id == profile._id') + .achievement.achievement-alien(ng-show='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.spookDust') + .achievement.achievement-spookDust + h5=env.t('alarmingFriends') + small + =env.t('alarmingFriendsText', {spookDust: "{{profile.achievements.spookDust}}"}) + 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.valentine') + .achievement.achievement-valentine + h5=env.t('adoringFriends') + small + =env.t('adoringFriendsText', {cards: "{{::profile.achievements.valentine}}"}) + 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.costumeContest') + .achievement.achievement-costumeContest + h5=env.t('costumeContest') + small + =env.t('costumeContestText') + hr + +div(ng-if='::profile.achievements.nye') + .achievement.achievement-nye + h5=env.t('auldAcquaintance') + small + =env.t('auldAcquaintanceText', {cards: "{{::profile.achievements.nye}}"}) + hr + +div(ng-if='::profile.achievements.quests.stressbeast') + .achievement.achievement-stoikalm + h5=env.t('achievementStressbeast') + small + =env.t('achievementStressbeastText') + hr diff --git a/website/views/shared/profiles/stats.jade b/website/views/shared/profiles/stats.jade new file mode 100644 index 0000000000..df1d72404f --- /dev/null +++ b/website/views/shared/profiles/stats.jade @@ -0,0 +1,91 @@ +h4(class=mobile?'item item-divider':'')=env.t('stats') +table.table.table-striped + tr + td + strong=env.t('health') + | : {{profile.stats.hp | number:0}} / 50 + tr(ng-if='profile.stats.lvl >= 10 && !profile.preferences.disableClasses') + td + strong=env.t('mana') + | : {{profile.stats.mp | number:0}} / {{profile._statsComputed.maxMP}} + tr + td + strong=env.t('gold') + | : {{profile.stats.gp | number:0}} + tr + td + strong=env.t('level') + | : {{profile.stats.lvl}} + tr + td + strong=env.t('experience') + | : {{profile.stats.exp | number:0}} / {{Shared.tnl(profile.stats.lvl)}} + +// FIXME get translations working before can use this +unless mobile + h4.stats-equipment(class=mobile?'item item-divider':'',ng-show='user.flags.itemsEnabled')=env.t('equipment') + table.table.table-striped(ng-show='user.flags.itemsEnabled') + tr(ng-repeat='(k,v) in profile.items.gear.equipped', ng-init='piece=Content.gear.flat[v]', ng-show='piece') + td + strong {{piece.text()}}:  + span(ng-repeat='stat in ["str","con","per","int"]', ng-show='piece[stat]') {{piece[stat]}} {{stat.toUpperCase()}}  + +h4(class=mobile?'item item-divider':'')=env.t('attributes') +table.table.table-striped + each v,k in { str: {title:"strength",popover:'strengthText'},int: {title:"intelligence",popover:'intText'},con: {title:"constitution",popover:'conText'},per: {title:"perception",popover:'perText'} } + tr + td + span.hint(popover-title=env.t(v.title), popover-placement='right', popover=env.t(v.popover), popover-trigger='mouseenter', style='margin-right:3px') + strong=env.t(v.title) + span + strong : {{profile._statsComputed.#{k}}} + td + ul.list-unstyled(ng-init='g=Content.gear.flat;e=profile.items.gear.equipped') + li(ng-show='profile.stats.lvl > 1') + span.hint(popover-title=env.t('levelBonus'), popover-trigger='mouseenter', popover-placement='top', popover=env.t('levelBonusText'))=env.t('level') + |: {{(profile.stats.lvl - 1) / 2}}  + li(ng-show='g[e.weapon].#{k} + g[e.armor].#{k} + g[e.head].#{k} + g[e.shield].#{k} > 0') + span.hint(popover-title=env.t('equipment'), popover-trigger='mouseenter', popover-placement='top', popover=env.t('equipmentBonusText'))=env.t('equipment') + |: {{g[e.weapon].#{k} + g[e.armor].#{k} + g[e.head].#{k} + g[e.shield].#{k} || 0}}  + li(ng-show='profile._statsComputed.#{k} - profile.stats.buffs.#{k} - ((profile.stats.lvl - 1) / 2) - g[e.weapon].#{k} - g[e.armor].#{k} - g[e.head].#{k} - g[e.shield].#{k} - profile.stats.#{k} > 0') + span.hint(popover-title=env.t('classBonus'), popover-trigger='mouseenter', popover-placement='top', popover=env.t('classBonusText'))=env.t('classEquipBonus') + |: {{profile._statsComputed.#{k} - profile.stats.buffs.#{k} - ((profile.stats.lvl - 1) / 2) - g[e.weapon].#{k} - g[e.armor].#{k} - g[e.head].#{k} - g[e.shield].#{k} - profile.stats.#{k}}}  + li(ng-show='profile.stats.#{k} > 0') + span.hint(popover-title=env.t('allocatedPoints'), popover-trigger='mouseenter', popover-placement='top', popover=env.t('allocatedPointsText'))=env.t('allocated') + |: {{profile.stats.#{k} || 0}}  + li(ng-show='profile.stats.buffs.#{k} > 0') + span.hint(popover-title=env.t('buffs'), popover-trigger='mouseenter', popover-placement='top', popover=env.t('buffsText'))=env.t('buffs') + |: {{profile.stats.buffs.#{k} || 0}}  + tr(ng-if='profile.stats.buffs.stealth') + td + span.hint(popover-title=env.t('stealth'), popover-trigger='mouseenter', popover-placement='right', popover=env.t('stealthNewDay')) + strong + =env.t('stealth') + strong : {{profile.stats.buffs.stealth}}  + td + tr(ng-if='profile.stats.buffs.streaks') + td + strong.hint(popover-title=env.t('streaksFrozen'), popover-trigger='mouseenter', popover-placement='right', popover=env.t('streaksFrozenText'))=env.t('streaksFrozen') + td + +h4(class=mobile?'item item-divider':'',ng-if='user.flags.dropsEnabled')=env.t('pets') +table.table.table-striped(ng-if='user.flags.dropsEnabled') + tr + td + strong=env.t('petsFound') + | : {{_.size(profile.items.pets)}} + tr + td + strong=env.t('beastMasterProgress') + | : {{profile.petCount}}/90 + +h4(class=mobile?'item item-divider':'', ng-if='user.flags.dropsEnabled')=env.t('mounts') +table.table.table-striped(ng-if='user.flags.dropsEnabled') + tr + td + strong=env.t('mountsTamed') + | : {{_.size(profile.items.mounts)}} + tr + td + strong=env.t('mountMasterProgress') + | : {{profile.mountCount}}/90 diff --git a/website/views/shared/tasks/lists.jade b/website/views/shared/tasks/lists.jade new file mode 100644 index 0000000000..cd37d2962c --- /dev/null +++ b/website/views/shared/tasks/lists.jade @@ -0,0 +1,153 @@ +// Note here, we need this part of Habit to be a directive since we're going to be passing it variables from various +// parts of the app. The alternative would be to create new scopes for different containing sections, but that +// started to get unwieldy +script(id='templates/habitrpg-tasks.html', type="text/ng-template") + .tasks-lists.container-fluid + .row + .col-md-3.col-sm-6(bindonce='lists', ng-repeat='list in lists', ng-class='::{"rewards-module": list.type==="reward"}') + .task-column(class='{{list.type}}s') + + // Todos export/graph options + span.option-box.pull-right(ng-if='::main && list.type=="todo"') + a.option-action(ng-show='obj.history.todos', ng-click='toggleChart("todos")', tooltip=env.t('progress')) + span.glyphicon.glyphicon-signal + //a.option-action(ng-href='/v1/users/{{user.id}}/calendar.ics?apiToken={{user.apiToken}}', tooltip='iCal') + a.option-action(ng-click='notPorted()', tooltip='iCal', ng-show='false') + span.glyphicon.glyphicon-calendar + // + + // Header + h2.task-column_title {{list.header}} + + // Todo Chart + .todos-chart(ng-if='::list.type == "todo"', ng-show='charts.todos') + + // Add New + form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(obj[list.type+"s"],list)') + textarea(rows='6', task-focus='list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolderBulk}}', ng-if='list.bulk', ui-keydown='{"meta-enter ctrl-enter":"addTask(obj[list.type+\'s\'],list)"}', required) + input(type='text', task-focus='!list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolder}}', ng-if='!list.bulk', required) + button(type='submit', ng-disabled='new{{list.type}}form.$invalid') + span.glyphicon.glyphicon-plus + small.help-block.btn-link.pull-right(ng-click='toggleBulk(list)') + span(ng-if='!list.bulk')=env.t('addmultiple') + span(ng-if='list.bulk')=env.t('addsingle') + + mixin taskColumnTabs(position) + // Habits Tabs + div(ng-if='::main && list.type=="habit"', class='tabbable tabs-below') + ul.task-filter + li(ng-class='{active: list.view == "all"}') + a(ng-click='list.view = "all"')=env.t('all') + li(ng-class='{active: list.view == "yellowred"}') + a(ng-click='list.view = "yellowred"')=env.t('yellowred') + li(ng-class='{active: list.view == "greenblue"}') + a(ng-click='list.view = "greenblue"')=env.t('greenblue') + // Daily Tabs + div(ng-if='::main && list.type=="daily"', class='tabbable tabs-below') + // remaining/completed tabs + ul.task-filter + li(ng-class='{active: list.view == "all"}') + a(ng-click='list.view = "all"')=env.t('all') + li(ng-class='{active: list.view == "remaining"}') + a(ng-click='list.view = "remaining"')=env.t('due') + li(ng-class='{active: list.view == "complete"}') + a(ng-click='list.view = "complete"')=env.t('grey') + // Todo Tabs + div(ng-if='::main && list.type=="todo"', ng-class='::{"tabbable tabs-below": list.type=="todo"}') + if position=="bottom" + div(ng-show='list.view == "complete"') + .alert + =env.t('lotOfToDos') + button.task-action-btn.tile.spacious.bright(ng-click='user.ops.clearCompleted({})',popover=env.t('deleteToDosExplanation'),popover-trigger='mouseenter')=env.t('clearCompleted') + p!=env.t('beeminderDeleteWarning') + // remaining/completed tabs + ul.task-filter + li(ng-class='{active: list.view == "remaining"}') + a(ng-click='list.view = "remaining"')=env.t('remaining') + li(ng-class='{active: list.view == "dated"}', tooltip=env.t('datedNotSorted')) + a(ng-click='list.view = "dated"')=env.t('dated') + li(ng-class='{active: list.view == "complete"}') + a(ng-click='list.view = "complete"')=env.t('complete') + // Rewards Tabs + div(ng-if='::main && list.type=="reward"', class='tabbable tabs-below') + ul.task-filter + li(ng-class='{active: list.view == "all"}') + a(ng-click='list.view = "all"')=env.t('all') + li(ng-class='{active: list.view == "ingamerewards"}') + a(ng-click='list.view = "ingamerewards"')=env.t('ingamerewards') + + +taskColumnTabs('top') + + // Actual List + ul(class='{{list.type}}s main-list', ng-show='obj[list.type + "s"].length > 0', hrpg-sort-tasks) + include ./task + + // Static Rewards + ul.items.rewards(ng-if='main && list.type=="reward" && user.flags.itemsEnabled') + li.task.reward-item(ng-repeat='item in itemStore',popover-trigger='mouseenter', popover-placement='top', popover='{{item.notes()}}') + // right-hand side control buttons + .task-meta-controls + span.task-notes + span.glyphicon.glyphicon-comment + //left-hand size commands + .task-controls.task-primary + a.money.btn-buy.item-btn(ng-class='{highValue: item.value >= 1000}', ng-click='buy(item)') + span.shop_gold + span.reward-cost {{item.value}} + // main content + span(ng-class='::{"shop_{{item.key}} shop-sprite item-img": true}').reward-img + p.task-text {{item.text()}} + + // Events + ul.items.rewards(ng-if='main && list.type=="reward" && (user.items.special.snowball>0 || user.stats.buffs.snowball || user.items.special.spookDust>0 || user.stats.buffs.spookDust)') + + mixin specialSpell(k,canceler) + li.task.reward-item(ng-if='#{canceler ? "user.stats.buffs."+canceler : "user.items.special."+k+">0"}',popover-trigger='mouseenter', popover-placement='top', popover='{{Content.spells.special.#{k}.notes()}}') + .task-meta-controls + span.task-notes + span.glyphicon.glyphicon-comment + //left-hand size commands + .task-controls.task-primary + a.money.btn-buy.item-btn(ng-click='castStart(Content.spells.special.#{k})', ng-class='{active: Content.spells.special.#{k}.key == spell.key}') + if canceler + span.shop_gold + span.reward-cost {{Content.spells.special.#{k}.value}} + else + span.shop_spell(class='shop_#{k}') + span.reward-cost {{user.items.special.#{k}}} + // main content + p.task-text {{Content.spells.special.#{k}.text()}} + + +specialSpell('snowball') + +specialSpell('spookDust') + + +specialSpell('salt','snowball') + +specialSpell('opaquePotion','spookDust') + + // Spells + ul.items(ng-if='main && list.type=="reward" && user.stats.class && !user.preferences.disableClasses') + li.task.reward-item(ng-repeat='(k,skill) in Content.spells[user.stats.class]', ng-if='user.stats.lvl >= skill.lvl',popover-trigger='mouseenter', popover-placement='top', popover='{{skill.notes()}}') + .task-meta-controls + span.task-notes + span.glyphicon.glyphicon-comment + //left-hand size commands + .task-controls.task-primary + a.money.btn-buy.item-btn(ng-click='castStart(skill)', ng-class='{active: skill.key == spell.key}') + span.reward-cost + strong {{skill.mana}} + =env.t('mp') + // main content + span(ng-class='{"shop_{{skill.key}} shop-sprite item-img": true}') + p.task-text {{skill.text()}} + + br + + // Ads + div(ng-if='::main && !user.purchased.ads && !user.purchased.plan.customerId && list.type!="reward"') + span.pull-right + a(ui-sref='options.settings.subscription', popover=env.t('removeAds'), popover-trigger='mouseenter') + span.glyphicon.glyphicon-remove + // Habit3 + ins.adsbygoogle(ng-init='initAds()', style='display: inline-block; width: 234px; height: 60px;', data-ad-client='ca-pub-3242350243827794', data-ad-slot='9529624576') + + +taskColumnTabs('bottom') diff --git a/website/views/shared/tasks/task.jade b/website/views/shared/tasks/task.jade new file mode 100644 index 0000000000..9350697474 --- /dev/null +++ b/website/views/shared/tasks/task.jade @@ -0,0 +1,238 @@ +li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}', ng-show='shouldShow(task, list, user.preferences)') + // right-hand side control buttons + .task-meta-controls + + // Due Date + span(ng-if='task.type=="todo" && task.date') + span(ng-class='{"label label-danger":(moment(task.date).isBefore(_today, "days") && !task.completed)}') {{task.date | date:(user.preferences.dateFormat.indexOf('yyyy') == 0 ? user.preferences.dateFormat.substr(5) : user.preferences.dateFormat.substr(0,5))}} + + // Streak + |   + span(ng-show='task.streak') {{task.streak}}  + span(tooltip=env.t('streakCounter')) + span.glyphicon.glyphicon-forward + |   + + // Icons only available if you own the tasks (aka, hidden from challenge stats) + span(ng-if='!obj._locked') + a(ng-click='pushTask(task,$index,"top")', tooltip=env.t('pushTaskToTop')) + span.glyphicon.glyphicon-open + // a(ng-click='pushTask(task,$index,"bottom")', tooltip=env.t('pushTaskToBottom')) + // span.glyphicon.glyphicon-import + // // glyphicon-import or glyphicon-save or glyphicon-sort-by-attributes + a.badge(ng-if='task.checklist[0]', ng-class='{"badge-success":checklistCompletion(task.checklist) == task.checklist.length}', ng-click='collapseChecklist(task)', tooltip=env.t('expandCollapse')) + |{{checklistCompletion(task.checklist)}}/{{task.checklist.length}} + span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)') + // edit + a(ng-hide='task._editing', ng-click='editTask(task)', tooltip=env.t('edit')) + |   + span.glyphicon.glyphicon-pencil(ng-hide='task._editing') + |   + a(ng-hide='!task._editing', ng-click='editTask(task)', tooltip=env.t('cancel')) + span.glyphicon.glyphicon-remove(ng-hide='!task._editing') + |   + // save + a(ng-hide='!task._editing', ng-click='editTask(task);saveTask(task)', tooltip=env.t('save')) + span.glyphicon.glyphicon-ok(ng-hide='!task._editing') + |   + //challenges + span(ng-if='task.challenge.id') + span(ng-if='task.challenge.broken') + span.glyphicon.glyphicon-bullhorn(style='background-color:red;', ng-click='task._editing = true', tooltip=env.t('brokenChaLink') tooltip-placement='right') + |   + span(ng-if='!task.challenge.broken') + span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) + |   + // delete + a(ng-if='!task.challenge.id', ng-click='removeTask(obj[list.type+"s"], $index)', tooltip=env.t('delete')) + span.glyphicon.glyphicon-trash + |   + + // chart + a(ng-show='task.history', ng-click='toggleChart(obj._id+task.id, task)', tooltip=env.t('progress')) + span.glyphicon.glyphicon-signal + |   + // notes + span.task-notes(ng-show='task.notes && !task._editing') + span.glyphicon.glyphicon-comment + |   + + // left-hand side checkbox + .task-controls.task-primary(ng-if='!task._editing') + + // Habits + .task-actions(ng-if='::task.type=="habit"') + // score() is overridden in challengesCtrl to do nothing + a(ng-if='task.up', ng-click='applyingAction || score(task,"up")') + span.glyphicon.glyphicon-plus + a(ng-if='task.down', ng-click='applyingAction || score(task,"down")') + span.glyphicon.glyphicon-minus + + // Rewards + span(ng-show='task.type=="reward"') + a.money.btn-buy(ng-class='{highValue: task.value >= 1000}', ng-click='score(task, "down")') + span.shop_gold + span.reward-cost {{task.value}} + // Daily & Todos + span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"') + input.visuallyhidden.focusable(ng-if='$state.includes("tasks")', id='box-{{obj._id}}_{{task.id}}', type='checkbox', ng-model='task.completed', ng-change='task.type=="todo" && pushTask(task,$index,"bottom"); changeCheck(task)') + input.visuallyhidden.focusable(ng-if='!$state.includes("tasks")', id='box-{{obj._id}}_{{task.id}}', type='checkbox') + label(for='box-{{obj._id}}_{{task.id}}') + + // main content + div.task-text(ng-dblclick='task._editing ? saveTask(task) : editTask(task)') + markdown(text='task.text',target='_blank') + //-| {{task.text}} + + div(ng-if='task.checklist && !$state.includes("options.social.challenges") && !task.collapseChecklist && !task._editing') + fieldset.option-group.task-checklist + label.checkbox(ng-repeat='item in task.checklist') + input(type='checkbox',ng-model='item.completed',ng-change='saveTask(task,true)') + markdown(text='item.text',target='_blank') + + // edit/options dialog + div(ng-if='task._editing') + .task-options + + // Broken Challenge + .well(ng-if='task.challenge.broken') + div(ng-if='task.challenge.broken=="TASK_DELETED"') + p=env.t('brokenTask') + p + a(ng-click='unlink(task, "keep")')=env.t('keepIt') + |    + a(ng-click="removeTask(obj[list.type+'s'], $index)")=env.t('removeIt') + div(ng-if='task.challenge.broken=="CHALLENGE_DELETED"') + p + |  + =env.t('brokenChallenge') + p + a(ng-click='unlink(task, "keep-all")')=env.t('keepThem') + |  |  + a(ng-click='unlink(task, "remove-all")')=env.t('removeThem') + div(ng-if='task.challenge.broken=="CHALLENGE_CLOSED"') + p + !=env.t('challengeCompleted', {user: "{{task.challenge.winner}}"}) + p + a(ng-click='unlink(task, "keep-all")')=env.t('keepThem') + |  |  + a(ng-click='unlink(task, "remove-all")')=env.t('removeThem') + //div(ng-if='task.challenge.broken=="UNSUBSCRIBED"') + p=env.t('unsubChallenge') + p + a(ng-click="unlink(task, 'keep-all')")=env.t('keepThem') + |  |  + a(ng-click="unlink(task, 'remove-all')")=env.t('removeThem') + + // Checklists + .task-checklist-edit(ng-if='!$state.includes("options.social.challenges")') + ul + li + button(type='button', ng-if='!task.checklist[0] && (task.type=="daily" || task.type=="todo")',ng-click='addChecklist(task)') + span.glyphicon.glyphicon-tasks + span=env.t('addChecklist') + form.checklist-form(ng-if='task.checklist') + fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') + legend.option-title + span.hint(popover=env.t('checklistText'),popover-trigger='mouseenter',popover-placement='bottom')=env.t('checklist') + ul(hrpg-sort-checklist) + li(ng-repeat='item in task.checklist') + //input(type='checkbox',ng-model='item.completed',ng-change='saveTask(task,true)') + //-,ng-blur='saveTask(task,true)') + span.checklist-icon.glyphicon.glyphicon-resize-vertical() + input(type='text',ng-model='item.text',ui-keydown="{'8 46':'removeChecklistItem(task,$event,$index)'}",ui-keyup="{'13':'addChecklistItem(task,$event,$index)','38 40':'navigateChecklist(task,$index,$event)'}") + a(ng-click='removeChecklistItem(task,$event,$index,true)') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + + form(ng-submit='saveTask(task,false,true)') + // text & notes + fieldset.option-group + label.option-title=env.t('text') + input.option-content(type='text', ng-model='task.text', required, ng-disabled='task.challenge.id') + + label.option-title=env.t('extraNotes') + textarea.option-content(rows='3', ng-model='task.notes', ng-model-options="{debounce: 1000}") + + // if Habit, plus/minus command options + fieldset.option-group.plusminus(ng-if='task.type=="habit" && !task.challenge.id') + legend.option-title=env.t('direction/Actions') + span.task-checker + input.visuallyhidden.focusable(id='{{obj._id}}_{{task.id}}-option-plus', type='checkbox', ng-model='task.up') + label(for='{{obj._id}}_{{task.id}}-option-plus') + span.task-checker + input.visuallyhidden.focusable(id='{{obj._id}}_{{task.id}}-option-minus', type='checkbox', ng-model='task.down') + label(for='{{obj._id}}_{{task.id}}-option-minus') + + // if Daily, calendar + fieldset(ng-if='::task.type=="daily"', class="option-group") + legend.option-title=env.t('repeat') + ul.repeat-days(bindonce) + // note, does not use data-toggle="buttons-checkbox" - it would interfere with our own click binding + li + button(ng-class='{active: task.repeat.su}', type='button', ng-click='task.challenge.id || (task.repeat.su = !task.repeat.su)') {{::moment.weekdaysMin(0)}} + li + button(ng-class='{active: task.repeat.m}', type='button', ng-click='task.challenge.id || (task.repeat.m = !task.repeat.m)') {{::moment.weekdaysMin(1)}} + li + button(ng-class='{active: task.repeat.t}', type='button', ng-click='task.challenge.id || (task.repeat.t = !task.repeat.t)') {{::moment.weekdaysMin(2)}} + li + button(ng-class='{active: task.repeat.w}', type='button', ng-click='task.challenge.id || (task.repeat.w = !task.repeat.w)') {{::moment.weekdaysMin(3)}} + li + button(ng-class='{active: task.repeat.th}', type='button', ng-click='task.challenge.id || (task.repeat.th = !task.repeat.th)') {{::moment.weekdaysMin(4)}} + li + button(ng-class='{active: task.repeat.f}', type='button', ng-click='task.challenge.id || (task.repeat.f= !task.repeat.f)') {{::moment.weekdaysMin(5)}} + li + button(ng-class='{active: task.repeat.s}', type='button', ng-click='task.challenge.id || (task.repeat.s = !task.repeat.s)') {{::moment.weekdaysMin(6)}} + + // if Reward, pricing + fieldset.option-group.option-short(ng-if='task.type=="reward" && !task.challenge.id') + legend.option-title=env.t('price') + input.option-content(type='number', size='16', min='0', step="any", ng-model='task.value') + .money.input-suffix + span.shop_gold + + // if Todos, the due date + fieldset.option-group(ng-if='task.type=="todo" && !task.challenge.id') + legend.option-title=env.t('dueDate') + input.option-content.datepicker(type='text', datepicker-popup='{{user.preferences.dateFormat}}', ng-model='task.date', is-open='datepickerOpened', ng-click='datepickerOpened = true') + + // Tags + fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') + p.option-title.mega(ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags') + label.checkbox(ng-repeat='tag in user.tags', ng-class="{visuallyhidden: task._tags}") + input(type='checkbox', ng-model='task.tags[tag.id]') + markdown(text='tag.name') + + // Advanced Options + span(ng-if='::task.type!="reward"') + p.option-title.mega(ng-click='task._advanced = !task._advanced', tooltip=env.t('expandCollapse'))=env.t('advancedOptions') + fieldset.option-group.advanced-option(ng-class="{visuallyhidden: task._advanced}") + legend.option-title + a.hint.priority-multiplier-help(href='https://trello.com/card/priority-multiplier/50e5d3684fe3a7266b0036d6/17', target='_blank', popover-title=env.t('difficultyHelpTitle'), popover-trigger='mouseenter', popover=env.t('difficultyHelpContent'))=env.t('difficulty') + ul.priority-multiplier + li + button(type='button', ng-class='{active: task.priority==1 || !task.priority}', ng-click='task.challenge.id || (task.priority=1)')=env.t('easy') + li + button(type='button', ng-class='{active: task.priority==1.5}', ng-click='task.challenge.id || (task.priority=1.5)')=env.t('medium') + li + button(type='button', ng-class='{active: task.priority==2}', ng-click='task.challenge.id || (task.priority=2)')=env.t('hard') + //span(ng-if='task.type=="daily" && !task.challenge.id') + span(ng-if='task.type=="daily"') + legend.option-title.pull-left=env.t('restoreStreak') + input.option-content(type='number', ng-model='task.streak') + + div(ng-if='(user.preferences.allocationMode == "taskbased" && user.preferences.automaticAllocation) || $state.is("options.social.challenges")') + legend.option-title.pull-left=env.t('attributes') + ul.task-attributes + li + button(type='button', ng-class='{active: task.attribute=="str"}', ng-click='task.attribute="str"')=env.t('physical') + li + button(type='button', ng-class='{active: task.attribute=="int"}', ng-click='task.attribute="int"')=env.t('mental') + li + button(type='button', ng-class='{active: task.attribute=="con"}', ng-click='task.attribute="con"')=env.t('social') + li + button(type='button', ng-class='{active: task.attribute=="per"}', ng-click='task.attribute="per"', popover=env.t('otherExamples'), popover-trigger='mouseenter', popover-placement='top')=env.t('other') + + .save-close + button(type='submit')=env.t('saveAndClose') + + div(class='{{obj._id}}{{task.id}}-chart', ng-show='charts[obj._id+task.id]') diff --git a/website/views/static/api.jade b/website/views/static/api.jade new file mode 100644 index 0000000000..f4bbd576f3 --- /dev/null +++ b/website/views/static/api.jade @@ -0,0 +1,98 @@ +doctype html +html + head + title Swagger UI + link(href='//fonts.googleapis.com/css?family=Droid+Sans:400,700', rel='stylesheet', type='text/css') + link(href='/bower_components/swagger-ui/dist/css/reset.css', media='screen', rel='stylesheet', type='text/css') + link(href='/bower_components/swagger-ui/dist/css/screen.css', media='screen', rel='stylesheet', type='text/css') + link(href='/bower_components/swagger-ui/dist/css/reset.css', media='print', rel='stylesheet', type='text/css') + link(href='/bower_components/swagger-ui/dist/css/screen.css', media='print', rel='stylesheet', type='text/css') + + script(src='/bower_components/swagger-ui/dist/lib/shred.bundle.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/jquery-1.8.0.min.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/jquery.slideto.min.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/jquery.wiggle.min.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/jquery.ba-bbq.min.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/handlebars-1.0.0.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/underscore-min.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/backbone-min.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/swagger.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/swagger-ui.js', type='text/javascript') + script(src='/bower_components/swagger-ui/dist/lib/highlight.7.3.pack.js', type='text/javascript') + script(type='text/javascript'). + $(function () { + window.swaggerUi = new SwaggerUi({ + url: "/api/v2/api-docs", + dom_id: "swagger-ui-container", + supportedSubmitMethods: ['get', 'post', 'put', 'delete'], + onComplete: function(swaggerApi, swaggerUi){ + if(console) { + console.log("Loaded SwaggerUI") + } + $('pre code').each(function(i, e) {hljs.highlightBlock(e)}); + }, + onFailure: function(data) { + if(console) { + console.log("Unable to Load SwaggerUI"); + console.log(data); + } + }, + docExpansion: "none" + }); + + debugger; + + $('#input_apiKey').change(function() { + var key = $('#input_apiKey')[0].value; + console.log("apiKey: " + key); + if(key && key.trim() != "") { + console.log("added key " + key); + window.authorizations.add("apiKey", new ApiKeyAuthorization("x-api-key", key, "header")); + } + }) + $('#input_uuid').change(function() { + var key = $('#input_uuid')[0].value; + console.log("uuid: " + key); + if(key && key.trim() != "") { + console.log("added key " + key); + window.authorizations.add("uuid", new ApiKeyAuthorization("x-api-user", key, "header")); + } + }) + window.swaggerUi.load(); + }); + body.swagger-section + #header + .swagger-ui-wrap + a#logo(href='http://swagger.wordnik.com') HabitRPG API Documentation + + .swagger-ui-wrap(style='padding:50px') + form#api_selector + .input + input#input_uuid(placeholder='UUID', name='uuid', type='text') + input#input_apiKey(placeholder='API Key', name='apiKey', type='password') + //.input + input#input_baseUrl(placeholder='http://example.com/api', name='baseUrl', type='text') + //.input + a#explore(href='#') Explore + br + h2 Two API Types + p HabitRPG's API is meant for two different audiences: (1) extensions and scripts, and (2) full-fledged applications. Extensions and scripts can utilize Habit's up/down scoring for individual tasks. An example of this in action is the Chrome Extension, which up-scores you for visiting productive websites, and down-scores you for visiting procrastination websites. Other examples currently in use are Pomodoro, Anki, and Github scripts - which up-score you for good behavior and downscore you for bad behavior - see the list. The second API consumer is for full-fledge applications, which need read / write access to the entire user document. An example of this would be Mobile Apps or Desktop application. + h2 Extensions / Scripts + p HabitRPG has a simple API for up-scoring and down-scoring third party Habits: POST /api/v2/user/tasks/{id}/{direction} (headers x-api-user and x-api-key required). + h4 Example + p curl -X POST -H "x-api-key: YOUR_API_TOKEN" -H "x-api-user: YOUR_USER_ID" https://habitrpg.com/api/v2/user/tasks/productivity/up + p Note: You may need to add --compressed -H "Content-Type:application/json" to your curl if you get errors. + ul + li POST to the URL /api/v2/user/tasks/{id}/{direction} + ul + li {direction} is 'up' or 'down' + li {id} is a unique identifier for a task, which you make up, consisting of lowercase letters. Try to make it something common, like 'productivity' or 'fitness' - because other services may piggy-back off your task. For example, the Chrome extension down-scores a productivity task when you visit vice websites (reddit, 9gag, etc). However, Pomodoro up-scores productivity when you complete a task. So the two services share a single task to score your overall productivity. If the task doesn't yet exist, it is created the first time you POST to this URL. + li apiToken (POST body) required + h2 Full API + p All API requests should be prefaced by https://habitrpg.com. Every authenticated request should include two headers. Your api key (x-api-key) and your user id (x-api-user). Do not include {} braces in your header (-H 'x-api-user: a94b6d9d-6b64-43ae-856c-2c3f211bd426') + h2 Requirements: + p The base-url for all routes is /api/v2. So /user actions will be at https://habitrpg.com/api/v2/*. You need to send x-api-user and x-api-key headers for each request. + p For create & edit paths (PUT & POST), you'll need to know the schema of the object you're trying to create or edit. See Schema definitions here + p If any of the documentation is lacking or you're having trouble with it, please post an issue to Github + #message-bar.swagger-ui-wrap + #swagger-ui-container.swagger-ui-wrap diff --git a/website/views/static/community-guidelines.jade b/website/views/static/community-guidelines.jade new file mode 100644 index 0000000000..4af0c13a60 --- /dev/null +++ b/website/views/static/community-guidelines.jade @@ -0,0 +1,369 @@ +extends ./layout + +block extraHead + style. + .pull-left { margin-right: 20px } + .peopleList { list-style-type: none } + .listColumns2 { + width: 50%; + columns: 2; + -moz-columns: 2; + -webkit-columns: 2; + // 2-column list: Allow a single ul/ol list to split into two columns. + // (Will not work in IE. Not important enough to make a workaround.) + } + +block vars + - var layoutEnv = env + +block title + title HabitRPG |  + =env.t('communityGuidelines') + +block content + .row + .col-md-12 + .page-header + h1=env.t('communityGuidelines') + p.pagemeta + |Last updated  + =env.t('February') + | 9, 2015 + h2=env.t('commGuideHeadingWelcome') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/intro.png', alt='') + p=env.t('commGuidePara001') + p=env.t('commGuidePara002') + p=env.t('commGuidePara003') + p=env.t('commGuidePara004') + h2=env.t('commGuideHeadingBeing') + p=env.t('commGuidePara005') + + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/beingHabitican.png', alt='') + ul + li!=env.t('commGuideList01A') + li!=env.t('commGuideList01B') + li!=env.t('commGuideList01C') + li!=env.t('commGuideList01D') + + h2=env.t('commGuideHeadingMeet') + p=env.t('commGuidePara006') + p + strong=env.t('commGuidePara007') + p + strong=env.t('commGuidePara008') + p + strong=env.t('commGuidePara009') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/staff.png', alt='') + ul(class='pull-left list-unstyled') + li + strong Lefnire (Tyler Renelle) + li + strong redphoenix (caffeinatedvee  + =env.t('commGuidePara009a') + |, veeeeeee  + =env.t('commGuidePara009b') + |) (Vicky Hsu) + li + strong Lemoness (Siena Leslie) + li + strong SabreCat (Sabe) + li + strong paglias (Matteo) + + p=env.t('commGuidePara010') + p + strong=env.t('commGuidePara011') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/moderators.png', alt='') + ul(class='pull-left list-unstyled') + li + strong Bailey (It's Bailey  + =env.t('commGuidePara011a') + |) + li + strong Ryan (deilann  + =env.t('commGuidePara011b') + |) + li + strong Alys (LadyAlys  + =env.t('commGuidePara011c') + |) + li + strong Blade (crookedneighbor  + =env.t('commGuidePara011d') + |) + li + strong Breadstrings + li + strong Megan + li + strong Daniel the Bard + p!=env.t('commGuidePara012') + p=env.t('commGuidePara013') + p=env.t('commGuidePara014') + |  + em Slappybag, litenull, Shaner, Bobbyroberts99, wc8 + h2=env.t('commGuideHeadingPublicSpaces') + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/publicSpaces.png', alt='') + p=env.t('commGuidePara015') + p=env.t('commGuidePara016') + p!=env.t('commGuidePara017') + ul + li!=env.t('commGuideList02A') + li!=env.t('commGuideList02B') + li!=env.t('commGuideList02C') + li!=env.t('commGuideList02D') + li!=env.t('commGuideList02E') + li!=env.t('commGuideList02F') + li!=env.t('commGuideList02G') + li!=env.t('commGuideList02H') + li!=env.t('commGuideList02I') + p!=env.t('commGuidePara019') + p=env.t('commGuidePara020') + p   + p=env.t('commGuidePara021') + + h3=env.t('commGuideHeadingTavern') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/tavern.png', alt='') + p=env.t('commGuidePara022') + p + strong=env.t('commGuidePara023') + p!=env.t('commGuidePara024') + p!=env.t('commGuidePara027') + + h3=env.t('commGuideHeadingPublicGuilds') + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/publicGuilds.png', alt='') + p!=env.t('commGuidePara029') + p!=env.t('commGuidePara031') + p!=env.t('commGuidePara033') + p!=env.t('commGuidePara035') + p + strong=env.t('commGuidePara037') + + h3=env.t('commGuideHeadingBackCorner') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/backCorner.png', alt='') + p!=env.t('commGuidePara038') + p!=env.t('commGuidePara039') + + h3=env.t('commGuideHeadingTrello') + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/trello.png', alt='') + p!=env.t('commGuidePara040') + p + strong=env.t('commGuidePara041') + ul + li!=env.t('commGuideList03A') + li!=env.t('commGuideList03B') + li!=env.t('commGuideList03C') + li!=env.t('commGuideList03D') + li!=env.t('commGuideList03E') + p!=env.t('commGuidePara042') + + h3=env.t('commGuideHeadingGitHub') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/github.gif', alt='') + p!=env.t('commGuidePara043') + p + strong=env.t('commGuidePara044') + ul(class='listColumns2 peopleList') + li lefnire (Tyler) + li Alys + li benmanley (Pixel) + li colegleason (Cole) + li deilann (Ryan v*) + li Fandekasp + li Lemoness + li litenull + li paglias + li SabreCat + li Sinza- + li snicker + li thepeopleseason + li zakkain + li crookedneighbor (Blade) + li huaruiwu + li negue + li ruddfawcett + li wogsland + li MagicMicky + li viirus + + h3=env.t('commGuideHeadingWiki') + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/wiki.png', alt='') + p!=env.t('commGuidePara045') + p=env.t('commGuidePara046') + p + strong=env.t('commGuidePara047') + p=env.t('commGuidePara048') + ul + li=env.t('commGuideList04A') + li=env.t('commGuideList04B') + li=env.t('commGuideList04C') + li=env.t('commGuideList04D') + li=env.t('commGuideList04E') + li=env.t('commGuideList04F') + li=env.t('commGuideList04G') + li=env.t('commGuideList04H') + p + strong=env.t('commGuidePara049') + ul(class='peopleList') + li Breadstrings (bureaucrat) + li JiggerD + li LadyAlys + li LadyKatFrog + li Lefnire (bureaucrat) + p=env.t('commGuidePara018') + |:  + em Bobbyroberts99 (founder and bureaucrat), wc8 (bureaucrat) + + h2=env.t('commGuideHeadingInfractionsEtc') + h3=env.t('commGuideHeadingInfractions') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/infractions.png', alt='') + p=env.t('commGuidePara050') + p!=env.t('commGuidePara051') + h4=env.t('commGuideHeadingSevereInfractions') + p=env.t('commGuidePara052') + p=env.t('commGuidePara053') + ul + li=env.t('commGuideList05A') + li=env.t('commGuideList05B') + li=env.t('commGuideList05C') + li=env.t('commGuideList05D') + li=env.t('commGuideList05E') + li=env.t('commGuideList05F') + h4=env.t('commGuideHeadingModerateInfractions') + p=env.t('commGuidePara054') + p=env.t('commGuidePara055') + ul + li!=env.t('commGuideList06A') + li=env.t('commGuideList06B') + li=env.t('commGuideList06C') + li=env.t('commGuideList06D') + h4=env.t('commGuideHeadingMinorInfractions') + p=env.t('commGuidePara056') + p=env.t('commGuidePara057') + ul + li=env.t('commGuideList07A') + li=env.t('commGuideList07B') + + h3=env.t('commGuideHeadingConsequences') + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/consequences.png', alt='') + p=env.t('commGuidePara058') + p!=env.t('commGuidePara059') + p + strong=env.t('commGuidePara060') + ul + li=env.t('commGuideList08A') + li=env.t('commGuideList08B') + li=env.t('commGuideList08C') + h4=env.t('commGuideHeadingSevereConsequences') + ul + li=env.t('commGuideList09A') + li=env.t('commGuideList09B') + li=env.t('commGuideList09C') + h4=env.t('commGuideHeadingModerateConsequences') + ul + li=env.t('commGuideList10A') + li=env.t('commGuideList10B') + li=env.t('commGuideList10C') + li=env.t('commGuideList10D') + li=env.t('commGuideList10E') + li=env.t('commGuideList10F') + h4=env.t('commGuideHeadingMinorConsequences') + ul + li=env.t('commGuideList11A') + li=env.t('commGuideList11B') + li=env.t('commGuideList11C') + li=env.t('commGuideList11D') + li=env.t('commGuideList11E') + + h3=env.t('commGuideHeadingRestoration') + div(class='clearfix') + img(class='pull-left', src='/community-guidelines-images/restoration.png', alt='') + p!=env.t('commGuidePara061') + p!=env.t('commGuidePara062') + p!=env.t('commGuidePara063') + + h2=env.t('commGuideHeadingContributing') + div(class='clearfix') + img(class='pull-right', src='/community-guidelines-images/contributing.png', alt='') + p=env.t('commGuidePara064') + ol + li=env.t('commGuideList12A') + li=env.t('commGuideList12B') + li=env.t('commGuideList12C') + li=env.t('commGuideList12D') + li=env.t('commGuideList12E') + li=env.t('commGuideList12F') + li=env.t('commGuideList12G') + p=env.t('commGuidePara065') + p=env.t('commGuidePara066') + ul + li!=env.t('commGuideList13A') + li!=env.t('commGuideList13B') + li!=env.t('commGuideList13C') + li!=env.t('commGuideList13D') + + h2=env.t('commGuideHeadingFinal') + p!=env.t('commGuidePara067') + p=env.t('commGuidePara068') + + h2=env.t('commGuideHeadingLinks') + ul + li + a(href='https://habitrpg.com/#/options/groups/guilds/5481ccf3-5d2d-48a9-a871-70a7380cee5a' target='_blank')=env.t('commGuideLink01') + |:  + =env.t('commGuideLink01description') + li + a(href='https://habitrpg.com/#/options/groups/guilds/426c2c1a-eed0-4997-9b73-d30fc1397688' target='_blank')=env.t('commGuideLink02') + |:  + =env.t('commGuideLink02description') + li + a(href='http://habitrpg.wikia.com/wiki/HabitRPG_Wiki' target='_blank')=env.t('commGuideLink03') + |:  + =env.t('commGuideLink03description') + li + a(href='https://github.com/HabitRPG/habitrpg' target='_blank')=env.t('commGuideLink04') + |:  + =env.t('commGuideLink04description') + li + a(href='https://trello.com/b/EpoYEYod/habitrpg' target='_blank')=env.t('commGuideLink05') + |:  + =env.t('commGuideLink05description') + li + a(href='https://trello.com/b/mXK3Eavg/habitrpg-mobile' target='_blank')=env.t('commGuideLink06') + |:  + =env.t('commGuideLink06description') + li + a(href='https://trello.com/b/vwuE9fbO/habitrpg-pixel-art' target='_blank')=env.t('commGuideLink07') + |:  + =env.t('commGuideLink07description') + li + a(href='https://trello.com/b/nnv4QIRX/habitrpg-quests' target='_blank')=env.t('commGuideLink08') + |:  + =env.t('commGuideLink08description') + + p + strong=env.t('commGuidePara069') + ul(class='listColumns2 peopleList') + li Breadstrings + li Draayder + li Kiwibot + li Leephon + li Lemoness + li Luciferian + li Revcleo + li Shaner + li Starsystemic + li UncommonCriminal + diff --git a/website/views/static/contact.jade b/website/views/static/contact.jade new file mode 100644 index 0000000000..48b9e0eee0 --- /dev/null +++ b/website/views/static/contact.jade @@ -0,0 +1,35 @@ +extends ./layout +//-Trick needed to pass 'env' to ./layout +block vars + - var layoutEnv = env + - var menuItem = 'contact' + +block title + title=env.t('contactUs') + +block content + .row + .col-md-12 + .page-header + h1=env.t('contactUs') + p + | Report Account Problems: + a(href='mailto:admin@habitrpg.com') admin@habitrpg.com + br + | Report a Bug: + a(target='_blank', href='https://github.com/HabitRPG/habitrpg/issues?q=is%3Aopen') Github + br + | Report Community Issues: + a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com + br + | General Questions about the Site: + a(target='_blank', href='http://habitrpg.wikia.com/wiki/The_Keep:The_Newbies_Guild') Newbies Guild + br + | Business Inquiries: + a(href='mailto:vicky@habitrpg.com') vicky@habitrpg.com + br + | Merchandise Inquiries: + a(href='mailto:store@habitrpg.com') store@habitrpg.com + br + | Marketing/Social Media Inquiries: + a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com diff --git a/website/views/static/features.jade b/website/views/static/features.jade new file mode 100644 index 0000000000..542f76d00c --- /dev/null +++ b/website/views/static/features.jade @@ -0,0 +1,91 @@ +extends ./layout + +block vars + - var layoutEnv = env + - var menuItem = 'features' + +block title + title=env.t('companyAbout') + +block content + .row#about-page(ng-controller='AboutCtrl') + .col-md-12#aboutPage + .marketing + h1=env.t('marketing1Header') + .row + .col-md-6 + a.gallery(href='/marketing/screenshot.png', title=env.t('marketing1Header')) + img(src='/marketing/screenshot.png') + p.lead=env.t('marketing1Lead1') + .col-md-6 + + a.gallery(href='/marketing/gear.png', title=env.t('marketing1Lead2Title')) + img(src='/marketing/gear.png') + p.lead!=env.t('marketing1Lead2') + + a.gallery(href='/marketing/drops.png', title=env.t('marketing1Lead3Title')) + img(src='/marketing/drops.png',style='max-height:200px') + p.lead!=env.t('marketing1Lead3') + + // TODO achievements + + hr.clearfix + + h1=env.t('marketing2Header') + .row + .col-md-6 + a.gallery(href='/marketing/guild.png', title=env.t('marketing2Header')) + img(src='/marketing/guild.png') + p.lead=env.t('marketing2Lead1') + + a.gallery(href='/common/img/sprites/spritesmith/quests/quest_vice3.png', title=env.t('marketing2Lead2Title')) + img(src='/common/img/sprites/spritesmith/quests/quest_vice3.png') + p.lead!=env.t('marketing2Lead2') + .col-md-6 + a.gallery(href='/marketing/challenge.png', title=env.t('challenges')) + img(src='/marketing/challenge.png') + p.lead!=env.t('marketing2Lead3') + + hr.clearfix + + h1=env.t('marketing3Header') + .row + .col-md-6 + a.gallery(href='/marketing/android_iphone.png', title=env.t('marketing3LeadTitle')) + img(src='/marketing/android_iphone.png',style='box-shadow:none;') + p.lead!=env.t('marketing3Lead1') + .col-md-6 + a.gallery(href='/marketing/integration.png', title=env.t('marketing3LeadTitle')) + img(src='/marketing/integration.png') + p.lead!=env.t('marketing3Lead2') + + hr.clearfix + + h1=env.t('marketing4Header') + .row + .col-md-6 + h3=env.t('marketing4Lead1Title') + img.pull-left(src='/marketing/education.png') + p.lead=env.t('marketing4Lead1') + .col-md-6 + h3=env.t('marketing4Lead2Title') + img.pull-left(src='/marketing/wellness.png') + p.lead=env.t('marketing4Lead2') + .row + .col-md-6.col-md-offset-3 + h3=env.t('marketing4Lead3Title') + img(src='/marketing/lefnire.png') + p.lead + =env.t('marketing4Lead3-1') + |  + a.btn.btn-primary(ng-click='playButtonClick()')=env.t('playButton') + |  + =env.t('marketing4Lead3-2') + |  + a.btn.btn-primary(href='/static/plans',target='_blank')=env.t('contactUs') + |  + =env.t('marketing4Lead3-3') + |  + a.btn.btn-primary(href='/static/videos')=env.t('watchVideos') + + diff --git a/website/views/static/front.jade b/website/views/static/front.jade new file mode 100644 index 0000000000..bbd25b9603 --- /dev/null +++ b/website/views/static/front.jade @@ -0,0 +1,52 @@ +extends ./layout +//-Trick needed to pass 'env' to ./layout +block vars + - var layoutEnv = env + - var menuItem = 'home' + +block title + title=env.t('titleFront') + +block content + div(ng-controller='RootCtrl') + include ../shared/header/avatar + include ../shared/modals/members + + .marketing + //we need to use something that's not jumbotron for this, but still keep it centered + //could someone write something else to make it pretty? + img(src='/common/img/logo/habitrpg_pixel.png', alt='HabitRPG logo') + //this image needs to be replaced by something more enticing, that shows off the features of hRPG + //while acting similarly to a logo + h1#tagline=env.t('tagline') + p.lead + button.btn.btn-primary.btn-lg#frontpage-play-button(ng-click='playButtonClick()')=env.t('playButton') + hr + img(src='/marketing/devices.png') + //we'd want the tagline centered, for sure, and a bit more pop, but without using jumbotron + //it could also be part of the image, as long as the alt text included it + //in fact, I think I really want it on the image, rather than as text, but language issues + br + p.lead=env.t('landingp1') + h2=env.t('landingp2header') + //images in these parts could be useful, too + //if there's a language workaround, image headers? people like pictures! + p.lead + =env.t('landingp2') + |  + h2=env.t('landingp3header') + //I'm not sold on "Consquences as the title here. Anyone got a better idea? + p.lead + =env.t('landingp3') + |  + h2=env.t('landingp4header') + p.lead=env.t('landingp4') + //- TODO + h2=env.t('landingend') + p.lead + =env.t('landingend2') + a(href="FEATURESPAGEHERE")=env.t('landingfeatureslink') + =env.t('landingend3') + a(href="ENTERPRISEPAGEHERE")=env.t('landingadminlink') + |  + =env.t('landingend4') diff --git a/website/views/static/layout.jade b/website/views/static/layout.jade new file mode 100644 index 0000000000..2b712d49f4 --- /dev/null +++ b/website/views/static/layout.jade @@ -0,0 +1,63 @@ +include ../shared/mixins.jade + +//-Trick needed to pass 'env' to ./layout +block vars +doctype html +html(ng-app='habitrpg') + + head + block extraHead + block title + title=env.t('titleIndex') + + if(env.NODE_ENV == 'production') + script(type='text/javascript'). + window.NREUM||(NREUM={}),__nr_require=function(t,e,n){function r(n){if(!e[n]){var o=e[n]={exports:{}};t[n][0].call(o.exports,function(e){var o=t[n][1][e];return r(o?o:e)},o,o.exports)}return e[n].exports}if("function"==typeof __nr_require)return __nr_require;for(var o=0;od;d++)c[d].apply(u,n);return u}function a(t,e){f[t]=s(t).concat(e)}function s(t){return f[t]||[]}function c(){return n(e)}var f={};return{on:a,emit:e,create:c,listeners:s,_events:f}}function r(){return{}}var o="nr@context",i=t("gos");e.exports=n()},{gos:"7eSDFh"}],ee:[function(t,e){e.exports=t("QJf3ax")},{}],3:[function(t){function e(t,e,n,i,s){try{c?c-=1:r("err",[s||new UncaughtException(t,e,n)])}catch(f){try{r("ierr",[f,(new Date).getTime(),!0])}catch(u){}}return"function"==typeof a?a.apply(this,o(arguments)):!1}function UncaughtException(t,e,n){this.message=t||"Uncaught error with no additional information",this.sourceURL=e,this.line=n}function n(t){r("err",[t,(new Date).getTime()])}var r=t("handle"),o=t(5),i=t("ee"),a=window.onerror,s=!1,c=0;t("loader").features.err=!0,window.onerror=e,NREUM.noticeError=n;try{throw new Error}catch(f){"stack"in f&&(t(1),t(4),"addEventListener"in window&&t(2),window.XMLHttpRequest&&XMLHttpRequest.prototype&&XMLHttpRequest.prototype.addEventListener&&t(3),s=!0)}i.on("fn-start",function(){s&&(c+=1)}),i.on("fn-err",function(t,e,r){s&&(this.thrown=!0,n(r))}),i.on("fn-end",function(){s&&!this.thrown&&c>0&&(c-=1)}),i.on("internal-error",function(t){r("ierr",[t,(new Date).getTime(),!0])})},{1:8,2:5,3:9,4:7,5:21,ee:"QJf3ax",handle:"D5DuLP",loader:"G9z0Bl"}],4:[function(t){function e(){}if(window.performance&&window.performance.timing&&window.performance.getEntriesByType){var n=t("ee"),r=t("handle"),o=t(2);t("loader").features.stn=!0,t(1),n.on("fn-start",function(t){var e=t[0];e instanceof Event&&(this.bstStart=Date.now())}),n.on("fn-end",function(t,e){var n=t[0];n instanceof Event&&r("bst",[n,e,this.bstStart,Date.now()])}),o.on("fn-start",function(t,e,n){this.bstStart=Date.now(),this.bstType=n}),o.on("fn-end",function(t,e){r("bstTimer",[e,this.bstStart,Date.now(),this.bstType])}),n.on("pushState-start",function(){this.time=Date.now(),this.startPath=location.pathname+location.hash}),n.on("pushState-end",function(){r("bstHist",[location.pathname+location.hash,this.startPath,this.time])}),"addEventListener"in window.performance&&(window.performance.addEventListener("webkitresourcetimingbufferfull",function(){r("bstResource",[window.performance.getEntriesByType("resource")]),window.performance.webkitClearResourceTimings()},!1),window.performance.addEventListener("resourcetimingbufferfull",function(){r("bstResource",[window.performance.getEntriesByType("resource")]),window.performance.clearResourceTimings()},!1)),document.addEventListener("scroll",e,!1),document.addEventListener("keypress",e,!1),document.addEventListener("click",e,!1)}},{1:6,2:8,ee:"QJf3ax",handle:"D5DuLP",loader:"G9z0Bl"}],5:[function(t,e){function n(t){i.inPlace(t,["addEventListener","removeEventListener"],"-",r)}function r(t){return t[1]}var o=(t(1),t("ee").create()),i=t(2)(o),a=t("gos");if(e.exports=o,n(window),"getPrototypeOf"in Object){for(var s=document;s&&!s.hasOwnProperty("addEventListener");)s=Object.getPrototypeOf(s);s&&n(s);for(var c=XMLHttpRequest.prototype;c&&!c.hasOwnProperty("addEventListener");)c=Object.getPrototypeOf(c);c&&n(c)}else XMLHttpRequest.prototype.hasOwnProperty("addEventListener")&&n(XMLHttpRequest.prototype);o.on("addEventListener-start",function(t){if(t[1]){var e=t[1];"function"==typeof e?this.wrapped=t[1]=a(e,"nr@wrapped",function(){return i(e,"fn-",null,e.name||"anonymous")}):"function"==typeof e.handleEvent&&i.inPlace(e,["handleEvent"],"fn-")}}),o.on("removeEventListener-start",function(t){var e=this.wrapped;e&&(t[1]=e)})},{1:21,2:22,ee:"QJf3ax",gos:"7eSDFh"}],6:[function(t,e){var n=(t(2),t("ee").create()),r=t(1)(n);e.exports=n,r.inPlace(window.history,["pushState"],"-")},{1:22,2:21,ee:"QJf3ax"}],7:[function(t,e){var n=(t(2),t("ee").create()),r=t(1)(n);e.exports=n,r.inPlace(window,["requestAnimationFrame","mozRequestAnimationFrame","webkitRequestAnimationFrame","msRequestAnimationFrame"],"raf-"),n.on("raf-start",function(t){t[0]=r(t[0],"fn-")})},{1:22,2:21,ee:"QJf3ax"}],8:[function(t,e){function n(t,e,n){var r=t[0];"string"==typeof r&&(r=new Function(r)),t[0]=o(r,"fn-",null,n)}var r=(t(2),t("ee").create()),o=t(1)(r);e.exports=r,o.inPlace(window,["setTimeout","setInterval","setImmediate"],"setTimer-"),r.on("setTimer-start",n)},{1:22,2:21,ee:"QJf3ax"}],9:[function(t,e){function n(){c.inPlace(this,d,"fn-")}function r(t,e){c.inPlace(e,["onreadystatechange"],"fn-")}function o(t,e){return e}var i=t("ee").create(),a=t(1),s=t(2),c=s(i),f=s(a),u=window.XMLHttpRequest,d=["onload","onerror","onabort","onloadstart","onloadend","onprogress","ontimeout"];e.exports=i,window.XMLHttpRequest=function(t){var e=new u(t);try{i.emit("new-xhr",[],e),f.inPlace(e,["addEventListener","removeEventListener"],"-",function(t,e){return e}),e.addEventListener("readystatechange",n,!1)}catch(r){try{i.emit("internal-error",[r])}catch(o){}}return e},window.XMLHttpRequest.prototype=u.prototype,c.inPlace(XMLHttpRequest.prototype,["open","send"],"-xhr-",o),i.on("send-xhr-start",r),i.on("open-xhr-start",r)},{1:5,2:22,ee:"QJf3ax"}],10:[function(t){function e(t){if("string"==typeof t&&t.length)return t.length;if("object"!=typeof t)return void 0;if("undefined"!=typeof ArrayBuffer&&t instanceof ArrayBuffer&&t.byteLength)return t.byteLength;if("undefined"!=typeof Blob&&t instanceof Blob&&t.size)return t.size;if("undefined"!=typeof FormData&&t instanceof FormData)return void 0;try{return JSON.stringify(t).length}catch(e){return void 0}}function n(t){var n=this.params,r=this.metrics;if(!this.ended){this.ended=!0;for(var i=0;c>i;i++)t.removeEventListener(s[i],this.listener,!1);if(!n.aborted){if(r.duration=(new Date).getTime()-this.startTime,4===t.readyState){n.status=t.status;var a=t.responseType,f="arraybuffer"===a||"blob"===a||"json"===a?t.response:t.responseText,u=e(f);if(u&&(r.rxSize=u),this.sameOrigin){var d=t.getResponseHeader("X-NewRelic-App-Data");d&&(n.cat=d.split(", ").pop())}}else n.status=0;r.cbTime=this.cbTime,o("xhr",[n,r,this.startTime])}}}function r(t,e){var n=i(e),r=t.params;r.host=n.hostname+":"+n.port,r.pathname=n.pathname,t.sameOrigin=n.sameOrigin}if(window.XMLHttpRequest&&XMLHttpRequest.prototype&&XMLHttpRequest.prototype.addEventListener&&!/CriOS/.test(navigator.userAgent)){t("loader").features.xhr=!0;var o=t("handle"),i=t(2),a=t("ee"),s=["load","error","abort","timeout"],c=s.length,f=t(1);t(4),t(3),a.on("new-xhr",function(){this.totalCbs=0,this.called=0,this.cbTime=0,this.end=n,this.ended=!1,this.xhrGuids={}}),a.on("open-xhr-start",function(t){this.params={method:t[0]},r(this,t[1]),this.metrics={}}),a.on("open-xhr-end",function(t,e){"loader_config"in NREUM&&"xpid"in NREUM.loader_config&&this.sameOrigin&&e.setRequestHeader("X-NewRelic-ID",NREUM.loader_config.xpid)}),a.on("send-xhr-start",function(t,n){var r=this.metrics,o=t[0],i=this;if(r&&o){var f=e(o);f&&(r.txSize=f)}this.startTime=(new Date).getTime(),this.listener=function(t){try{"abort"===t.type&&(i.params.aborted=!0),("load"!==t.type||i.called===i.totalCbs&&(i.onloadCalled||"function"!=typeof n.onload))&&i.end(n)}catch(e){try{a.emit("internal-error",[e])}catch(r){}}};for(var u=0;c>u;u++)n.addEventListener(s[u],this.listener,!1)}),a.on("xhr-cb-time",function(t,e,n){this.cbTime+=t,e?this.onloadCalled=!0:this.called+=1,this.called!==this.totalCbs||!this.onloadCalled&&"function"==typeof n.onload||this.end(n)}),a.on("xhr-load-added",function(t,e){var n=""+f(t)+!!e;this.xhrGuids&&!this.xhrGuids[n]&&(this.xhrGuids[n]=!0,this.totalCbs+=1)}),a.on("xhr-load-removed",function(t,e){var n=""+f(t)+!!e;this.xhrGuids&&this.xhrGuids[n]&&(delete this.xhrGuids[n],this.totalCbs-=1)}),a.on("addEventListener-end",function(t,e){e instanceof XMLHttpRequest&&"load"===t[0]&&a.emit("xhr-load-added",[t[1],t[2]],e)}),a.on("removeEventListener-end",function(t,e){e instanceof XMLHttpRequest&&"load"===t[0]&&a.emit("xhr-load-removed",[t[1],t[2]],e)}),a.on("fn-start",function(t,e,n){e instanceof XMLHttpRequest&&("onload"===n&&(this.onload=!0),("load"===(t[0]&&t[0].type)||this.onload)&&(this.xhrCbStart=(new Date).getTime()))}),a.on("fn-end",function(t,e){this.xhrCbStart&&a.emit("xhr-cb-time",[(new Date).getTime()-this.xhrCbStart,this.onload,e],e)})}},{1:"XL7HBI",2:11,3:9,4:5,ee:"QJf3ax",handle:"D5DuLP",loader:"G9z0Bl"}],11:[function(t,e){e.exports=function(t){var e=document.createElement("a"),n=window.location,r={};e.href=t,r.port=e.port;var o=e.href.split("://");return!r.port&&o[1]&&(r.port=o[1].split("/")[0].split("@").pop().split(":")[1]),r.port&&"0"!==r.port||(r.port="https"===o[0]?"443":"80"),r.hostname=e.hostname||n.hostname,r.pathname=e.pathname,r.protocol=o[0],"/"!==r.pathname.charAt(0)&&(r.pathname="/"+r.pathname),r.sameOrigin=!e.hostname||e.hostname===document.domain&&e.port===n.port&&e.protocol===n.protocol,r}},{}],gos:[function(t,e){e.exports=t("7eSDFh")},{}],"7eSDFh":[function(t,e){function n(t,e,n){if(r.call(t,e))return t[e];var o=n();if(Object.defineProperty&&Object.keys)try{return Object.defineProperty(t,e,{value:o,writable:!0,enumerable:!1}),o}catch(i){}return t[e]=o,o}var r=Object.prototype.hasOwnProperty;e.exports=n},{}],D5DuLP:[function(t,e){function n(t,e,n){return r.listeners(t).length?r.emit(t,e,n):(o[t]||(o[t]=[]),void o[t].push(e))}var r=t("ee").create(),o={};e.exports=n,n.ee=r,r.q=o},{ee:"QJf3ax"}],handle:[function(t,e){e.exports=t("D5DuLP")},{}],XL7HBI:[function(t,e){function n(t){var e=typeof t;return!t||"object"!==e&&"function"!==e?-1:t===window?0:i(t,o,function(){return r++})}var r=1,o="nr@id",i=t("gos");e.exports=n},{gos:"7eSDFh"}],id:[function(t,e){e.exports=t("XL7HBI")},{}],loader:[function(t,e){e.exports=t("G9z0Bl")},{}],G9z0Bl:[function(t,e){function n(){var t=l.info=NREUM.info;if(t&&t.licenseKey&&t.applicationID&&f&&f.body){s(h,function(e,n){e in t||(t[e]=n)}),l.proto="https"===p.split(":")[0]||t.sslForHttp?"https://":"http://",a("mark",["onload",i()]);var e=f.createElement("script");e.src=l.proto+t.agent,f.body.appendChild(e)}}function r(){"complete"===f.readyState&&o()}function o(){a("mark",["domContent",i()])}function i(){return(new Date).getTime()}var a=t("handle"),s=t(1),c=window,f=c.document,u="addEventListener",d="attachEvent",p=(""+location).split("?")[0],h={beacon:"bam.nr-data.net",errorBeacon:"bam.nr-data.net",agent:"js-agent.newrelic.com/nr-515.min.js"},l=e.exports={offset:i(),origin:p,features:{}};f[u]?(f[u]("DOMContentLoaded",o,!1),c[u]("load",n,!1)):(f[d]("onreadystatechange",r),c[d]("onload",n)),a("mark",["firstbyte",i()])},{1:20,handle:"D5DuLP"}],20:[function(t,e){function n(t,e){var n=[],o="",i=0;for(o in t)r.call(t,o)&&(n[i]=e(o,t[o]),i+=1);return n}var r=Object.prototype.hasOwnProperty;e.exports=n},{}],21:[function(t,e){function n(t,e,n){e||(e=0),"undefined"==typeof n&&(n=t?t.length:0);for(var r=-1,o=n-e||0,i=Array(0>o?0:o);++rHabitRPG's Privacy Policy for + | information and notices concerning HabitRPG's collection and use of your + | personal information. If you have any questions about the HabitRPG + | Privacy Policy, please contact HabitRPG at privacy AT HabitRPG.com. By + | accessing the Services you are agreeing to the terms of our Privacy + | Policy. + p + strong Content + br + | Certain types of content are made available through the Services. + | "HabitRPG Content" means, collectively, the text, data, graphics, images, + | illustrations, forms and look and feel attributes, HabitRPG trademarks + | and logos and other content made available through the Services, + | including any technology or code making up the Services, excluding User + | Content. "Public User Content" means the text, data, graphics, images, photos, + | video or audiovisual content, hypertext links and any other content uploaded, + | transmitted or submitted by a Member via the Services with the intent to share + | with other users. "Private User Content" means data created through the services + | exclusively for personal use or private sharing. + | This includes tasks and related data created in HabitRPG Tasks that have not + | been explicitly shared publicly. + | You understand that by using any of the Services, you may encounter content + | that may be deemed offensive, indecent, or objectionable, which content + | may or may not be identified as having explicit language, and that the + | results of any search or entering of a particular URL may automatically + | and unintentionally generate links or references to objectionable + | material. Nevertheless, you agree to use the Services at your sole risk + | and that we shall not have any liability to you for content that may be + | found to be offensive, indecent, or objectionable. + p + strong Ownership + br + | The Services and HabitRPG Content are protected by copyright, trademark, + | and other laws of the United States and foreign countries. Except as + | expressly provided in these Terms of Service, HabitRPG and its licensors + | exclusively own all right, title and interest in and to the Services and + | HabitRPG Content, including all associated intellectual property rights. + | You will not remove, alter or obscure any copyright, trademark, service + | mark or other proprietary rights notices incorporated in or accompanying + | the Services or HabitRPG Content. + p + strong HabitRPG License + br + | Subject to your compliance with the terms and conditions of these Terms + | of Service, HabitRPG grants you a limited, non-exclusive, + | non-transferable license, without the right to sublicense, to access, + | use, view, download and print, where applicable, the Services and any + | HabitRPG Content solely for your personal and non-commercial purposes. + | You will not use, copy, adapt, modify, prepare derivative works based + | upon, distribute, license, sell, transfer, publicly display, publicly + | perform, transmit, stream, broadcast or otherwise exploit the Services + | or HabitRPG Content, except as expressly permitted in these Terms of + | Service. No licenses or rights are granted to you by implication or + | otherwise under any intellectual property rights owned or controlled by + | HabitRPG or its licensors, except for the licenses and rights expressly + | granted in these Terms of Service. With respect to HabitRPG Applications, + | your license is limited to use of such applications on platforms and + | devices that you own or control, and you may not distribute or make the + | HabitRPG Applications available over a network where it could be used by + | multiple devices at the same time. + p + strong Public User Content + br + | By making available any Public User Content through the Services, you hereby + | grant to HabitRPG a worldwide, irrevocable, perpetual, non-exclusive, + | transferable, royalty-free license, with the right to sublicense, to + | use, copy, adapt, modify, distribute, license, sell, transfer, publicly + | display, publicly perform, transmit, stream, broadcast and otherwise + | exploit such Public User Content only on, through or by means of the Services. + | HabitRPG does not claim any ownership rights in any such Public User Content and + | nothing in these Terms of Service will be deemed to restrict any rights + | that you may have to use and exploit any such Public User Content. + p + | You acknowledge and agree that you are solely responsible for all + | Public User Content that you make available through the Services. Accordingly, + | you represent and warrant that: (i) you either are the sole and + | exclusive owner of all Public User Content that you make available through the + | Services or you have all rights, licenses, consents and releases that + | are necessary to grant to HabitRPG the rights in such Public User Content, as + | contemplated under these Terms of Service; and (ii) neither the User + | Content nor your posting, uploading, publication, submission or + | transmittal of the Public User Content or HabitRPG's use of the Public User Content (or + | any portion thereof) on, through or by means of the Services will + | infringe, misappropriate or violate a third party's patent, copyright, + | trademark, trade secret, moral rights or other intellectual property + | rights, or rights of publicity or privacy, or result in the violation of + | any applicable law or regulation. + p + | Copyrighted Materials: No Infringing Use. You will not use the + | Services to offer, display, distribute, transmit, route, provide + | connections to or store any material that infringes copyrighted works or + | otherwise violates or promotes the violation of the intellectual + | property rights of any third party. HabitRPG has adopted and implemented + | a policy that provides for the termination in appropriate circumstances + | of the accounts of users who repeatedly infringe or are believed to be + | or are charged with repeatedly infringing the rights of copyright + | holders. + p + strong Notify Us of Infringers + br + | If you believe that something on the Services violates your copyright, + | notify our copyright agent in writing. The contact information for our + | copyright agent is at the bottom of this section. + p + | In order for us to take action, you must do the following in your + | notice: + p + | (1) provide your physical or electronic signature; (2) identify + | the copyrighted work that you believe is being infringed; (3) identify + | the item on the Services that you think is infringing your work and + | include sufficient information about where the material is located on + | the Services (including which website and URL) so that we can find it; + | (4) provide us with a way to contact you, such as your address, + | telephone number, or e-mail; (5) provide a statement that you believe in + | good faith that the item you have identified as infringing is not + | authorized by the copyright owner, its agent, or the law to be used on + | the Services; and (6) provide a statement that the information you + | provide in your notice is accurate, and that (under penalty of perjury), + | you are authorized to act on behalf of the copyright owner whose work is + | being infringed. + p + | Here is the contact information for our copyright agent: + p + | Copyright Enforcement + br + | HabitRPG, Inc. + br + | 11870 Santa Monica Blvd., Suite 106-577 + br + | Los Angeles, CA 90025 + br + | E-Mail: admin@habitrpg.com + p + | Again, we cannot take action unless you give us all the required + | information. + p + strong Ratings and Comments & Feedback. + br + | You can rate and make comments about content made available through the + | Services ("Comments"). HabitRPG advises you to exercise caution and good + | judgment when leaving such Comments. Once you complete and submit your + | Comments to the Services you will not be able to go back and edit your + | Comments. You should also be aware that you could be held legally + | responsible for damages to someone's reputation if your Comments are + | deemed to be defamatory. Without limiting any other terms of this Terms + | of Service, HabitRPG may, but is under no obligation to, monitor or + | censor Comments and disclaims any and all liability relating thereto. + | Notwithstanding the foregoing, HabitRPG does reserve the right, in its + | sole discretion, to remove any Comments that it deems to be improper, + | inappropriate or inconsistent with the online activities that are + | permitted under these Terms of Service. We welcome and encourage you to + | provide feedback, comments and suggestions for improvements to the + | Services ("Feedback"). You may submit Feedback by emailing us at support + | AT HabitRPG.com. You acknowledge and agree that all Comments and Feedback + | will be the sole and exclusive property of HabitRPG and you hereby + | irrevocably assign to HabitRPG and agree to irrevocably assign to HabitRPG + | all of your right, title, and interest in and to all Comments and + | Feedback, including without limitation all worldwide patent rights, + | copyright rights, trade secret rights, and other proprietary or + | intellectual property rights therein. At HabitRPG's request and expense, + | you will execute documents and take such further acts as HabitRPG may + | reasonably request to assist HabitRPG to acquire, perfect, and maintain + | its intellectual property rights and other legal protections for the + | Comments and Feedback. + p + strong Interactions between Users + br + | You are solely responsible for your interactions (including any + | disputes) with other users. You understand that HabitRPG does not in any + | way screen HabitRPG users, except to only allow people aged 14 and over + | to create accounts. You are solely responsible for, and will exercise + | caution, discretion, common sense and judgment in, using the Services + | and disclosing personal information to other HabitRPG users. You agree to + | take reasonable precautions in all interactions with other HabitRPG + | users, particularly if you decide to meet a HabitRPG user offline, or in + | person. Your use of the Services, HabitRPG Content and any other content + | made available through the Services is at your sole risk and discretion + | and HabitRPG hereby disclaims any and all liability to you or any third + | party relating thereto. HabitRPG reserves the right to contact Members, + | in compliance with applicable law, in order to evaluate compliance with + | the rules and policies in these Terms of Service. You will cooperate + | fully with HabitRPG to investigate any suspected unlawful, fraudulent or + | improper activity, including, without limitation, granting authorized + | HabitRPG representatives access to any password-protected portions of + | your HabitRPG Account. + p + strong General Prohibitions + br + | You agree not to do any of the following while using the Services or + | HabitRPG Content: + br + ul + li + | Post, upload, publish, submit or transmit any text, graphics, + | images, software, music, audio, video, information or other material + | that: (i) infringes, misappropriates or violates a third party's + | patent, copyright, trademark, trade secret, moral rights or other + | intellectual property rights, or rights of publicity or privacy; (ii) + | violates, or encourages any conduct that would violate, any applicable + | law or regulation or would give rise to civil liability; (iii) is + | fraudulent, false, misleading or deceptive; (iv) is defamatory, + | obscene, pornographic, vulgar or offensive; (v) promotes + | discrimination, bigotry, racism, hatred, harassment or harm against any + | individual or group; (vi) is violent or threatening or promotes + | violence or actions that are threatening to any other person; or (vii) + | promotes illegal or harmful activities or substances (including but not + | limited to activities that promote or provide instructional information + | regarding the manufacture or purchase of illegal weapons or illegal + | substances). + li + | Use, display, mirror, frame or utilize framing techniques to + | enclose the Services, or any individual element or materials within the + | Services, HabitRPG's name, any HabitRPG trademark, logo or other + | proprietary information, the content of any text or the layout and + | design of any page or form contained on a page, without HabitRPG's + | express written consent; + li + | Access, tamper with, or use non-public areas of the Services, + | HabitRPG's computer systems, or the technical delivery systems of + | HabitRPG's providers; + li + | Attempt to probe, scan, or test the vulnerability of any + | HabitRPG system or network or breach any security or authentication + | measures; + li + | Avoid, bypass, remove, deactivate, impair, descramble or + | otherwise circumvent any technological measure implemented by HabitRPG + | or any of HabitRPG's providers or any other third party (including + | another user) to protect the Services or HabitRPG Content; + li + | Attempt to access or search the Services or HabitRPG Content or + | download HabitRPG Content from the Services through the use of any + | engine, software, tool, agent, device or mechanism (including spiders, + | robots, crawlers, data mining tools or the like) other than the + | software and/or search agents provided by HabitRPG or other generally + | available third party web browsers (such as Google Chrome, Microsoft + | Internet Explorer, Mozilla Firefox, Safari or Opera); + li + | Send any unsolicited or unauthorized advertising, promotional + | materials, email, junk mail, spam, chain letters or other form of + | solicitation; + li + | Use any meta tags or other hidden text or metadata utilizing a + | HabitRPG trademark, logo URL or product name without HabitRPG's express + | written consent; + li + | Use the Services or HabitRPG Content for any commercial purpose + | or the benefit of any third party or in any manner not permitted by + | these Terms of Service; + li + | Forge any TCP/IP packet header or any part of the header + | information in any email or newsgroup posting, or in any way use the + | Services or HabitRPG Content to send altered, deceptive or false + | source-identifying information; + li + | Attempt to decipher, decompile, disassemble or reverse + | engineer any of the software used to provide the Services or HabitRPG + | Content; + li + | Interfere with, or attempt to interfere with, the access of + | any user, host or network, including, without limitation, sending a + | virus, overloading, flooding, spamming, or mail-bombing the Services; + li + | Collect or store any personally identifiable information from + | the Services from other users of the Services without their express + | permission; + li + | Impersonate or misrepresent your affiliation with any person + | or entity; Violate any applicable law or regulation; or + li + | Encourage or enable any other individual to do any of the + | foregoing. + p + | HabitRPG will have the right to investigate and prosecute + | violations of any of the above, including intellectual property rights + | infringement and Services security issues, to the fullest extent of the + | law. HabitRPG may involve and cooperate with law enforcement authorities + | in prosecuting users who violate these Terms of Service. You acknowledge + | that HabitRPG has no obligation to monitor your access to or use of the + | Services or HabitRPG Content or to review or edit any Public User Content, but + | has the right to do so for the purpose of operating the Services, to + | ensure your compliance with these Terms of Service, or to comply with + | applicable law or the order or requirement of a court, administrative + | agency or other governmental body. HabitRPG reserves the right, at any + | time and without prior notice, to remove or disable access to any + | HabitRPG Content, including, any Public User Content, that HabitRPG, in its sole + | discretion, considers to be in violation of these Terms of Service or + | otherwise harmful to the Services. + p + strong Links + br + | The Services may contain links to third-party websites or resources. You + | acknowledge and agree that HabitRPG is not responsible or liable for: (i) + | the availability or accuracy of such websites or resources; or (ii) the + | content, products, or services on or available from such websites or + | resources. Links to such websites or resources do not imply any + | endorsement by HabitRPG of such websites or resources or the content, + | products, or services available from such websites or resources. You + | acknowledge sole responsibility for and assume all risk arising from + | your use of any such websites or resources. + p + strong Termination and HabitRPG Account; Cancellation + br + | Without limiting other remedies, HabitRPG may at any time suspend or + | terminate your HabitRPG Account and refuse to provide access to the + | Services. In addition, HabitRPG may notify authorities or take any + | actions it deems appropriate, without notice to you, if HabitRPG suspects + | or determines, in its own discretion, that you may have or there is a + | significant risk that you have (i) failed to comply with any provision + | of these Terms of Service or any policies or rules established by + | HabitRPG; or (ii) engaged in actions relating to or in the course of + | using the Services that may be illegal or cause liability, harm, + | embarrassment, harassment, abuse or disruption for you, HabitRPG Users, + | HabitRPG or any other third parties or the Services. + p + | You may terminate your HabitRPG Account at any time and for any + | reason by sending email to support AT HabitRPG.com. Upon any termination + | by a Member, the related account will no longer be accessible. + p + | After any termination, you understand and acknowledge that we + | will have no further obligation to provide the Services and all licenses + | and other rights granted to you by these Terms of Service will + | immediately cease. HabitRPG will not be liable to you or any third party + | for termination of the Services or termination of your use of either. + | UPON ANY TERMINATION OR SUSPENSION, ANY CONTENT, MATERIALS OR + | INFORMATION (INCLUDING PUBLIC USER CONTENT) THAT YOU HAVE SUBMITTED ON THE + | SERVICES OR THAT WHICH IS RELATED TO YOUR ACCOUNT MAY NO LONGER BE + | ACCESSED BY YOU. Furthermore, HabitRPG will have no obligation to + | maintain any information stored in our database related to your account + | or to forward any information to you or any third party. + p + | Any suspension, termination or cancellation will not affect your + | obligations to HabitRPG under these Terms of Service (including, without + | limitation, proprietary rights and ownership, indemnification and + | limitation of liability), which by their sense and context are intended + | to survive such suspension, termination or cancellation. + p + strong Disclaimers + br + | THE SERVICES, HABITRPG CONTENT AND PUBLIC USER CONTENT ARE PROVIDED "AS IS", + | WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED. WITHOUT + | LIMITING THE FOREGOING, HABITRPG EXPLICITLY DISCLAIMS ANY WARRANTIES OF + | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR + | NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR + | USAGE OF TRADE. + p + | HABITRPG MAKES NO WARRANTY THAT THE SERVICES, HABITRPG CONTENT OR + | PUBLIC USER CONTENT WILL MEET YOUR REQUIREMENTS OR BE AVAILABLE ON AN + | UNINTERRUPTED, SECURE, OR ERROR-FREE BASIS. HABITRPG MAKES NO WARRANTY + | REGARDING THE QUALITY OF ANY PRODUCTS, SERVICES OR CONTENT PURCHASED OR + | OBTAINED THROUGH THE SERVICES OR THE ACCURACY, TIMELINESS, TRUTHFULNESS, + | COMPLETENESS OR RELIABILITY OF ANY CONTENT OBTAINED THROUGH THE + | SERVICES. + p + | NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED FROM + | HABITRPG OR THROUGH THE SERVICES, HABITRPG CONTENT OR PUBLIC USER CONTENT, WILL + | CREATE ANY WARRANTY NOT EXPRESSLY MADE HEREIN. + p + strong Indemnity + br + | You agree to defend, indemnify, and hold HabitRPG, its officers, + | directors, employees and agents, harmless from and against any claims, + | liabilities, damages, losses, and expenses, including, without + | limitation, reasonable legal and accounting fees, arising out of or in + | any way connected with Public User Content you submit to HabitRPG, your access + | to or use of the Services or HabitRPG Content, or your violation of these + | Terms of Service. + p + strong Limitation of Liability + br + | YOU ACKNOWLEDGE AND AGREE THAT, TO THE MAXIMUM EXTENT PERMITTED BY LAW, + | THE ENTIRE RISK ARISING OUT OF YOUR ACCESS TO AND USE OF THE SERVICES + | AND CONTENT THEREIN REMAINS WITH YOU. NEITHER HABITRPG NOR ANY OTHER + | PARTY INVOLVED IN CREATING, PRODUCING, OR DELIVERING THE SERVICES OR + | HABITRPG CONTENT WILL BE LIABLE FOR ANY INCIDENTAL, SPECIAL, EXEMPLARY OR + | CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, LOSS OF DATA OR LOSS OF + | GOODWILL, SERVICE INTERRUPTION, COMPUTER DAMAGE OR SYSTEM FAILURE OR THE + | COST OF SUBSTITUTE PRODUCTS OR SERVICES, ARISING OUT OF OR IN CONNECTION + | WITH THESE TERMS OR FROM THE USE OF OR INABILITY TO USE THE SERVICES OR + | CONTENT THEREIN, WHETHER BASED ON WARRANTY, CONTRACT, TORT (INCLUDING + | NEGLIGENCE), PRODUCT LIABILITY OR ANY OTHER LEGAL THEORY, AND WHETHER OR + | NOT HABITRPG HAS BEEN INFORMED OF THE POSSIBILITY OF SUCH DAMAGE, EVEN IF + | A LIMITED REMEDY SET FORTH HEREIN IS FOUND TO HAVE FAILED OF ITS + | ESSENTIAL PURPOSE. YOU SPECIFICALLY ACKNOWLEDGE THAT HABITRPG IS NOT + | LIABLE FOR THE DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS + | OR THIRD PARTIES AND THAT THE RISK OF INJURY FROM THE FOREGOING RESTS + | ENTIRELY WITH YOU. FURTHER, HABITRPG WILL HAVE NO LIABILITY TO YOU OR TO + | ANY THIRD PARTY FOR ANY PUBLIC USER CONTENT OR THIRD-PARTY CONTENT UPLOADED + | ONTO OR DOWNLOADED FROM THE SITES OR THROUGH THE SERVICES. + p + | IN NO EVENT WILL HABITRPG'S AGGREGATE LIABILITY ARISING OUT OF OR + | IN CONNECTION WITH THESE TERMS OF SERVICE OR FROM THE USE OF OR + | INABILITY TO USE THE SITE, SERVICES OR CONTENT THEREIN EXCEED ONE + | HUNDRED U.S. DOLLARS ($100). THE LIMITATIONS OF DAMAGES SET FORTH ABOVE + | ARE FUNDAMENTAL ELEMENTS OF THE BASIS OF THE BARGAIN BETWEEN HABITRPG AND + | YOU. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF + | LIABILITY FOR CONSEQUENTIAL OR INCIDENTAL DAMAGES, SO THE ABOVE + | LIMITATION MAY NOT APPLY TO YOU. + p + strong Proprietary Rights Notices + br + | All trademarks, service marks, logos, trade names and any other + | proprietary designations of HabitRPG used herein are trademarks or + | registered trademarks of HabitRPG. Any other trademarks, service marks, + | logos, trade names and any other proprietary designations are the + | trademarks or registered trademarks of their respective parties. + p + strong Controlling Law and Jurisdiction + br + | These Terms of Service and any action related thereto will be governed + | by the laws of the State of California without regard to its conflict of + | laws provisions. The exclusive jurisdiction and venue of any action with + | respect to the subject matter of these Terms of Service will be the + | courts having jurisdiction over disputes arising in Santa Clara County, + | California, and each of the parties hereto waives any objection to + | jurisdiction and venue in such courts. + p + | YOU AGREE THAT IF YOU WANT TO SUE US, YOU MUST FILE YOUR LAWSUIT + | WITHIN ONE YEAR AFTER THE EVENT THAT GAVE RISE TO YOUR LAWSUIT. + | OTHERWISE, YOUR LAWSUIT WILL BE PERMANENTLY BARRED. + p + strong Export Control + br + | You may not use or otherwise export or re-export the Services except as + | authorized by United States law and the laws of the jurisdiction in + | which the Services were obtained. In particular, but without limitation, + | the Services may not be exported or re-exported (a) into any U.S. + | embargoed countries or (b) to anyone on the U.S. Treasury Department's + | list of Specially Designated Nationals or the U.S. Department of + | Commerce Denied Person's List or Entity List. By using the Services, you + | represent and warrant that you are not located in any such country or on + | any such list. You also agree that you will not use these products for + | any purposes prohibited by United States law, including, without + | limitation, the development, design, manufacture or production of + | nuclear, missiles, or chemical or biological weapons. + p + strong Entire Agreement + br + | These Terms of Service constitute the entire and exclusive understanding + | and agreement between HabitRPG and you regarding the Services and HabitRPG + | Content, and these Terms of Service supersede and replace any and all + | prior oral or written understandings or agreements between HabitRPG and + | you regarding the Services and HabitRPG Content. + p + strong Assignment + br + | You may not assign or transfer these Terms of Service, by operation of + | law or otherwise, without HabitRPG's prior written consent. Any attempt + | by you to assign or transfer these Terms of Service, without such + | consent, will be null and of no effect. HabitRPG may freely assign these + | Terms of Service. Subject to the foregoing, these Terms of Service will + | bind and inure to the benefit of the parties, their successors and + | permitted assigns. + p + strong Notices + br + | You consent to the use of: (i) electronic means to complete these Terms + | of Service and to deliver any notices or other communications permitted + | or required hereunder; and (ii) electronic records to store information + | related to these Terms of Service or your use of the Services. Any + | notices or other communications permitted to required hereunder, + | including those regarding modifications to these Terms of Service, will + | be in writing and given: (x) by HabitRPG via email (in each case to the + | address that you provide) or (y) by posting to the Sites or Services. + | For notices made by e-mail, the date of receipt will be deemed the date + | on which such notice is transmitted. + p + strong General + br + | The failure of HabitRPG to enforce any right or provision of these Terms + | of Service will not constitute a waiver of future enforcement of that + | right or provision. The waiver of any such right or provision will be + | effective only if in writing and signed by a duly authorized + | representative of HabitRPG. Except as expressly set forth in these Terms + | of Service, the exercise by either party of any of its remedies under + | these Terms of Service will be without prejudice to its other remedies + | under these Terms of Service or otherwise. If for any reason a court of + | competent jurisdiction finds any provision of these Terms of Service + | invalid or unenforceable, that provision will be enforced to the maximum + | extent permissible and the other provisions of these Terms of Service + | will remain in full force and effect. + p + strong Contacting Us + br + | If you have any questions about these Terms of Service, please contact + | us at admin@habitrpg.com diff --git a/website/views/static/videos.jade b/website/views/static/videos.jade new file mode 100644 index 0000000000..a0082e1542 --- /dev/null +++ b/website/views/static/videos.jade @@ -0,0 +1,18 @@ +extends ./layout + +block vars + - var layoutEnv = env + - var menuItem = 'videos' + +block title + title=env.t('companyVideos') + +block content + .row + .col-md-12#aboutPage + iframe(src='//player.vimeo.com/video/76557040', width='100%', height='539', frameborder='0', webkitallowfullscreen='', mozallowfullscreen='', allowfullscreen='') + iframe(src='//player.vimeo.com/video/57654086', width='100%', height='539', frameborder='0', webkitallowfullscreen='', mozallowfullscreen='', allowfullscreen='') + iframe(src='//player.vimeo.com/video/79172253', width='100%', height='539', frameborder='0', webkitallowfullscreen='', mozallowfullscreen='', allowfullscreen='') + iframe(src='//player.vimeo.com/video/79172327', width='100%', height='539', frameborder='0', webkitallowfullscreen='', mozallowfullscreen='', allowfullscreen='') + iframe(src='//player.vimeo.com/video/79172363', width='100%', height='539', frameborder='0', webkitallowfullscreen='', mozallowfullscreen='', allowfullscreen='') +