Compare commits

..

26 Commits

Author SHA1 Message Date
Phillip Thelen cd8535a1c5 log language for registration events 2026-04-07 16:24:11 +02:00
Phillip Thelen f092a24ba8 || 2026-03-25 10:46:34 +01:00
Phillip Thelen 78f114182b remove only 2026-03-25 10:44:07 +01:00
Phillip Thelen 597ed0db87 fix tests 2026-03-25 10:36:40 +01:00
Phillip Thelen 8b13463c37 bump mongoose 2026-03-25 10:25:32 +01:00
Phillip Thelen 8348bff342 restore docker mongodb healthcheck 2026-03-25 10:09:20 +01:00
Phillip Thelen 80ee133466 don’t specify mongodb version directly 2026-03-25 10:06:54 +01:00
Phillip Thelen 23e96e431b correct check for finding authentication method 2026-03-25 10:06:42 +01:00
Kalista Payne fb3bac3493 fix(lint): no-unused-vars again 2026-03-24 17:20:43 -05:00
Kalista Payne 52a30a4d7e fix(lint): don't assign unused var 2026-03-24 17:12:19 -05:00
Phillip Thelen 01db89b259 remove unused code 2026-03-24 14:24:00 +01:00
Phillip Thelen 012592ae1c add tests 2026-03-24 14:19:04 +01:00
Phillip Thelen 0fe543b394 tests 2026-03-23 18:10:29 +01:00
Phillip Thelen 0e3e80bd08 track registration events 2026-03-23 17:41:07 +01:00
Phillip Thelen a64b190ac5 cleanup 2026-03-23 17:40:52 +01:00
Phillip Thelen 42d883c51e remove most of analytics code 2026-03-20 17:59:25 +01:00
Kalista Payne cc7683a871 chore(git): update submodule 2026-03-19 18:09:22 -05:00
Kalista Payne 31b2781333 Squashed commit of the following:
commit 866f074a15
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 15:51:18 2026 -0500

    fix(quests): remove backticks from text

commit d06fc2825e
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 13:16:32 2026 -0500

    fix(background): add missing data

commit 55156c5f80
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 13:00:43 2026 -0500

    fix(lint): max-len

commit db88092acf
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 12:56:30 2026 -0500

    fix(customization): show event backgrounds for AF

commit d6fd1ce7fa
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 15:55:53 2026 +0100

    set release date

commit b876d8c789
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 11:40:17 2026 +0100

    Improve swap handling

commit f75a4d0147
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 11:22:42 2026 +0100

    use correct name for bear sprites

commit 195db1b132
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 23:01:35 2026 +0100

    more fix

commit a42d3f08d7
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 18:43:21 2026 +0100

    fix test

commit 9c06299c34
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 09:13:13 2026 +0100

    AF tweaks

commit 5465e23e67
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 11 19:43:01 2026 -0500

    fix(sprites): add missing gif redirects

commit 5721ecc6f2
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 16:34:26 2026 -0500

    chore(css): run sprites

commit 2184ff2e69
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:38:34 2026 -0500

    fix(test): date

commit d684a7297c
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:32:04 2026 -0500

    fix(test): update for 2026, also more lint

commit 82e7947fd7
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:22:05 2026 -0500

    fix(event): lint and missing pieces

commit 96818b2d77
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Mar 9 20:11:22 2026 -0500

    feat(event): finished Alien build

commit 4c9763b676
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Mar 6 16:30:36 2026 -0600

    wip(event): April Fools 2026 build

commit 2f16a016e6
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Feb 4 14:16:50 2026 +0100

    add april fools tests

commit cd1d926c98
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Feb 4 14:09:16 2026 +0100

    make april fools cycle through

commit d8a4216c41
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 14:41:26 2026 +0100

    fix lint

commit 8265a15923
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:34:46 2026 +0100

    name key more generic

commit 9c7bde8ad5
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:26:43 2026 +0100

    right date for april fools

commit c2b92c6311
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:26:19 2026 +0100

    rework how april fools works
2026-03-19 15:09:32 -05:00
Kalista Payne d37d3bc5ac 5.46.4 2026-03-17 14:51:06 -05:00
Weblate ef3a28791e Translated using Weblate (Dutch)
Currently translated at 72.8% (2586 of 3551 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 95.0% (19 of 20 strings)

Translated using Weblate (Dutch)

Currently translated at 63.6% (161 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 70.0% (14 of 20 strings)

Translated using Weblate (Russian)

Currently translated at 98.5% (927 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Korean)

Currently translated at 43.3% (82 of 189 strings)

Translated using Weblate (Korean)

Currently translated at 33.8% (64 of 189 strings)

Co-authored-by: Alexander Tschigir <tchi.gugl@gmail.com>
Co-authored-by: Jildau Bras <jildaubras@gmail.com>
Co-authored-by: Serhii <serzh.photograf@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Tijl Casteleyn <casteleyntijl@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: 성명 <phjk2536@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/front/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/
Translation: Habitica/Backgrounds
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Rebirth
Translation: Habitica/Settings
2026-03-17 10:33:27 +01:00
Kalista Payne c3c2607bca Remove join retired public guild loophole (#15626)
* fix(guilds): remove join retired public guild loophole

* fix(groups): adjust test expectations
and move some business logic back out of the controller

* test(challenges): add cases related to public Guilds, Tavern and otherwise

* fix(tests): more erroneously public test guilds, lint

* fix(tests): still more setup tweaks

* fix(lint): whitespace

* fix(tests): couple more adjustments

* fix(test): last challenge issue??
2026-03-12 19:15:00 -05:00
Tanmay Nalawade 7a6d64f158 Duplicate tags Bug solved (#15615)
* added hasExactMatch function

* changed the handleSubmit logic to check for match

* changed the conditional in class for dropdown

* added hasExactMatch conditional to display the AddTag text

* one more conditional to check if string is empty

* changed styling to display hidden text of "Press Enter to add tag"

* lint error fixed

* fix(tags): don't allow same tag spam within add session

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2026-03-12 19:09:30 -05:00
Kalista Payne 836e63246d fix(emails): correct variable name in group plan invites 2026-03-12 18:58:49 -05:00
Kalista Payne 61585b2549 5.46.3 2026-03-12 16:51:19 -05:00
Corban Villa 07275bd522 fix(security): don't reuse IV for cryptography 2026-03-12 14:53:30 -05:00
Fiz 74fc543ef2 Orb of Rebirth Updates (Count usage) (#15629)
* Update orb of rebirth to count every usage

* Orb of rebirth modal UI updates

* Fix rebirth modal showing again after page refresh

* remove unused MAX_LEVEL

* scale orb of rebirth on modal

* UI & wording updates for Orb of Rebirth

* UI & wording updates for Orb of Rebirth cont.

* Orb of rebirth UI tweaks

* Orb of rebirth UI tweaks

* Orb of Rebirth modal UI tweak

* Extend modal waves

* Continued Orb of Rebirth UI Updates

* Orb of rebirth margin tweak

* rebirth-orb asset
2026-03-12 14:51:48 -05:00
138 changed files with 1651 additions and 2724 deletions
-1
View File
@@ -75,7 +75,6 @@
"S3_ACCESS_KEY_ID": "accessKeyId",
"S3_BUCKET": "bucket",
"S3_SECRET_ACCESS_KEY": "secretAccessKey",
"SESSION_SECRET_IV": "12345678912345678912345678912345",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SESSION_SECRET": "YOUR SECRET HERE",
"SITE_HTTP_AUTH_ENABLED": "false",
+1
View File
@@ -21,3 +21,4 @@ services:
timeout: 30s
start_period: 0s
retries: 30
+166 -143
View File
@@ -1,12 +1,12 @@
{
"name": "habitica",
"version": "5.46.2",
"version": "5.46.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habitica",
"version": "5.46.2",
"version": "5.46.4",
"hasInstallScript": true,
"dependencies": {
"@babel/core": "^7.22.10",
@@ -57,7 +57,7 @@
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"mongoose": "^8.23.0",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
@@ -2624,6 +2624,19 @@
"node": ">=12.0.0"
}
},
"node_modules/@google-cloud/trace-agent/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@google-cloud/trace-agent/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -3052,9 +3065,9 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
"integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==",
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
"integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
@@ -6316,6 +6329,7 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz",
"integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
@@ -6325,13 +6339,15 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/bl/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -6347,6 +6363,7 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -11229,18 +11246,6 @@
"node": ">=12"
}
},
"node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -11984,6 +11989,19 @@
"node": ">=12"
}
},
"node_modules/google-auth-library/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/google-auth-library/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -15557,128 +15575,14 @@
}
},
"node_modules/mongodb": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dev": true,
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
"integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz",
"integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==",
"license": "MIT",
"dependencies": {
"tr46": "^5.0.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"dev": true,
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/mongoose": {
"version": "8.9.7",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.7.tgz",
"integrity": "sha512-mvNXmU0V8qZzMR/qoK2mjT4Ti2ALdtfS0teK+twxhlGkwzOD76V02/zWajTu2MJ7QyEmZe9OWvnJsIY0iAuX3Q==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.1",
"kareem": "2.6.3",
"mongodb": "~6.12.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/mongodb": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz",
"integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.9",
"bson": "^6.10.1",
"mongodb-connection-string-url": "^3.0.0"
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
},
"engines": {
"node": ">=16.20.1"
@@ -15689,7 +15593,7 @@
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.2.2",
"snappy": "^7.3.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
@@ -15716,6 +15620,72 @@
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongoose": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz",
"integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.4",
"kareem": "2.6.3",
"mongodb": "~6.20.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -15775,6 +15745,56 @@
"integrity": "sha512-jSTz73B/+pGTTvhu5Ym8xsG6+QqaWab53UXnXdNNlTijTdLvcHABCLJXudQiJxob5N1Mzr5EOSx5ziwn2sihPQ==",
"dev": true
},
"node_modules/monk/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/monk/node_modules/mongodb": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
@@ -17137,10 +17157,11 @@
}
},
"node_modules/optional-require": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz",
"integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==",
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.10.tgz",
"integrity": "sha512-0r3OB9EIQsP+a5HVATHq2ExIy2q/Vaffoo4IAikW1spCYswhLxqWQS0i3GwS3AdY/OIP4SWZHLGz8CMU558PGw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"require-at": "^1.0.6"
},
@@ -18622,6 +18643,7 @@
"resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz",
"integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=4"
}
@@ -18920,6 +18942,7 @@
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"sparse-bitfield": "^3.0.3"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.46.2",
"version": "5.46.4",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -52,7 +52,7 @@
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"mongoose": "^8.23.0",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
-560
View File
@@ -1,560 +0,0 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analyticsService', () => {
beforeEach(() => {
sandbox.stub(Amplitude.prototype, 'track').returns(Promise.resolve());
});
afterEach(() => {
sandbox.restore();
});
describe('#getServiceByEnvironment', () => {
it('returns mock methods when not in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
expect(analyticsService.getAnalyticsServiceByEnvironment())
.to.equal(analyticsService.mockAnalyticsService);
});
it('returns real methods when in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
expect(analyticsService.getAnalyticsServiceByEnvironment().track)
.to.equal(analyticsService.track);
expect(analyticsService.getAnalyticsServiceByEnvironment().trackPurchase)
.to.equal(analyticsService.trackPurchase);
});
});
describe('#track', () => {
let eventType; let
data;
beforeEach(() => {
eventType = 'Cron';
data = {
category: 'behavior',
uuid: 'unique-user-id',
resting: true,
cronCount: 5,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about event', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
category: 'behavior',
resting: true,
cronCount: 5,
},
});
}));
it('sends english item name for gear if itemKey is provided', () => {
data.itemKey = 'headAccessory_special_foxEars';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Fox Ears',
},
});
});
});
it('sends english item name for egg if itemKey is provided', () => {
data.itemKey = 'Wolf';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Wolf Egg',
},
});
});
});
it('sends english item name for food if itemKey is provided', () => {
data.itemKey = 'Cake_Skeleton';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Bare Bones Cake',
},
});
});
});
it('sends english item name for hatching potion if itemKey is provided', () => {
data.itemKey = 'Golden';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Golden Hatching Potion',
},
});
});
});
it('sends english item name for quest if itemKey is provided', () => {
data.itemKey = 'atom1';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Attack of the Mundane, Part 1: Dish Disaster!',
},
});
});
});
it('sends english item name for purchased spell if itemKey is provided', () => {
data.itemKey = 'seafoam';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Seafoam',
},
});
});
});
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
balance: 12,
loginIncentives: 1,
preferences: {
analyticsConsent: true,
},
};
data.user = user;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
balance: 12,
balanceGemAmount: 48,
loginIncentives: 1,
},
});
});
});
});
});
describe('#trackPurchase', () => {
let data;
beforeEach(() => {
data = {
uuid: 'user-id',
sku: 'paypal-checkout',
paymentMethod: 'PayPal',
itemPurchased: 'Gems',
purchaseValue: 8,
purchaseType: 'checkout',
gift: false,
quantity: 1,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about purchase', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
gift: false,
itemPurchased: 'Gems',
paymentMethod: 'PayPal',
purchaseType: 'checkout',
quantity: 1,
sku: 'paypal-checkout',
},
});
}));
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
preferences: {
analyticsConsent: true,
},
};
data.user = user;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
},
});
});
});
});
});
describe('mockAnalyticsService', () => {
it('has stubbed track method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('track');
});
it('has stubbed trackPurchase method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase');
});
});
});
+116 -136
View File
@@ -13,7 +13,6 @@ import { cron, cronWrapper } from '../../../../website/server/libs/cron';
import { model as User } from '../../../../website/server/models/user';
import * as Tasks from '../../../../website/server/models/task';
import common from '../../../../website/common';
import * as analytics from '../../../../website/server/libs/analyticsService';
import { model as Group } from '../../../../website/server/models/group';
const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime();
@@ -41,20 +40,17 @@ describe('cron', async () => {
},
},
});
sinon.spy(analytics, 'track');
});
afterEach(async () => {
if (clock !== null) clock.restore();
analytics.track.restore();
});
it('updates user.preferences.timezoneOffsetAtLastCron', async () => {
const timezoneUtcOffsetFromUserPrefs = -1;
await cron({
user, tasksByType, daysMissed, analytics, timezoneUtcOffsetFromUserPrefs,
user, tasksByType, daysMissed, timezoneUtcOffsetFromUserPrefs,
});
expect(user.preferences.timezoneOffsetAtLastCron).to.equal(1);
@@ -63,7 +59,7 @@ describe('cron', async () => {
it('resets user.items.lastDrop.count', async () => {
user.items.lastDrop.count = 4;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.items.lastDrop.count).to.equal(0);
});
@@ -71,26 +67,11 @@ describe('cron', async () => {
it('increments user cron count', async () => {
const cronCountBefore = user.flags.cronCount;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore);
});
it('calls analytics', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
it('calls analytics when user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
describe('end of the month perks', async () => {
beforeEach(async () => {
user.purchased.plan.customerId = 'subscribedId';
@@ -101,7 +82,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-12-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(2);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -112,7 +93,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-11-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(4);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -122,7 +103,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -131,7 +112,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
user.purchased.plan.dateUpdated = undefined;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -142,7 +123,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
@@ -150,7 +131,7 @@ describe('cron', async () => {
it('resets plan.dateUpdated on a new month', async () => {
const currentMonth = moment().startOf('month');
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(moment(user.purchased.plan.dateUpdated).startOf('month').isSame(currentMonth)).to.eql(true);
});
@@ -158,7 +139,7 @@ describe('cron', async () => {
it('increments plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.count).to.equal(1);
});
@@ -166,7 +147,7 @@ describe('cron', async () => {
it('increments plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.cumulativeCount).to.equal(1);
});
@@ -175,7 +156,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(2, 'months').toDate();
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.count).to.equal(2);
});
@@ -184,7 +165,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(3, 'months').toDate();
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.cumulativeCount).to.equal(3);
});
@@ -196,7 +177,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(1);
@@ -206,7 +187,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -214,7 +195,7 @@ describe('cron', async () => {
it('does not reset plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.customerId).to.exist;
});
@@ -225,7 +206,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.customerId).to.not.exist;
@@ -264,7 +245,7 @@ describe('cron', async () => {
// Add 2 days so that we're sure we're not affected by any start-of-month effects
// e.g., from time zone oddness.
await cron({
user: user1, tasksByType, daysMissed, analytics,
user: user1, tasksByType, daysMissed,
});
expect(user1.purchased.plan.consecutive.count).to.equal(1);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -276,7 +257,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user1, tasksByType, daysMissed, analytics,
user: user1, tasksByType, daysMissed,
});
expect(user1.purchased.plan.consecutive.count).to.equal(10);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -311,7 +292,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed, analytics,
user: user3, tasksByType, daysMissed,
});
expect(user3.purchased.plan.consecutive.count).to.equal(1);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -323,7 +304,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed, analytics,
user: user3, tasksByType, daysMissed,
});
expect(user3.purchased.plan.consecutive.count).to.equal(10);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -358,7 +339,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user6, tasksByType, daysMissed, analytics,
user: user6, tasksByType, daysMissed,
});
expect(user6.purchased.plan.consecutive.count).to.equal(1);
expect(user6.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -391,7 +372,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed, analytics,
user: user12, tasksByType, daysMissed,
});
expect(user12.purchased.plan.consecutive.count).to.equal(1);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -403,7 +384,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed, analytics,
user: user12, tasksByType, daysMissed,
});
expect(user12.purchased.plan.consecutive.count).to.equal(10);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -439,7 +420,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed, analytics,
user: user3g, tasksByType, daysMissed,
});
expect(user3g.purchased.plan.consecutive.count).to.equal(1);
expect(user3g.purchased.plan.cumulativeCount).to.equal(1);
@@ -452,7 +433,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed, analytics,
user: user3g, tasksByType, daysMissed,
});
// subscription has been erased by now
expect(user3g.purchased.plan.consecutive.count).to.equal(0);
@@ -471,7 +452,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -482,14 +463,14 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
it('does not reset plan.dateUpdated on a new month', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.dateUpdated).to.be.empty;
});
@@ -497,7 +478,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.count).to.equal(0);
});
@@ -505,7 +486,7 @@ describe('cron', async () => {
it('does not increment plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.cumulativeCount).to.equal(0);
});
@@ -513,7 +494,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(0);
});
@@ -521,7 +502,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0);
});
@@ -530,7 +511,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -538,7 +519,7 @@ describe('cron', async () => {
it('does nothing to plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.customerId).to.not.exist;
});
@@ -564,7 +545,7 @@ describe('cron', async () => {
it('should make uncompleted todos redder', async () => {
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore);
});
@@ -573,7 +554,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.todos[0].value).to.equal(valueBefore);
});
@@ -582,7 +563,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.history.todos).to.be.lengthOf(1);
@@ -608,7 +589,7 @@ describe('cron', async () => {
expect(user.tasksOrder.todos).to.be.lengthOf(3);
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
// user.tasksOrder.todos should be filtered while tasks by type remains unchanged
@@ -635,7 +616,7 @@ describe('cron', async () => {
const original = user.tasksOrder.todos; // Preserve the original order
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
let listsAreEqual = true;
@@ -675,7 +656,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].isDue).to.be.false;
});
@@ -686,7 +667,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().toDate();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].isDue).to.exist;
});
@@ -696,14 +677,14 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].nextDue.length).to.eql(6);
});
it('should add history', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].history).to.be.lengthOf(1);
});
@@ -711,7 +692,7 @@ describe('cron', async () => {
it('should set tasks completed to false', async () => {
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -720,7 +701,7 @@ describe('cron', async () => {
user.preferences.sleep = true;
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -729,7 +710,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -739,7 +720,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -749,7 +730,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -759,7 +740,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.be.lessThan(hpBefore);
});
@@ -770,7 +751,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(hpBefore);
});
@@ -784,7 +765,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
cronOverride({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -797,7 +778,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -808,7 +789,7 @@ describe('cron', async () => {
let hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
const hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp;
@@ -816,7 +797,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: true });
tasksByType.dailys[0].checklist.push({ title: 'test2', completed: false });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
const hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp;
@@ -829,7 +810,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(progress.down).to.equal(-1);
@@ -841,7 +822,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(progress.down).to.equal(0);
@@ -862,7 +843,7 @@ describe('cron', async () => {
tasksByType.dailys[1].frequency = 'daily';
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(48);
@@ -886,7 +867,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = false;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -897,7 +878,7 @@ describe('cron', async () => {
tasksByType.habits[0].up = false;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -909,7 +890,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].value).to.equal(1);
@@ -928,7 +909,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -941,7 +922,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -955,7 +936,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -964,7 +945,7 @@ describe('cron', async () => {
// should reset
daysMissed = 8;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -988,7 +969,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1002,7 +983,7 @@ describe('cron', async () => {
// should reset after user CDS
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1026,7 +1007,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1036,7 +1017,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1060,7 +1041,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1084,7 +1065,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1098,7 +1079,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1107,7 +1088,7 @@ describe('cron', async () => {
// should reset
daysMissed = 32;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1132,7 +1113,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1156,7 +1137,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1166,7 +1147,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1199,7 +1180,7 @@ describe('cron', async () => {
user.stats.lvl = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.history.exp).to.have.lengthOf(1);
@@ -1212,7 +1193,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.achievements.perfect).to.equal(1);
@@ -1224,7 +1205,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = false;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.achievements.perfect).to.equal(0);
@@ -1238,7 +1219,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1256,7 +1237,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1280,7 +1261,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1307,7 +1288,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1333,7 +1314,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1360,7 +1341,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1381,7 +1362,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1401,7 +1382,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1420,7 +1401,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.mp).to.be.greaterThan(mpBefore);
@@ -1436,7 +1417,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.mp).to.equal(mpBefore);
@@ -1449,7 +1430,7 @@ describe('cron', async () => {
user.stats.mp = 120;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.mp).to.equal(common.statsComputed(user).maxMP);
@@ -1482,7 +1463,7 @@ describe('cron', async () => {
it('resets user progress', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.party.quest.progress.up).to.equal(0);
expect(user.party.quest.progress.down).to.equal(0);
@@ -1491,7 +1472,7 @@ describe('cron', async () => {
it('applies the user progress', async () => {
const progress = await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(progress.down).to.equal(-1);
});
@@ -1529,19 +1510,19 @@ describe('cron', async () => {
describe('login incentives', async () => {
it('increments incentive counter each cron', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
user.lastCron = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(2);
});
it('pushes a notification of the day\'s incentive each cron', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.notifications.length).to.eql(1);
expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE');
@@ -1549,13 +1530,13 @@ describe('cron', async () => {
it('replaces previous notifications', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
const filteredNotifications = user.notifications.filter(n => n.type === 'LOGIN_INCENTIVE');
@@ -1566,7 +1547,7 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if days are skipped in between', async () => {
daysMissed = 3;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
});
@@ -1574,14 +1555,14 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
});
it('awards user bard robes if login incentive is 1', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
expect(user.items.gear.owned.armor_special_bardRobes).to.eql(true);
@@ -1591,7 +1572,7 @@ describe('cron', async () => {
it('awards user incentive backgrounds if login incentive is 2', async () => {
user.loginIncentives = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(2);
expect(user.purchased.background.blue).to.eql(true);
@@ -1605,7 +1586,7 @@ describe('cron', async () => {
it('awards user Bard Hat if login incentive is 3', async () => {
user.loginIncentives = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(3);
expect(user.items.gear.owned.head_special_bardHat).to.eql(true);
@@ -1615,7 +1596,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 4', async () => {
user.loginIncentives = 3;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(4);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1625,7 +1606,7 @@ describe('cron', async () => {
it('awards user a Chocolate, Meat and Pink Contton Candy if login incentive is 5', async () => {
user.loginIncentives = 4;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(5);
@@ -1639,7 +1620,7 @@ describe('cron', async () => {
it('awards user moon quest if login incentive is 7', async () => {
user.loginIncentives = 6;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(7);
expect(user.items.quests.moon1).to.eql(1);
@@ -1649,7 +1630,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 10', async () => {
user.loginIncentives = 9;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(10);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1659,7 +1640,7 @@ describe('cron', async () => {
it('awards user a Strawberry, Patato and Blue Contton Candy if login incentive is 14', async () => {
user.loginIncentives = 13;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(14);
@@ -1673,7 +1654,7 @@ describe('cron', async () => {
it('awards user a bard instrument if login incentive is 18', async () => {
user.loginIncentives = 17;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(18);
expect(user.items.gear.owned.weapon_special_bardInstrument).to.eql(true);
@@ -1683,7 +1664,7 @@ describe('cron', async () => {
it('awards user second moon quest if login incentive is 22', async () => {
user.loginIncentives = 21;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(22);
expect(user.items.quests.moon2).to.eql(1);
@@ -1693,7 +1674,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 26', async () => {
user.loginIncentives = 25;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(26);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1703,7 +1684,7 @@ describe('cron', async () => {
it('awards user Fish, Milk, Rotten Meat and Honey if login incentive is 30', async () => {
user.loginIncentives = 29;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(30);
@@ -1718,7 +1699,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 35', async () => {
user.loginIncentives = 34;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(35);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1728,7 +1709,7 @@ describe('cron', async () => {
it('awards user the third moon quest if login incentive is 40', async () => {
user.loginIncentives = 39;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(40);
expect(user.items.quests.moon3).to.eql(1);
@@ -1738,7 +1719,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 45', async () => {
user.loginIncentives = 44;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(45);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1748,7 +1729,7 @@ describe('cron', async () => {
it('awards user a saddle if login incentive is 50', async () => {
user.loginIncentives = 49;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(50);
expect(user.items.food.Saddle).to.eql(1);
@@ -1766,7 +1747,6 @@ describe('cron wrapper', () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analytics;
});
afterEach(() => {
+100
View File
@@ -0,0 +1,100 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import { model as User } from '../../../../website/server/models/user';
import { RegistrationEventModel } from '../../../../website/server/models/analytics/registrationEvent';
import { SubscriptionEventModel } from '../../../../website/server/models/analytics/subscriptionEvent';
describe('localAnalytics', () => {
let user;
let localAnalytics;
before(() => {
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('ANALYTICS_DB').returns('analytics');
nconfGetStub.withArgs('DISABLE_LOCAL_ANALYTICS').returns(false);
localAnalytics = requireAgain('../../../../website/server/libs/localAnalytics');
});
beforeEach(async () => {
user = new User({
auth: {
local: {
username: 'username',
email: 'email@example.com',
},
},
registeredThrough: 'habitica-web',
});
});
describe('trackRegistrationEvent', () => {
afterEach(async () => {
await RegistrationEventModel.deleteMany({});
});
it('creates a registration event when a user registers', async () => {
user._id = '00000000-0000-0000-0000-000000000001';
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.1' });
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(1);
expect(registrationEvents[0]).to.have.property('userId', user._id);
expect(registrationEvents[0]).to.have.property('ipAddress', '127.0.0.1');
});
it('saves the correct data to the database', async () => {
user._id = '00000000-0000-0000-0000-000000000002';
user.auth.google = { id: 'abc', emails: [{ value: 'email@example.com' }] };
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.2' });
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress', '127.0.0.2');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
});
describe('trackSubscriptionEvent', () => {
afterEach(async () => {
await SubscriptionEventModel.deleteMany({});
});
it('creates a subscription event when a user subscribes', async () => {
user._id = '00000000-0000-0000-0000-000000000003';
await localAnalytics.trackSubscriptionEvent({
eventType: 'subscribed',
user,
paymentMethod: 'stripe',
customerId: 'cus_123',
planId: 'plan_123',
});
const subscriptionEvents = await SubscriptionEventModel.find({ userId: user._id });
expect(subscriptionEvents).to.have.lengthOf(1);
expect(subscriptionEvents[0]).to.have.property('userId', user._id);
expect(subscriptionEvents[0]).to.have.property('eventType', 'subscribed');
expect(subscriptionEvents[0]).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvents[0]).to.have.property('customerId', 'cus_123');
expect(subscriptionEvents[0]).to.have.property('planId', 'plan_123');
});
it('creates a subscription event with cancellation reason when a user cancels', async () => {
user._id = '00000000-0000-0000-0000-000000000004';
await localAnalytics.trackSubscriptionEvent({
eventType: 'cancelled',
user,
paymentMethod: 'stripe',
customerId: 'cus_456',
planId: 'plan_456',
cancellationReason: 'No longer needed',
});
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvent).to.have.property('customerId', 'cus_456');
expect(subscriptionEvent).to.have.property('planId', 'plan_456');
expect(subscriptionEvent).to.have.property('cancellationReason', 'No longer needed');
});
});
});
@@ -66,13 +66,15 @@ describe('Amazon Payments - Cancel Subscription', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
subscriptionBlock = common.content.subscriptionBlocks[subKey];
subscriptionLength = subscriptionBlock.months * 30;
@@ -30,12 +30,14 @@ describe('Amazon Payments - Subscribe', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
amount = common.content.subscriptionBlocks[subKey].price;
billingAgreementId = 'billingAgreementId';
@@ -246,11 +248,6 @@ describe('Amazon Payments - Subscribe', () => {
user.guilds.push(groupId);
await user.save();
// Add existing users
user = new User();
user.guilds.push(groupId);
await user.save();
// Set expected amount
sub.key = 'group_monthly';
sub.price = 9;
@@ -128,11 +128,12 @@ describe('Purchasing a group plan for group', () => {
expect(publicGroup.purchased.plan.planId).to.not.exist;
data.groupId = publicGroup._id;
// Public Guilds are no longer even findable
await expect(api.createSubscription(data))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('onlyPrivateGuildsCanUpgrade'),
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
});
const updatedGroup = await Group.findById(publicGroup._id).exec();
+64 -47
View File
@@ -3,7 +3,6 @@ import moment from 'moment';
import * as sender from '../../../../../website/server/libs/email';
import common from '../../../../../website/common';
import api from '../../../../../website/server/libs/payments/payments';
import * as analytics from '../../../../../website/server/libs/analyticsService';
import * as notifications from '../../../../../website/server/libs/pushNotifications';
import { model as User } from '../../../../../website/server/models/user';
import { translate as t } from '../../../../helpers/api-integration/v3';
@@ -13,6 +12,7 @@ import {
import * as worldState from '../../../../../website/server/libs/worldState';
import { TransactionModel } from '../../../../../website/server/models/transaction';
import { REPEATING_EVENTS } from '../../../../../website/common/script/content/constants/events';
import { SubscriptionEventModel } from '../../../../../website/server/models/analytics/subscriptionEvent';
describe('payments/index', () => {
let user;
@@ -36,8 +36,6 @@ describe('payments/index', () => {
sandbox.stub(sender, 'sendTxn');
sandbox.stub(user, 'sendMessage');
sandbox.stub(analytics.mockAnalyticsService, 'trackPurchase');
sandbox.stub(analytics.mockAnalyticsService, 'track');
sandbox.stub(notifications, 'sendNotification');
data = {
@@ -97,6 +95,16 @@ describe('payments/index', () => {
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: recipient._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'subscribed');
expect(subscriptionEvent).to.have.property('userId', recipient._id);
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('adds extra months to an existing subscription', async () => {
recipient.purchased.plan = plan;
@@ -298,28 +306,6 @@ describe('payments/index', () => {
expect(notifications.sendNotification).to.be.calledOnce;
});
it('tracks subscription purchase as gift', async () => {
await api.createSubscription(data);
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: true,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('No Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEventList').returns([]);
@@ -455,6 +441,16 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCreated).to.exist;
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('ipAddress');
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('sets plan.dateCreated if it did not previously exist', async () => {
expect(user.purchased.plan.dateCreated).to.not.exist;
@@ -543,29 +539,24 @@ describe('payments/index', () => {
expect(sender.sendTxn).to.be.calledWith(data.user, 'subscription-begins');
});
it('tracks subscription purchase', async () => {
await api.createSubscription(data);
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: false,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('Upgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
data.sub.key = 'basic_6mo';
data.updatedFrom = { key: 'basic_earned' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_6mo' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'upgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
@@ -608,6 +599,23 @@ describe('payments/index', () => {
});
context('Downgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
data.sub.key = 'basic_earned';
data.updatedFrom = { key: 'basic_6mo' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_earned' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'downgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('from basic_6mo to basic_earned', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
@@ -1136,6 +1144,15 @@ describe('payments/index', () => {
expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days
});
it('tracks subscription events', async () => {
await api.cancelSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('userId', user._id);
});
it('adds extraMonths to dateTerminated value', async () => {
user.purchased.plan.extraMonths = 2;
@@ -30,13 +30,15 @@ describe('paypal - subscribeCancel', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = groupCustomerId;
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
nextBillingDate = new Date();
@@ -236,7 +236,7 @@ describe('Stripe - Checkout', () => {
const group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
const groupId = group._id;
@@ -376,11 +376,13 @@ describe('Stripe - Checkout', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
groupId = group._id;
await group.save();
user.guilds.push(group._id);
await user.save();
});
it('throws if user is not allowed to change group plan', async () => {
@@ -136,7 +136,7 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
groupId = group._id;
@@ -315,12 +315,14 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
groupId = group._id;
});
@@ -1,50 +0,0 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analytics middleware', () => {
let res; let req; let
next;
const pathToAnalyticsMiddleware = '../../../../website/server/middlewares/analytics';
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('attaches analytics object to res', () => {
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics).to.exist;
});
it('attaches stubbed methods for non-prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase);
});
it('attaches real methods for prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase);
});
});
@@ -1,19 +0,0 @@
import {
generateUser,
requester,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /analytics/track/:eventName', () => {
it('calls res.analytics', async () => {
const user = await generateUser();
sandbox.spy(analytics, 'track');
const requestWithHeaders = requester(user, { 'x-client': 'habitica-web' });
await requestWithHeaders.post('/analytics/track/eventName', { data: 'example' }, { 'x-client': 'habitica-web' });
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('eventName', sandbox.match({ data: 'example' }));
sandbox.restore();
});
});
@@ -5,6 +5,8 @@ import {
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-integration/v3';
import { model as Group } from '../../../../../website/server/models/group';
import { TAVERN_ID } from '../../../../../website/common/script/constants';
describe('POST /challenges/:challengeId/join', () => {
it('returns error when challengeId is not a valid UUID', async () => {
@@ -27,6 +29,37 @@ describe('POST /challenges/:challengeId/join', () => {
});
});
context('public Guild', () => {
let group;
let groupLeader;
let members;
let challenge;
before(async () => {
({ group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: 'test group',
type: 'guild',
privacy: 'private',
},
members: 1,
upgradeToGroupPlan: true,
}));
challenge = await generateChallenge(groupLeader, group);
// Creation API is shut down, we need to simulate an extant public group
await Group.updateOne({ _id: group._id }, { $set: { privacy: 'public' }, $unset: { 'purchased.plan': 1 } }).exec();
});
it('returns error when challengeId is in an old public Guild', async () => {
const authorizedUser = members[0]; // eslint-disable-line prefer-destructuring
await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
});
context('Joining a valid challenge', () => {
let groupLeader;
let group;
@@ -66,6 +99,15 @@ describe('POST /challenges/:challengeId/join', () => {
expect(res.name).to.equal(challenge.name);
});
it('succeeds when it\'s a Tavern challenge, even if the user isn\'t a "member" of Tavern', async () => {
const tavern = await groupLeader.get(`/groups/${TAVERN_ID}`);
const tavernChallenge = await generateChallenge(groupLeader, tavern, { prize: 1 });
const generalUser = await generateUser();
const res = await generalUser.post(`/challenges/${tavernChallenge._id}/join`);
expect(res.name).to.equal(tavernChallenge.name);
});
it('returns challenge data', async () => {
const res = await authorizedUser.post(`/challenges/${challenge._id}/join`);
@@ -62,9 +62,9 @@ describe('GET /groups/:groupId/chat', () => {
it('returns error if user attempts to fetch a sunset Guild', async () => {
await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
});
@@ -121,9 +121,9 @@ describe('POST /chat/:chatId/like', () => {
await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
});
@@ -1,7 +1,6 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /user/sleep', () => {
let user;
@@ -23,15 +22,4 @@ describe('POST /user/sleep', () => {
await user.sync();
expect(user.preferences.sleep).to.be.false;
});
it('sends sleep status to analytics service', async () => {
sandbox.spy(analytics, 'track');
await user.post('/user/sleep');
await user.sync();
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('sleep', sandbox.match.has('status', user.preferences.sleep));
sandbox.restore();
});
});
@@ -9,6 +9,7 @@ import {
} from '../../../../../helpers/api-integration/v3';
import { ApiUser } from '../../../../../helpers/api-integration/api-classes';
import { encrypt } from '../../../../../../website/server/libs/encryption';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
function generateRandomUserName () {
return (Date.now() + uuid()).substring(0, 20);
@@ -41,6 +42,25 @@ describe('POST /user/auth/local/register', () => {
expect(user.newUser).to.eql(true);
});
it('tracks a registration event', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
const password = 'password';
const user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'local');
});
it('registers a new user and sets verifiedUsername to true', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
@@ -7,6 +7,7 @@ import {
getProperty,
} from '../../../../../helpers/api-integration/v3';
import apiErrorMessages from '../../../../../../website/common/script/errors/apiErrorMessages';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
describe('POST /user/auth/social', () => {
let api;
@@ -65,6 +66,19 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('tracks a registration event', async () => {
const socialUser = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: socialUser.id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', socialUser.id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -231,6 +245,17 @@ describe('POST /user/auth/social', () => {
expect(response.newUser).to.be.false;
});
it('does not track a registration event for existing users', async () => {
const beforeEvents = await RegistrationEventModel.find({ userId: user._id });
await user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(beforeEvents.length);
});
it('does not log into other account if social auth already exists', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
+1 -10
View File
@@ -13,7 +13,6 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buy', () => {
let user;
const analytics = { track () {} };
beforeEach(() => {
user = generateUser({
@@ -32,12 +31,6 @@ describe('shared.ops.buy', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('returns error when key is not provided', async () => {
@@ -51,10 +44,8 @@ describe('shared.ops.buy', () => {
it('buys health potion', async () => {
user.stats.hp = 30;
await buy(user, { params: { key: 'potion' } }, analytics);
await buy(user, { params: { key: 'potion' } });
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('adds equipment to inventory', async () => {
+3 -7
View File
@@ -29,10 +29,9 @@ describe('shared.ops.buyArmoire', () => {
const YIELD_EQUIPMENT = 0.5;
const YIELD_FOOD = 0.7;
const YIELD_EXP = 0.9;
const analytics = { track () {} };
async function buyArmoire (_user, _req, _analytics) {
const buyOp = new BuyArmoireOperation(_user, _req, _analytics);
async function buyArmoire (_user, _req) {
const buyOp = new BuyArmoireOperation(_user, _req);
return buyOp.purchase();
}
@@ -50,12 +49,10 @@ describe('shared.ops.buyArmoire', () => {
user.items.food = {};
sandbox.stub(randomValFns, 'trueRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
randomValFns.trueRandom.restore();
analytics.track.restore();
});
context('failure conditions', () => {
@@ -147,7 +144,7 @@ describe('shared.ops.buyArmoire', () => {
expect(_.size(user.items.gear.owned)).to.equal(2);
await buyArmoire(user, {}, analytics);
await buyArmoire(user, {});
expect(_.size(user.items.gear.owned)).to.equal(3);
@@ -155,7 +152,6 @@ describe('shared.ops.buyArmoire', () => {
expect(armoireCount).to.eql(_.size(getFullArmoire()) - 2);
expect(user.stats.gp).to.eql(100);
expect(analytics.track).to.be.calledTwice;
});
});
});
+3 -12
View File
@@ -1,6 +1,5 @@
/* eslint-disable camelcase */
import sinon from 'sinon'; // eslint-disable-line no-shadow
import {
generateUser,
} from '../../../helpers/common.helper';
@@ -11,15 +10,14 @@ import i18n from '../../../../website/common/script/i18n';
import { BuyGemOperation } from '../../../../website/common/script/ops/buy/buyGem';
import planGemLimits from '../../../../website/common/script/libs/planGemLimits';
async function buyGem (user, req, analytics) {
const buyOp = new BuyGemOperation(user, req, analytics);
async function buyGem (user, req) {
const buyOp = new BuyGemOperation(user, req);
return buyOp.purchase();
}
describe('shared.ops.buyGem', () => {
let user;
const analytics = { track () {} };
const goldPoints = 40;
const gemsBought = 40;
const userGemAmount = 10;
@@ -35,23 +33,16 @@ describe('shared.ops.buyGem', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Gems', () => {
it('purchases gems', async () => {
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } }, analytics);
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } });
expect(message).to.equal(i18n.t('plusGem', { count: 1 }));
expect(user.balance).to.equal(userGemAmount + 0.25);
expect(user.purchased.plan.gemsBought).to.equal(1);
expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate);
expect(analytics.track).to.be.calledOnce;
});
it('purchases gems with a different language than the default', async () => {
+3 -10
View File
@@ -10,10 +10,9 @@ import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyHealthPotion', () => {
let user;
const analytics = { track () {} };
async function buyHealthPotion (_user, _req, _analytics) {
const buyOp = new BuyHealthPotionOperation(_user, _req, _analytics);
async function buyHealthPotion (_user, _req) {
const buyOp = new BuyHealthPotionOperation(_user, _req);
return buyOp.purchase();
}
@@ -32,19 +31,13 @@ describe('shared.ops.buyHealthPotion', () => {
},
stats: { gp: 200 },
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Potion', () => {
it('recovers 15 hp', async () => {
user.stats.hp = 30;
await buyHealthPotion(user, {}, analytics);
await buyHealthPotion(user, {});
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('does not increase hp above 50', async () => {
+5 -9
View File
@@ -13,15 +13,14 @@ import {
import i18n from '../../../../website/common/script/i18n';
import { errorMessage } from '../../../../website/common/script/libs/errorMessage';
async function buyGear (user, req, analytics) {
const buyOp = new BuyMarketGearOperation(user, req, analytics);
async function buyGear (user, req) {
const buyOp = new BuyMarketGearOperation(user, req);
return buyOp.purchase();
}
describe('shared.ops.buyMarketGear', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -47,14 +46,12 @@ describe('shared.ops.buyMarketGear', () => {
sinon.stub(shared, 'randomVal');
sinon.stub(shared.onboarding, 'checkOnboardingStatus');
sinon.stub(shared.fns, 'predictableRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
shared.randomVal.restore();
shared.fns.predictableRandom.restore();
shared.onboarding.checkOnboardingStatus.restore();
analytics.track.restore();
if (clock) {
clock.restore();
@@ -65,7 +62,7 @@ describe('shared.ops.buyMarketGear', () => {
it('adds equipment to inventory', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
await buyGear(user, { params: { key: 'armor_warrior_1' } });
expect(user.items.gear.owned).to.eql({
weapon_warrior_0: true,
@@ -92,13 +89,12 @@ describe('shared.ops.buyMarketGear', () => {
eyewear_special_whiteHalfMoon: true,
eyewear_special_yellowHalfMoon: true,
});
expect(analytics.track).to.be.calledOnce;
});
it('adds the onboarding achievement to the user and checks the onboarding status', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
await buyGear(user, { params: { key: 'armor_warrior_1' } });
expect(user.addAchievement).to.be.calledOnce;
expect(user.addAchievement).to.be.calledWith('purchasedEquipment');
@@ -111,7 +107,7 @@ describe('shared.ops.buyMarketGear', () => {
user.stats.gp = 31;
user.achievements.purchasedEquipment = true;
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
await buyGear(user, { params: { key: 'armor_warrior_1' } });
expect(user.addAchievement).to.not.be.called;
});
+2 -5
View File
@@ -14,7 +14,6 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyMysterySet', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -27,11 +26,9 @@ describe('shared.ops.buyMysterySet', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -93,7 +90,7 @@ describe('shared.ops.buyMysterySet', () => {
context('successful purchases', () => {
it('buys Steampunk Accessories Set', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '301404' } }, analytics);
await buyMysterySet(user, { params: { key: '301404' } });
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true);
@@ -106,7 +103,7 @@ describe('shared.ops.buyMysterySet', () => {
it('buys mystery set if it is available', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-16'));
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '201601' } }, analytics);
await buyMysterySet(user, { params: { key: '201601' } });
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('shield_mystery_201601', true);
+2 -5
View File
@@ -12,10 +12,9 @@ describe('shared.ops.buyQuestGems', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGemOperation(_user, _req, _analytics);
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGemOperation(_user, _req);
return buyOp.purchase();
}
@@ -25,13 +24,11 @@ describe('shared.ops.buyQuestGems', () => {
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sinon.useFakeTimers(new Date('2024-01-16'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
+8 -17
View File
@@ -12,21 +12,15 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyQuest', () => {
let user;
const analytics = { track () {} };
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req, _analytics);
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('buys a Quest scroll', async () => {
@@ -35,12 +29,11 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
}, analytics);
});
expect(user.items.quests).to.eql({
dilatoryDistress1: 1,
});
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('if a user\'s count of a quest scroll is negative, it will be reset to 0 before incrementing when they buy a new one.', async () => {
@@ -49,10 +42,9 @@ describe('shared.ops.buyQuest', () => {
user.items.quests[key] = -1;
await buyQuest(user, {
params: { key },
}, analytics);
});
expect(user.items.quests[key]).to.equal(1);
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('buys a Quest scroll with the right quantity if a string is passed for quantity', async () => {
@@ -61,13 +53,13 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
}, analytics);
});
await buyQuest(user, {
params: {
key: 'dilatoryDistress1',
},
quantity: '3',
}, analytics);
});
expect(user.items.quests).to.eql({
dilatoryDistress1: 4,
@@ -82,7 +74,7 @@ describe('shared.ops.buyQuest', () => {
key: 'dilatoryDistress1',
},
quantity: 'a',
}, analytics);
});
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -187,12 +179,11 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress3',
},
}, analytics);
});
expect(user.items.quests).to.eql({
dilatoryDistress3: 1,
});
expect(user.stats.gp).to.equal(100);
expect(analytics.track).to.be.calledOnce;
});
});
+5 -11
View File
@@ -14,20 +14,17 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buySpecialSpell', () => {
let user;
let clock;
const analytics = { track () {} };
async function buySpecialSpell (_user, _req, _analytics) {
const buyOp = new BuySpellOperation(_user, _req, _analytics);
async function buySpecialSpell (_user, _req) {
const buyOp = new BuySpellOperation(_user, _req);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -78,7 +75,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'thankyou',
},
}, analytics);
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
@@ -89,7 +86,6 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('buys a limited card when it is available', async () => {
@@ -101,7 +97,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'nye',
},
}, analytics);
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.nye).to.equal(1);
@@ -112,7 +108,6 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the card is not currently available', async () => {
@@ -140,7 +135,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'seafoam',
},
}, analytics);
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.seafoam).to.equal(1);
@@ -151,7 +146,6 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the spell is not currently available', async () => {
+3 -10
View File
@@ -13,21 +13,15 @@ import { BuyHourglassMountOperation } from '../../../../website/common/script/op
describe('common.ops.hourglassPurchase', () => {
let user;
const analytics = { track () {} };
async function buyMount (_user, _req, _analytics) {
const buyOp = new BuyHourglassMountOperation(_user, _req, _analytics);
async function buyMount (_user, _req) {
const buyOp = new BuyHourglassMountOperation(_user, _req);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('failure conditions', () => {
@@ -131,12 +125,11 @@ describe('common.ops.hourglassPurchase', () => {
it('buys a pet', async () => {
user.purchased.plan.consecutive.trinkets = 2;
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } }, analytics);
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } });
expect(message).to.eql(i18n.t('hourglassPurchase'));
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.items.pets).to.eql({ 'MantisShrimp-Base': 5 });
expect(analytics.track).to.be.calledOnce;
});
it('buys a mount', async () => {
+4 -8
View File
@@ -17,20 +17,17 @@ describe('shared.ops.purchase', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
before(() => {
user = generateUser({ 'stats.class': 'rogue' });
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sandbox.useFakeTimers(new Date('2024-01-10'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
@@ -187,11 +184,10 @@ describe('shared.ops.purchase', () => {
const type = 'eggs';
const key = 'Wolf';
await purchase(user, { params: { type, key } }, analytics);
await purchase(user, { params: { type, key } });
expect(user.items[type][key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
expect(analytics.track).to.be.calledOnce;
});
it('purchases hatchingPotions', async () => {
@@ -332,7 +328,7 @@ describe('shared.ops.purchase', () => {
const key = 'Wolf';
try {
await purchase(user, { params: { type, key }, quantity: 'jamboree' }, analytics);
await purchase(user, { params: { type, key }, quantity: 'jamboree' });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -345,7 +341,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: -2 }, analytics);
await purchase(user, { params: { type, key }, quantity: -2 });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -358,7 +354,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: 2.9 }, analytics);
await purchase(user, { params: { type, key }, quantity: 2.9 });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
+17 -8
View File
@@ -211,22 +211,32 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(2);
});
it('does not increment rebirth achievements when level is lower than previous', async () => {
it('increments rebirth achievements even when level is lower than previous', async () => {
user.stats.lvl = 2;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(1);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(3);
});
it('always increments rebirth achievements when level is MAX_LEVEL', async () => {
it('updates rebirthLevel when current level is higher than previous', async () => {
user.stats.lvl = 5;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(5);
});
it('increments rebirth achievements when level is MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL;
user.achievements.rebirths = 1;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 1;
user.achievements.rebirthLevel = MAX_LEVEL;
await rebirth(user);
@@ -234,11 +244,10 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL);
});
it('always increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
it('increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL + 1;
user.achievements.rebirths = 1;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 2;
user.achievements.rebirthLevel = MAX_LEVEL;
await rebirth(user);
+41
View File
@@ -0,0 +1,41 @@
import { getMatchingSwap, makeSubstitutionMap } from '../../website/common/script/content/constants/aprilFools';
describe('April Fools', () => {
describe('getMatchingSwap', () => {
it('returns Veggie for 2020', () => {
const swap = getMatchingSwap(new Date('2020-04-01'));
expect(swap).to.equal('Veggie');
});
it('returns Alien for 2026', () => {
const swap = getMatchingSwap(new Date('2026-04-01'));
expect(swap).to.equal('Alien');
});
it('Cycles through swaps correctly', () => {
const swap = getMatchingSwap(new Date('2027-04-01'));
expect(swap).to.equal('Veggie');
});
});
describe('makeSubstitutionMap', () => {
it('returns correct substitution for Veggie', () => {
const substitutions = makeSubstitutionMap('Veggie');
expect(substitutions.pets['Pet-Wolf-']).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets['Pet-TigerCub-']).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Veggie');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Veggie');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets.noPetIOS).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Veggie');
});
it('returns correct substitution for Cryptid', () => {
const substitutions = makeSubstitutionMap('Cryptid');
expect(substitutions.pets['Pet-Fox-']).to.equal('Pet-Fox-Cryptid');
expect(substitutions.pets['Pet-FlyingPig-']).to.equal('Pet-FlyingPig-Cryptid');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Cryptid');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Cryptid');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Cryptid');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Cryptid');
});
});
});
@@ -40,7 +40,6 @@ function _requestMaker (user, method, additionalSets = {}) {
|| route.indexOf('/paypal') === 0
|| route.indexOf('/amazon') === 0
|| route.indexOf('/stripe') === 0
|| route.indexOf('/analytics') === 0
) {
url += `${route}`;
} else {
+2 -123
View File
@@ -1,57 +1,3 @@
.quest_lostMasterclasser4 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_lostMasterclasser4.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_windup.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_solarSystem {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_solarSystem.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_virtualpet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_virtualpet.gif") no-repeat;
width: 219px;
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
width: 68px;
height: 68px;
}
.Pet_HatchingPotion_Dessert {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Dessert.gif") no-repeat;
}
.Pet_HatchingPotion_Veggie {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Veggie.gif") no-repeat;
}
.Pet_HatchingPotion_Windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Windup.gif") no-repeat;
}
.Pet_HatchingPotion_VirtualPet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_VirtualPet.gif") no-repeat;
}
.Pet_HatchingPotion_Fungi {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Fungi.gif") no-repeat;
}
.Pet_HatchingPotion_Cryptid {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -80,6 +26,7 @@
margin-left: -3px;
margin-top: -18px;
}
.slim_armor_special_0, .broad_armor_special_0, .shield_special_0 {
width: 90px;
height: 90px;
@@ -87,7 +34,6 @@
/* Critical */
.weapon_special_critical {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_critical.gif") no-repeat;
width: 90px;
height: 90px;
margin-left:-12px;
@@ -98,6 +44,7 @@
.weapon_special_1 {
margin-left: -12px;
}
.broad_armor_special_1, .slim_armor_special_1, .head_special_1 {
width: 90px;
height: 90px;
@@ -106,36 +53,15 @@
.back_special_heroicAureole {
width: 114px;
height: 90px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_special_heroicAureole.gif") no-repeat;
}
.head_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeHelmet.gif") no-repeat;
}
.head_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalHelmet.gif") no-repeat;
margin-top: 3px;
}
.broad_armor_special_0,.slim_armor_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeArmor.gif") no-repeat;
}
.broad_armor_special_1,.slim_armor_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalArmor.gif") no-repeat;
}
.shield_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Shield-TormentedSkull.gif") no-repeat;
}
.weapon_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Weapon-DarkSoulsBlade.gif") no-repeat;
}
.Pet-Wolf-Cerberus {
width: 105px;
height: 72px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-CerberusPup.gif") no-repeat;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019, .eyewear_special_ks2019, .head_special_ks2019, .shield_special_ks2019 {
@@ -143,36 +69,17 @@
height: 120px;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonArmor.gif") no-repeat;
}
.eyewear_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonVisor.gif") no-repeat;
}
.head_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonHelm.gif") no-repeat;
}
.shield_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonShield.gif") no-repeat;
}
.weapon_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonGlaive.gif") no-repeat;
width: 120px;
height: 120px;
}
.Pet-Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-Gryphatrice.gif") no-repeat;
width: 81px;
height: 99px;
}
.Pet-Gryphatrice-Jubilant {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Gryphatrice-Jubilant.gif") no-repeat;
width: 81px;
height: 96px;
}
@@ -182,39 +89,11 @@
height: 135px;
}
.Mount_Head_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Head-Gryphatrice.gif") no-repeat;
}
.Mount_Body_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Body-Gryphatrice.gif") no-repeat;
}
.Mount_Head_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Head_Dragon-Hydra.gif") no-repeat;
}
.Mount_Body_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Body_Dragon-Hydra.gif") no-repeat;
}
.background_airship, .background_clocktower, .background_steamworks {
width: 141px;
height: 147px;
}
.background_airship {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_airship.gif") no-repeat;
}
.background_clocktower {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_clocktower.gif") no-repeat;
}
.background_steamworks {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_steamworks.gif") no-repeat;
}
[class*="Mount_Head_"],
[class*="Mount_Body_"] {
margin-top:18px; /* Sprite accommodates 105x123 box */
@@ -1801,6 +1801,11 @@
width: 141px;
height: 147px;
}
.background_on_a_strange_planet {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_on_a_strange_planet.png');
width: 141px;
height: 147px;
}
.background_on_tree_branch {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_on_tree_branch.png');
width: 141px;
@@ -31485,8 +31490,8 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit .png');
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
@@ -53223,6 +53228,11 @@
width: 81px;
height: 99px;
}
.Pet-BearCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-BearCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-BearCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-BearCub-Amber.png');
width: 81px;
@@ -53713,6 +53723,11 @@
width: 81px;
height: 99px;
}
.Pet-Cactus-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Cactus-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Cactus-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Cactus-Amber.png');
width: 81px;
@@ -54503,6 +54518,11 @@
width: 81px;
height: 99px;
}
.Pet-Dragon-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Dragon-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Dragon-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Dragon-Amber.png');
width: 81px;
@@ -54998,6 +55018,11 @@
width: 81px;
height: 99px;
}
.Pet-FlyingPig-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-FlyingPig-Alien.png');
width: 81px;
height: 99px;
}
.Pet-FlyingPig-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-FlyingPig-Amber.png');
width: 81px;
@@ -55333,6 +55358,11 @@
width: 81px;
height: 99px;
}
.Pet-Fox-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Fox-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Fox-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Fox-Amber.png');
width: 81px;
@@ -56113,6 +56143,11 @@
width: 81px;
height: 99px;
}
.Pet-LionCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-LionCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-LionCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-LionCub-Amber.png');
width: 81px;
@@ -56718,6 +56753,11 @@
width: 81px;
height: 99px;
}
.Pet-PandaCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-PandaCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-PandaCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-PandaCub-Amber.png');
width: 81px;
@@ -58113,6 +58153,11 @@
width: 81px;
height: 99px;
}
.Pet-TigerCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-TigerCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-TigerCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-TigerCub-Amber.png');
width: 81px;
@@ -58758,6 +58803,11 @@
width: 81px;
height: 99px;
}
.Pet-Wolf-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Wolf-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Amber.png');
width: 81px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,5 @@
<svg width="330" height="80" viewBox="0 0 330 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M159.797 60.5502C165.534 61.8466 171.631 62.7536 178.221 63.1767C208.94 65.1492 233.733 56.6838 260.68 47.483C282.33 40.091 305.37 32.2243 333.99 28.9144L336 16.3018C260.81 7.08865 233.373 23.1672 205.362 39.5825C192.037 47.3908 178.583 55.2753 159.797 60.5502Z" fill="#BDA8FF"/>
<path d="M0 80L331.948 79.9998V29.1594C268.976 36.9871 233.03 66.6959 178.221 63.1767C112.951 58.9858 95.9516 7.31934 0.000104656 0L0 80Z" fill="#925CF3"/>
<path d="M203.54 40.6496C166.339 36.8525 141.531 39.6251 122.334 45.4666C133.94 51.8989 145.792 57.3851 159.797 60.5502C177.727 55.5155 190.801 48.1036 203.54 40.6496Z" fill="#D5C8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 803 B

@@ -1,53 +1,228 @@
<template>
<b-modal
id="rebirth"
:title="$t('modalAchievement')"
size="md"
:hide-footer="true"
size="sm"
:hide-header="true"
>
<div class="modal-body">
<div class="col-12">
<!-- @TODO: +achievementAvatar('sun',0)--><achievement-avatar class="avatar" />
</div><div class="col-6 offset-3 text-center">
<div v-if="user.achievements.rebirthLevel < 100">
{{ $t('rebirthAchievement', {
number: user.achievements.rebirths,
level: user.achievements.rebirthLevel}) }}
</div><div v-if="user.achievements.rebirthLevel >= 100">
{{ $t('rebirthAchievement100', {number: user.achievements.rebirths}) }}
</div><br><button
class="btn btn-primary"
@click="close()"
>
{{ $t('huzzah') }}
</button>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthNewAchievement') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<Sprite
class="achievement-icon"
image-name="achievement-sun2x"
/>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
</div>
</div><achievement-footer />
<p class="subtitle">
{{ $t('rebirthNewAdventure') }}
</p>
<p
class="description"
v-html="achievementText"
></p>
<p
v-once
class="stack-info"
>
{{ $t('rebirthStackInfo') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="footer-wave"
v-html="icons.purpleWaves"
></div>
</b-modal>
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth {
.modal-dialog {
width: 330px;
}
.modal-content {
border: none;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
border-radius: 0;
margin: 0;
line-height: 0;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 16px;
}
.icon-area {
margin-bottom: 16px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.achievement-icon {
margin: 0 24px;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-10;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 12px;
color: $gray-50;
}
.stack-info {
font-size: 0.875rem;
line-height: 1.71;
color: $gray-50;
margin-bottom: 24px;
}
.btn-primary {
margin-bottom: 8px;
}
.footer-wave {
width: 100%;
::v-deep svg {
display: block;
width: calc(100% + 8px);
height: auto;
margin: 0 -4px -4px;
}
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import closeIcon from '@/assets/svg/close.svg?raw';
import Sprite from '@/components/ui/sprite';
import starGroup from '@/assets/svg/star-group.svg?raw';
import purpleWaves from '@/assets/svg/purple-waves.svg?raw';
import { mapState } from '@/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
Sprite,
},
data () {
return {
icons: Object.freeze({
starGroup,
purpleWaves,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
achievementText () {
const rebirths = this.user.achievements.rebirths || 0;
const level = this.user.achievements.rebirthLevel || 0;
if (level >= 100) {
return this.$t('rebirthAchievement100', { number: rebirths, level });
}
if (rebirths === 1) {
return this.$t('rebirthAchievement', { number: rebirths, level });
}
return this.$t('rebirthAchievementPlural', { number: rebirths, level });
},
},
methods: {
close () {
@@ -1,41 +1,186 @@
<template>
<b-modal
id="rebirth-enabled"
:title="$t('rebirthNew')"
size="md"
:hide-footer="true"
size="sm"
:hide-header="true"
>
<div class="modal-body">
<div class="col-12">
<div class="rebirth_orb"></div>
<p>
<span>{{ $t('rebirthUnlock') }}</span>
</p>
</div>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="modal-footer">
<div class="col-12 text-center">
<button
class="btn btn-primary"
@click="close()"
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthUnlockedNewItem') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<img
class="orb-icon"
src="@/assets/images/rebirth-orb.png"
alt="Orb of Rebirth"
>
{{ $t('close') }}
</button>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
</div>
<p
v-once
class="subtitle"
>
{{ $t('rebirthUnlockedOrb') }}
</p>
<p
v-once
class="description"
>
{{ $t('rebirthUnlockedDesc') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="clearfix"
></div>
</b-modal>
</template>
<style scoped>
.rebirth_orb {
margin: 0 auto;
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth-enabled {
.modal-dialog {
width: 330px;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 12px;
}
.icon-area {
margin-bottom: 12px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.orb-icon {
width: 62px;
height: 62px;
margin: 0 24px;
image-rendering: pixelated;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-50;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 24px;
color: $gray-100;
}
.btn-primary {
margin-bottom: 24px;
}
</style>
<script>
import closeIcon from '@/assets/svg/close.svg?raw';
import starGroup from '@/assets/svg/star-group.svg?raw';
import { mapState } from '@/libs/store';
export default {
data () {
return {
icons: Object.freeze({
starGroup,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
},
+5 -4
View File
@@ -321,10 +321,11 @@ export default {
return null;
},
petClass () {
const foolEvent = this.currentEventList?.find(event => event.aprilFools && moment()
.isBetween(event.start, event.end));
if (foolEvent) {
return this.foolPet(this.member.items.currentPet, foolEvent.aprilFools);
const substitutionEvent = this.currentEventList?.find(event => event.spriteSubstitutions
&& moment().isBetween(event.start, event.end));
if (substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return this.foolPet(`Pet-${this.member.items.currentPet}`,
substitutionEvent.spriteSubstitutions.pets);
}
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
return '';
@@ -187,7 +187,8 @@
</div>
</div>
<div
v-if="user.purchased.background.birthday_bash"
v-if="user.purchased.background.birthday_bash
|| user.purchased.background.on_a_strange_planet"
>
<div
class="row justify-content-center title-row mb-3"
@@ -198,7 +198,6 @@ import dailyIcon from '@/assets/svg/daily.svg?raw';
import todoIcon from '@/assets/svg/todo.svg?raw';
import rewardIcon from '@/assets/svg/reward.svg?raw';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
export default {
@@ -438,14 +437,6 @@ export default {
return false;
},
changeMirrorPreference (newVal) {
Analytics.track({
eventName: 'mirror tasks',
eventAction: 'mirror tasks',
eventCategory: 'behavior',
hitType: 'event',
mirror: newVal,
group: this.group._id,
}, { trackOnClient: true });
const groupsToMirror = this.user.preferences.tasks.mirrorGroupTasks || [];
if (newVal) { // we're turning copy ON for this group
groupsToMirror.push(this.group._id);
@@ -240,7 +240,6 @@
<script>
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import notifications from '@/mixins/notifications';
import closeX from '../ui/closeX';
@@ -276,11 +275,6 @@ export default {
this.$store.state.party.data = party;
this.user.party._id = party._id;
Analytics.updateUser({
partyID: party._id,
partySize: 1,
});
this.$root.$emit('bv::hide::modal', 'create-party-modal');
await this.$router.push('/party');
},
@@ -314,7 +314,6 @@ import extend from 'lodash/extend';
import groupUtilities from '@/mixins/groupsUtilities';
import styleHelper from '@/mixins/styleHelper';
import { mapGetters } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import participantListModal from './participantListModal';
import groupFormModal from './groupFormModal';
import groupGemsModal from '@/components/groups/groupGemsModal';
@@ -560,7 +559,6 @@ export default {
if (this.isParty) {
data.type = 'party';
Analytics.updateUser({ partySize: null, partyID: null });
this.$store.state.partyMembers = [];
}
@@ -334,7 +334,6 @@ import orderBy from 'lodash/orderBy';
import * as quests from '@/../../common/script/content/quests';
import getItemInfo from '@/../../common/script/libs/getItemInfo';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import navigationBack from '@/assets/svg/navigation_back.svg?raw';
import questDialogContent from '../shops/quests/questDialogContent';
@@ -421,11 +420,6 @@ export default {
async questInit () {
this.loading = true;
Analytics.updateUser({
partyID: this.group._id,
partySize: this.group.memberCount,
});
const groupId = this.group._id || this.user.party._id;
const key = this.selectedQuest;
@@ -123,7 +123,6 @@
<script>
import orderBy from 'lodash/orderBy';
import * as Analytics from '@/libs/analytics';
import { mapGetters, mapActions } from '@/libs/store';
import MemberDetails from '../memberDetails';
import createPartyModal from '../groups/createPartyModal';
@@ -236,22 +235,8 @@ export default {
},
async createOrInviteParty () {
if (this.user.party._id) {
await Analytics.track({
eventName: 'Header Party CTA',
eventAction: 'Header Party CTA',
eventCategory: 'behavior',
hitType: 'event',
state: 'Find Party Members',
});
this.$router.push('/looking-for-party');
} else {
await Analytics.track({
eventName: 'Header Party CTA',
eventAction: 'Header Party CTA',
eventCategory: 'behavior',
hitType: 'event',
state: 'Get Started',
});
this.$root.$emit('bv::show::modal', 'create-party-modal');
}
},
@@ -182,12 +182,10 @@ export default {
return 'GreyedOut';
},
imageName () {
const foolEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.aprilFools);
if (this.isOwned() && foolEvent) {
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key, foolEvent.aprilFools)}`;
const petString = `${this.item.eggKey}-${this.item.key}`;
return `stable_${this.foolPet(petString, foolEvent.aprilFools)}`;
const substitutionEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.spriteSubstitutions);
if (this.isOwned() && substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return `stable_${this.foolPet(`Pet-${this.item.key}`, substitutionEvent.spriteSubstitutions.pets)}`;
}
if (this.isOwned() || (this.mountOwned() && this.isHatchable())) {
@@ -114,7 +114,6 @@ import { mapState } from '@/libs/store';
import notifications from '@/mixins/notifications';
import guide from '@/mixins/guide';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
import yesterdailyModal from './tasks/yesterdailyModal';
import newStuff from './news/modal';
@@ -648,15 +647,6 @@ export default {
// Reset daily analytics actions
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 0);
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 0);
} else {
// Note a failed cron event, for our records and investigation
Analytics.track({
eventName: 'cron failed',
eventAction: 'cron failed',
eventCategory: 'behavior',
hitType: 'event',
responseCode: response.status,
}, { trackOnClient: true });
}
// Sync
@@ -727,14 +717,14 @@ export default {
this.$root.$emit('habitica:won-challenge', notification);
break;
case 'REBIRTH_ACHIEVEMENT':
if (localStorage.getItem('show-rebirth-confirmation') === 'true') {
markAsRead = false;
} else if (!this.isInitialLoadComplete) {
this.pendingRebirthNotification = notification;
markAsRead = false;
} else {
this.playSound('Achievement_Unlocked');
this.$root.$emit('bv::show::modal', 'rebirth');
if (localStorage.getItem('show-rebirth-confirmation') !== 'true') {
if (!this.isInitialLoadComplete) {
this.pendingRebirthNotification = notification;
markAsRead = false;
} else {
this.playSound('Achievement_Unlocked');
this.$root.$emit('bv::show::modal', 'rebirth');
}
}
break;
case 'STREAK_ACHIEVEMENT':
@@ -433,9 +433,6 @@ import lockableLabel from '@/components/tasks/modal-controls/lockableLabel';
import notificationsMixin from '@/mixins/notifications';
import paymentsMixin from '@/mixins/payments';
// analytics
import * as Analytics from '@/libs/analytics';
export default {
components: {
selectTranslatedArray,
@@ -536,16 +533,6 @@ export default {
this.close();
},
submit () {
if (this.paymentData.group && !this.paymentData.newGroup) {
Analytics.track({
hitType: 'event',
eventName: 'group plan upgrade',
eventAction: 'group plan upgrade',
eventCategory: 'behavior',
demographics: this.upgradedGroup.demographics,
type: this.paymentData.group.type,
}, { trackOnClient: true });
}
this.paymentData = {};
this.$root.$emit('bv::hide::modal', 'payments-success-modal');
},
@@ -56,7 +56,7 @@
v-if="addNew || availableToSelect.length > 0"
:class="{
'item-group': true,
'add-new': availableToSelect.length === 0 && search !== '',
'add-new': search !== '' && !hasExactMatch,
'scroll': availableToSelect.length > 5
}"
>
@@ -86,7 +86,7 @@
</b-dropdown-item-button>
<div
v-if="addNew"
v-if="addNew && search !== '' && !hasExactMatch"
class="hint"
>
{{ $t('pressEnterToAddTag', { tagName: search }) }}
@@ -171,7 +171,8 @@ $itemHeight: 2rem;
max-height: #{5*$itemHeight};
&.add-new {
height: 30px;
min-height: 30px;
height: auto;
.hint {
display: block;
@@ -245,6 +246,7 @@ export default {
selected: this.selectedItems,
search: '',
textbox: null,
itemsAdded: [],
};
},
computed: {
@@ -272,6 +274,16 @@ export default {
return filteredItems;
},
hasExactMatch () {
const searchTerm = this.search.trim().toLowerCase();
if (!searchTerm) return false;
if (this.itemsAdded.indexOf(searchTerm) !== -1) return true;
if (this.availableToSelect.length === 0) return false;
if (this.availableToSelect[0].name.toLowerCase() === searchTerm) {
return true;
}
return false;
},
},
watch: {
selected () {
@@ -310,6 +322,7 @@ export default {
this.closeSelectPopup();
},
selectItem (item) {
if (!item) return;
this.selectedItems.push(item.id);
this.$emit('toggle', item.id);
this.preventHide = true;
@@ -371,9 +384,16 @@ export default {
handleSubmit () {
if (!this.addNew) return;
const { search } = this;
this.$emit('addNew', search);
this.search = '';
// If there is a existing tag
if (this.hasExactMatch) {
this.selectItem(this.availableToSelect[0]);
this.search = '';
} else {
// Creating a new tag as there is no existing tag present
this.$emit('addNew', search);
this.itemsAdded.push(search.toLowerCase());
this.search = '';
}
},
},
};
+8 -50
View File
@@ -1,56 +1,14 @@
import includes from 'lodash/includes';
export default {
methods: {
foolPet (pet, prank) {
const SPECIAL_PETS = [
'Bear-Veteran',
'BearCub-Polar',
'Cactus-Veteran',
'Dragon-Hydra',
'Dragon-Veteran',
'Fox-Veteran',
'Gryphatrice-Jubilant',
'Gryphon-Gryphatrice',
'Gryphon-RoyalPurple',
'Hippogriff-Hopeful',
'Jackalope-RoyalPurple',
'JackOLantern-Base',
'JackOLantern-Ghost',
'JackOLantern-Glow',
'JackOLantern-RoyalPurple',
'Lion-Veteran',
'MagicalBee-Base',
'Mammoth-Base',
'MantisShrimp-Base',
'Orca-Base',
'Phoenix-Base',
'Tiger-Veteran',
'Turkey-Base',
'Turkey-Gilded',
'Wolf-Cerberus',
'Wolf-Veteran',
];
const BASE_PETS = [
'BearCub',
'Cactus',
'Dragon',
'FlyingPig',
'Fox',
'LionCub',
'PandaCub',
'TigerCub',
'Wolf',
];
if (!pet) return `Pet-TigerCub-${prank}`;
if (SPECIAL_PETS.indexOf(pet) !== -1) {
return `Pet-Dragon-${prank}`;
foolPet (pet, substitutions) {
if (!pet || pet === 'Pet-') return substitutions.noPet;
if (substitutions[pet]) return substitutions[pet];
for (const key in substitutions) {
if (pet.startsWith(key)) {
return substitutions[key];
}
}
const species = pet.slice(0, pet.indexOf('-'));
if (includes(BASE_PETS, species)) {
return `Pet-${species}-${prank}`;
}
return `Pet-BearCub-${prank}`;
return substitutions.default;
},
},
};
-11
View File
@@ -6,7 +6,6 @@ import { mapState } from '@/libs/store';
import encodeParams from '@/libs/encodeParams';
import notificationsMixin from '@/mixins/notifications';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
const STRIPE_PUB_KEY = import.meta.env.STRIPE_PUB_KEY;
@@ -207,16 +206,6 @@ export default {
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
throw checkoutSessionResult.error;
}
if (paymentType === 'groupPlan') {
Analytics.track({
hitType: 'event',
eventName: 'group plan create',
eventAction: 'group plan create',
eventCategory: 'behavior',
demographics: appState.newGroup.demographics,
type: appState.newGroup.type,
}, { trackOnClient: true });
}
} catch (err) {
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${err.message}`);
-10
View File
@@ -3,7 +3,6 @@ import Vue from 'vue';
import scoreTask from '@/../../common/script/ops/scoreTask';
import notifications from './notifications';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
export default {
@@ -58,15 +57,6 @@ export default {
const tasksScoredCount = getLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT);
if (!tasksScoredCount || tasksScoredCount < 2) {
Analytics.track({
eventName: 'task scored',
eventAction: 'task scored',
eventCategory: 'behavior',
hitType: 'event',
uuid: user._id,
taskType: task.type,
direction,
}, { trackOnClient: true });
if (!tasksScoredCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 1);
} else {
-2
View File
@@ -130,7 +130,6 @@ import PrivacyBanner from '@/components/header/banners/privacy';
import AppFooter from '@/components/appFooter';
import notificationsDisplay from '@/components/notifications';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import BuyModal from '@/components/shops/buyModal.vue';
import SelectMembersModal from '@/components/selectMembersModal.vue';
import notifications from '@/mixins/notifications';
@@ -276,7 +275,6 @@ export default {
}
}
Analytics.updateUser();
return this.loadAllTranslations();
}).then(() => {
this.$store.state.isUserLoaded = true;
+14 -10
View File
@@ -1,6 +1,5 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import * as Analytics from '@/libs/analytics';
import getStore from '@/store';
import handleRedirect from './handleRedirect';
@@ -85,6 +84,13 @@ const router = new VueRouter({
props: true,
},
{ name: 'profile', path: '/user/profile' },
{
name: 'avatar',
path: '/avatar',
children: [
{ name: 'backgrounds', path: 'backgrounds' },
],
},
{ name: 'stats', path: '/user/stats' },
{ name: 'achievements', path: '/user/achievements' },
{
@@ -311,15 +317,6 @@ router.beforeEach(async (to, from, next) => {
router.app.$root.$emit('update-party');
}
if (to.name === 'lookingForParty') {
Analytics.track({
hitType: 'event',
eventName: 'View Find Members',
eventAction: 'View Find Members',
eventCategory: 'behavior',
}, { trackOnClient: true });
}
// Redirect old guild urls
if (to.hash.indexOf('#/options/groups/guilds/') !== -1) {
const splits = to.hash.split('/');
@@ -410,6 +407,13 @@ router.beforeEach(async (to, from, next) => {
router.app.$root.$emit('bv::hide::modal', 'profile');
}
if (to.name === 'backgrounds') {
store.state.avatarEditorOptions.editingUser = true;
store.state.avatarEditorOptions.startingPage = 'backgrounds';
router.app.$root.$emit('bv::show::modal', 'avatar-modal');
return null;
}
return next();
});
-8
View File
@@ -1,6 +1,5 @@
import axios from 'axios';
import Vue from 'vue';
import * as Analytics from '@/libs/analytics';
export async function getChat (store, payload) {
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat?limit=400`);
@@ -17,13 +16,6 @@ export async function postChat (store, payload) {
url += `?previousMsg=${payload.previousMsg}`;
}
if (group.type === 'party') {
Analytics.updateUser({
partyID: group.id,
partySize: group.memberCount,
});
}
const response = await axios.post(url, {
message: payload.message,
});
@@ -1,7 +1,6 @@
import axios from 'axios';
import omit from 'lodash/omit';
import findIndex from 'lodash/findIndex';
import * as Analytics from '@/libs/analytics';
import { loadAsyncResource } from '@/libs/asyncResource';
export async function getPublicGuilds (store, payload) {
@@ -74,7 +73,6 @@ export async function join (store, payload) {
if (invitationI !== -1) invitations.parties.splice(invitationI, 1);
user.party._id = groupId;
Analytics.updateUser({ partyID: groupId });
// load the party members so that they get shown in the header
store.dispatch('party:getMembers');
}
@@ -18,7 +18,6 @@ import * as shops from './shops';
import * as snackbars from './snackbars';
import * as worldState from './worldState';
import * as news from './news';
import * as analytics from './analytics';
import * as faq from './faq';
import * as blockers from './blockers';
@@ -44,7 +43,6 @@ const actions = flattenAndNamespace({
snackbars,
worldState,
news,
analytics,
faq,
blockers,
});
@@ -1,26 +1,6 @@
import axios from 'axios';
import * as Analytics from '@/libs/analytics';
// export async function initQuest (store) {
// }
export async function sendAction (store, payload) { // eslint-disable-line import/prefer-default-export, max-len
// @TODO: Maybe move this to server
let partyData = {};
if (store.state.party && store.state.party.data) {
partyData = {
partyID: store.state.party.data._id,
partySize: store.state.party.data.memberCount,
};
} else {
partyData = {
partyID: store.state.user.data.party._id,
partySize: store.state.partyMembers.data.length,
};
}
Analytics.updateUser(partyData);
const response = await axios.post(`/api/v4/groups/${payload.groupId}/${payload.action}`);
// @TODO: Update user?
-10
View File
@@ -3,7 +3,6 @@ import Vue from 'vue';
import compact from 'lodash/compact';
import omit from 'lodash/omit';
import { loadAsyncResource } from '@/libs/asyncResource';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
export function fetchUserTasks (store, options = {}) {
@@ -112,15 +111,6 @@ export async function create (store, createdTask) {
}
const tasksCreatedCount = getLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT);
if (!tasksCreatedCount || tasksCreatedCount < 2) {
const uuid = store.state.user.data._id;
Analytics.track({
eventName: 'task created',
eventAction: 'task created',
eventCategory: 'behavior',
hitType: 'event',
uuid,
taskType: taskRes.type,
}, { trackOnClient: true });
if (!tasksCreatedCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 1);
} else {
-4
View File
@@ -159,10 +159,6 @@ export default defineConfig({
target: DEV_BASE_URL,
changeOrigin: true,
},
'^/analytics': {
target: DEV_BASE_URL,
changeOrigin: true,
},
}
}
})
@@ -1086,6 +1086,8 @@
"eventBackgrounds": "Event Backgrounds",
"backgroundBirthdayBashText": "Birthday Bash",
"backgroundBirthdayBashNotes": "Habitica's having a birthday party, and everyone's invited!",
"backgroundOnAStrangePlanetText": "On a Strange Planet",
"backgroundOnAStrangePlanetNotes": "Venture where no Habitican has gone before: On a Strange Planet.",
"monthlyBackgrounds": "Monthly Backgrounds"
}
+1
View File
@@ -356,6 +356,7 @@
"hatchingPotionBalloon": "Balloon",
"hatchingPotionCryptid": "Cryptid",
"hatchingPotionOpal": "Opal",
"hatchingPotionAlien": "Alien",
"hatchingPotionNotes": "Pour this on an egg, and it will hatch as a <%= potText %> Pet.",
"premiumPotionUnlimitedNotes": "Not usable on Quest Pet eggs.",
+22 -12
View File
@@ -221,7 +221,7 @@
"questTRexUndeadBoss": "Skeletal Tyrannosaur",
"questTRexUndeadRageTitle": "Skeleton Healing",
"questTRexUndeadRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Skeletal Tyrannosaur will heal 30% of its remaining health!",
"questTRexUndeadRageEffect": "`Skeletal Tyrannosaur uses SKELETON HEALING!`\n\nThe monster lets forth an unearthly roar, and some of its damaged bones knit back together!",
"questTRexUndeadRageEffect": "Skeletal Tyrannosaur uses SKELETON HEALING!\n\nThe monster lets forth an unearthly roar, and some of its damaged bones knit back together!",
"questTRexDropTRexEgg": "Tyrannosaur (Egg)",
"questTRexUnlockText": "Unlocks Tyrannosaur Eggs for purchase in the Market",
@@ -282,7 +282,7 @@
"questDilatoryDistress2Boss": "Water Skull Swarm",
"questDilatoryDistress2RageTitle": "Swarm Respawn",
"questDilatoryDistress2RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Water Skull Swarm will heal 30% of its remaining health!",
"questDilatoryDistress2RageEffect": "`Water Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls pour forth from the crevasse, bolstering the swarm!",
"questDilatoryDistress2RageEffect": "Water Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls pour forth from the crevasse, bolstering the swarm!",
"questDilatoryDistress2DropSkeletonPotion": "Skeleton Hatching Potion",
"questDilatoryDistress2DropCottonCandyBluePotion": "Cotton Candy Blue Hatching Potion",
"questDilatoryDistress2DropHeadgear": "Fire Coral Circlet (Headgear)",
@@ -398,7 +398,7 @@
"questAxolotlUnlockText": "Unlocks Axolotl Eggs for purchase in the Market",
"questAxolotlRageTitle": "Axolotl Regeneration",
"questAxolotlRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Magical Axolotl will heal 30% of its remaining health!",
"questAxolotlRageEffect": "`Magical Axolotl uses AXOLOTL REGENERATION!`\n\n`A curtain of colorful bubbles obscures the monster for a moment, and when it clears, some of its wounds have vanished!`",
"questAxolotlRageEffect": "Magical Axolotl uses AXOLOTL REGENERATION!\n\nA curtain of colorful bubbles obscures the monster for a moment, and when it clears, some of its wounds have vanished!",
"questTurtleText": "Guide the Turtle",
"questTurtleNotes": "Help! This giant sea turtle cannot find her way to her nesting beach. She returns there every year to lay her eggs, but this year Inkomplete Bay is filled with toxic Task Flotsam made of red Dailies and unchecked To Do's. \"She's thrashing in a panic!\" @JessicaChase says.<br><br>@UncommonCriminal nods. \"It's because her guiding senses are fogged and confused.\"<br><br>@Scarabsi grabs your arm. \"Can you help clear the Task Flotsam blocking her path? It may be hazardous, but we have to help her!\"",
@@ -435,7 +435,7 @@
"questTaskwoodsTerror1Boss": "Fire Skull Swarm",
"questTaskwoodsTerror1RageTitle": "Swarm Respawn",
"questTaskwoodsTerror1RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Fire Skull Swarm will heal 30% of its remaining health!",
"questTaskwoodsTerror1RageEffect": "`Fire Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls swirl around you in a gout of flame!",
"questTaskwoodsTerror1RageEffect": "Fire Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls swirl around you in a gout of flame!",
"questTaskwoodsTerror1DropSkeletonPotion": "Skeleton Hatching Potion",
"questTaskwoodsTerror1DropRedPotion": "Red Hatching Potion",
"questTaskwoodsTerror1DropHeadgear": "Pyromancer's Turban (Headgear)",
@@ -507,7 +507,7 @@
"questStoikalmCalamity1Boss": "Earth Skull Swarm",
"questStoikalmCalamity1RageTitle": "Swarm Respawn",
"questStoikalmCalamity1RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Earth Skull Swarm will heal 30% of its remaining health!",
"questStoikalmCalamity1RageEffect": "`Earth Skull Swarm uses SWARM RESPAWN!`\n\nMore skulls break free from the ground, their teeth chattering in the cold!",
"questStoikalmCalamity1RageEffect": "Earth Skull Swarm uses SWARM RESPAWN!\n\nMore skulls break free from the ground, their teeth chattering in the cold!",
"questStoikalmCalamity1DropSkeletonPotion": "Skeleton Hatching Potion",
"questStoikalmCalamity1DropDesertPotion": "Desert Hatching Potion",
"questStoikalmCalamity1DropArmor": "Mammoth Rider Armor",
@@ -554,7 +554,7 @@
"questMayhemMistiflying1Boss": "Air Skull Swarm",
"questMayhemMistiflying1RageTitle": "Swarm Respawn",
"questMayhemMistiflying1RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Air Skull Swarm will heal 30% of its remaining health!",
"questMayhemMistiflying1RageEffect": "`Air Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls come whirling out of the clouds!",
"questMayhemMistiflying1RageEffect": "Air Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls come whirling out of the clouds!",
"questMayhemMistiflying1DropSkeletonPotion": "Skeleton Hatching Potion",
"questMayhemMistiflying1DropWhitePotion": "White Hatching Potion",
"questMayhemMistiflying1DropArmor": "Roguish Rainbow Messenger Robes (Armor)",
@@ -623,7 +623,7 @@
"questLostMasterclasser3Boss": "Void Skull Swarm",
"questLostMasterclasser3RageTitle": "Swarm Respawn",
"questLostMasterclasser3RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Void Skull Swarm will heal 30% of its remaining health!",
"questLostMasterclasser3RageEffect": "`Void Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls scream down from the heavens, bolstering the swarm!",
"questLostMasterclasser3RageEffect": "Void Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls scream down from the heavens, bolstering the swarm!",
"questLostMasterclasser3DropBodyAccessory": "Aether Amulet (Body Accessory)",
"questLostMasterclasser3DropBasePotion": "Base Hatching Potion",
"questLostMasterclasser3DropGoldenPotion": "Golden Hatching Potion",
@@ -637,7 +637,7 @@
"questLostMasterclasser4Boss": "Anti'zinnya",
"questLostMasterclasser4RageTitle": "Siphoning Void",
"questLostMasterclasser4RageDescription": "Siphoning Void: This bar fills when you don't complete your Dailies. When it is full, Anti'zinnya will remove the party's Mana!",
"questLostMasterclasser4RageEffect": "`Anti'zinnya uses SIPHONING VOID!` In a twisted inversion of the Ethereal Surge spell, you feel your magic drain away into the darkness!",
"questLostMasterclasser4RageEffect": "Anti'zinnya uses SIPHONING VOID! In a twisted inversion of the Ethereal Surge spell, you feel your magic drain away into the darkness!",
"questLostMasterclasser4DropBackAccessory": "Aether Cloak (Back Accessory)",
"questLostMasterclasser4DropWeapon": "Aether Crystals (Two-Handed Weapon)",
"questLostMasterclasser4DropMount": "Invisible Aether Mount",
@@ -804,7 +804,7 @@
"questWaffleBoss": "Awful Waffle",
"questWaffleRageTitle": "Maple Mire",
"questWaffleRageDescription": "Maple Mire: This bar fills when you don't complete your Dailies. When it is full, the Awful Waffle will subtract from the pending damage that party members have built up!",
"questWaffleRageEffect": "`Awful Waffle uses MAPLE MIRE!` Sticky sappy syrup slows your swings and spells! Pending damage reduced.",
"questWaffleRageEffect": "Awful Waffle uses MAPLE MIRE! Sticky sappy syrup slows your swings and spells! Pending damage reduced.",
"questWaffleDropDessertPotion": "Confection Hatching Potion",
"questWaffleUnlockText": "Unlocks Confection Hatching Potions for purchase in the Market",
@@ -875,7 +875,7 @@
"questVirtualPetBoss": "Wotchimon",
"questVirtualPetRageTitle": "The Beepening",
"questVirtualPetRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Wotchimon will take away some of your party's pending damage!",
"questVirtualPetRageEffect": "`Wotchimon uses Bothersome Beep!` Wotchimon sounds a bothersome beep, and its happiness bar suddenly disappears! Pending damage reduced.",
"questVirtualPetRageEffect": "Wotchimon uses Bothersome Beep! Wotchimon sounds a bothersome beep, and its happiness bar suddenly disappears! Pending damage reduced.",
"questVirtualPetDropVirtualPetPotion": "Virtual Pet Hatching Potion",
"questVirtualPetUnlockText": "Unlocks Virtual Pet Hatching Potion for purchase in the Market",
@@ -885,7 +885,7 @@
"questPinkMarbleBoss": "Cupido",
"questPinkMarbleRageTitle": "Pink Punch",
"questPinkMarbleRageDescription": "This bar fills when you don't complete your Dailies. When it is full, Cupido will take away some of your party's pending damage!",
"questPinkMarbleRageEffect": "`Cupido uses Pink Punch!` That wasn't affectionate at all! Your partymates are taken aback. Pending damage reduced.",
"questPinkMarbleRageEffect": "Cupido uses Pink Punch! That wasn't affectionate at all! Your partymates are taken aback. Pending damage reduced.",
"questPinkMarbleDropPinkMarblePotion": "Pink Marble Hatching Potion",
"questPinkMarbleUnlockText": "Unlocks Pink Marble Hatching Potions for purchase in the Market.",
@@ -997,5 +997,15 @@
"questFungiRageDescription": "This bar fills when you don't complete your Dailies. When it's full, the Moody Mushroom will take away some of your party's pending damage",
"questFungiRageEffect": "A Mist emanates from the Moody Mushroom and surrounds your party, dampening the mood and subduing your magic. The party's MP is reduced!",
"questFungiDropFungiPotion": "Fungi Hatching Potion",
"questFungiUnlockText": "Unlocks Fungi Hatching Potions for purchase in the Market."
"questFungiUnlockText": "Unlocks Fungi Hatching Potions for purchase in the Market.",
"questAlienText": "Invasion of the Motivation Snatchers",
"questAlienNotes": "Its been a strange few days in Habitica. The great flying saucer still hovers near the Flourishing Fields. It hums oddly. Why is it lingering? April Fools Day has passed, and the Master of Rogues' time in the spotlight has ended.<br><br>You wander toward the light of the space ship. You may as well check it out and get a few steps in while youre at it.<br><br>As you get closer you see the April Fool, looking a bit grim. His face appears greenish in the light of the ships beam.<br><br>”'Twas my plan to get some potions for everyone, a little gift so all can enjoy their little extraterrestrial pals again! But I just cant work up the gumption… I do believe I know why,” the Fool says, nodding toward the beam.<br><br>Little symbols are being sucked up into the ship. Its all your checked off tasks! No wonder your motivations been lackluster.<br><br>”Our motivation is being abducted!” you exclaim. “We have to rescue it before it ends up in deep space somewhere!”<br><br>The Fool smiles. “Concentrate your thoughts on the tasks you know you need to finish! Ill do the rest with a bit of magic.”",
"questAlienCompletion": "Youve managed to wrestle back the stolen motivation with your determination and the Fools magic power. As you feel your drive returning, the UFO descends, and a ramp slowly comes out along with a large, green, one-eyed creature. While strange-looking, it doesnt seem threatening.<br><br>“Looks like we went a little far trying to harvest a little extra encouragement from your fine city,” it says. “Apologies for that, and fantastic work getting it back. The extra aura of your efforts actually charged up the ships engine enough to get us home! Please, take these with our thanks.”<br><br>“Ooh potions,” says the Fool, “how delightful, and how convenient for me that you have them all ready to go!”",
"questAlienBoss": "Encouragement Thief, the Extraterrestrial",
"questAlienRageTitle": "Intergalactic Impediment",
"questAlienRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Extraterrestrial will discourage you by recovering some of its Health!",
"questAlienRageEffect": "Encouragement Thief uses Intergalactic Impediment! You've backslid right through hyperspace. Your opponent recovers HP!",
"questAlienDropAlienPotion": "Alien Hatching Potion",
"questAlienUnlockText": "Unlocks Alien Hatching Potion for purchase in the Market"
}
+9 -2
View File
@@ -1,8 +1,15 @@
{
"rebirthNew": "Rebirth: New Adventure Available!",
"rebirthUnlock": "You've unlocked Rebirth! This special Market item allows you to begin a new game at level 1 while keeping your tasks, achievements, pets, and more. Use it to breathe new life into Habitica if you feel you've achieved it all, or to experience new features with the fresh eyes of a beginning character!",
"rebirthAchievement": "You've begun a new adventure! This is Rebirth <%= number %> for you, and the highest Level you've attained is <%= level %>. To stack this Achievement, begin your next new adventure when you've reached an even higher Level!",
"rebirthAchievement100": "You've begun a new adventure! This is Rebirth <%= number %> for you, and the highest Level you've attained is 100 or higher. To stack this Achievement, begin your next new adventure when you've reached at least 100!",
"rebirthUnlockedNewItem": "Orb of Rebirth Unlocked",
"rebirthUnlockedOrb": "A new adventure is available!",
"rebirthUnlockedDesc": "Use the Orb of Rebirth to breathe new life into your Habitica adventure once you feel you've achieved it all! Begin again at level 1 while keeping your tasks, Achievements, and Pets with this special item found in the Market.",
"rebirthNewAchievement": "New Achievement",
"rebirthNewAdventure": "A new adventure begins now!",
"rebirthAchievement": "You've used the Orb of Rebirth <strong><%= number %></strong> time, and your highest level reached is <strong><%= level %></strong>.",
"rebirthAchievementPlural": "You've used the Orb of Rebirth <strong><%= number %></strong> times, and your highest level reached is <strong><%= level %></strong>.",
"rebirthAchievement100": "You've used the Orb of Rebirth <strong><%= number %></strong> times, and your highest level reached is <strong>100</strong> or higher.",
"rebirthStackInfo": "This achievement will stack each time you use the Orb of Rebirth.",
"rebirthBegan": "Began a New Adventure",
"rebirthText": "Began <%= rebirths %> New Adventures",
"rebirthOrb": "Used an Orb of Rebirth to start over after attaining Level <%= level %>.",
+21 -20
View File
@@ -17,9 +17,9 @@
"clearBrowserData": "브라우저 데이터 삭제하기",
"footerMobile": "모바일",
"footerCommunity": "커뮤니티",
"marketing3Header": "앱 및 확장",
"marketing2Header": "친구와 경쟁하고, 관심 그룹에 참여하세요",
"marketing1Lead1Title": "당신의 삶, 롤플레잉 게임",
"marketing3Header": "Habitica의 더 많은 활용",
"marketing2Header": "친구와 한 팀이 되세요",
"marketing1Lead1Title": "삶을 게임처럼",
"logout": "로그아웃",
"login": "로그인",
"history": "역사학",
@@ -39,22 +39,22 @@
"learnMore": "더 배우기",
"wrongPassword": "잘못된 암호.",
"muchmuchMore": "그리고 훨씬 더!",
"marketing1Lead2Title": "좋은 장비를 얻으세요",
"marketing1Lead1": "Habitica는 실생활의 습관들을 향상시키도록 돕는 비디오 게임입니다. 당신의 모든 과제들(습관, 일과 및 해야할 일)을 당신이 정복해야할 작은 괴물들로 바꾸어서 당신의 삶을 게임화하는 것입니다. 당신이 잘 할수록 게임에서 더 큰 성과를 얻습니다. 만약 잘못한다면 당신의 캐릭터는 게임에서 퇴보하게 될 것입니다.",
"marketing1Header": "게임을 해서 당신의 습관들을 향상시키세요",
"marketing1Lead2Title": "멋있는 장비를 얻으세요",
"marketing1Lead1": "Habitica는 해야할 일들과 씨름하는 여러분을 위한 완벽한 앱입니다. Habitica는 여러분이 과업을 완수할 때마다 골드, 경험치, 아이템 같은 익숙한 보상을 제공합니다. 여러분이 생산성과 성취감을 더 잘 느끼도록요. 과업을 잘 완수할수록, 여러분은 게임 속에서도 발전합니다.",
"marketing1Header": "좋은 습관에 한 걸음씩 가까워지세요!",
"invalidEmail": "암호 재설정을 수행하려면 유효한 전자 메일 주소가 필요합니다.",
"guidanceForBlacksmiths": "대장장이를 위한 안내",
"marketing1Lead3Title": "무작위로 나오는 상품을 찾아보세요",
"marketing1Lead2": "당신의 아바타를 꾸미기 위해서 습관들을 개선시키세요. 당신이 획득한 좋은 장비들 자랑해보세요!",
"marketing1Lead3Title": "노력의 보상을 받으세요",
"marketing1Lead2": "과제를 완수하세요. 그리고 검과 갑옷, 수많은 골드를 모으세요. 수백 종류가 만들어내는 끝없는 조합을 즐기세요. 능력치나 스타일을 최적화해보세요. 어쩌면 둘 다를요! ",
"mobileAndroid": "안드로이드",
"marketing4Lead1": "조직적 사용",
"marketing2Lead2Title": "몬스터와 싸우기",
"marketing2Lead1": "Habitica를 혼자서 하는 동안에도 당신이 협력하거나 경쟁 그리고 서로 책임을 묻기 시작할 때 더 빛이 나게 됩니다. 자기계발 프로그램의 가장적인 부분은 사회적 책임이며, 비디오 게임보다 더 책임과 경쟁을 위한 환경이 어디 있겠습니까?",
"marketing2Lead2": "전투가 없으면 롤플레잉 게임이겠어요? 파티와 함께 몬스터와 싸우세요. 몬스터들은 \"초책임모드\"입니다. - 당신이 운동을 빼먹은 날은 몬스터가 *모두*를 다치게 만드는 날입니다.",
"marketing2Lead3Title": "서로에게 도전심이 들게 해 보세요",
"marketing2Lead3": "도전과제는 친구들과 낯선 사람들과 경쟁을 하게 해줍니다. 도전과제가 끝났을때 최고인 사람이 특별한 상을 받게 됩니다.",
"marketing4Lead1": "교육만큼 작게 게임화하기 좋은 게 없죠! 매일 있는 수업의 지루함을 게임과 함께 깨버리세요. Habitica는 숙제를 따라가고, 학급 도전을 만들고, 학생들이 자신의 성취를 자랑하는 즐거운 방법이 될 겁니다.",
"marketing2Lead2Title": "퀘스트에서 몬스터와 싸우기",
"marketing2Lead1": "다른 사람이 있으면 동기가 샘솟습니다. 협력과 경쟁, 상호작용을 통해서요! Habitica는 사회적 책임을 활용합니다. 자기계발에 언제나적인 길이죠.",
"marketing2Lead2": "친구와 함께 수백 개의 퀘스트에 도전하고 전투에 참여하세요. 퀘스트 몬스터를 위해서는 최대한의 책임이 필요합니다. 치실을 까먹으면 모두에게 대미지가 가해집니다!",
"marketing2Lead3Title": "서로 도전해 보세요",
"marketing2Lead3": "저희가 만든 도전에 참여해보세요. 여러분의 관심과 목표에 맞는 과제 리스트를 받아보세요. 최선을 다해 경쟁하고 승자로서 보석을 가져가세요!",
"marketing4Lead1Title": "교육의 게임화",
"marketing4Header": "조직적 용도",
"marketing4Header": "가사일 너머",
"marketing4Lead2": "건강 관리 비용이 증가하고 있으며 무엇인가를 제공해야 합니다. 수백개의 프로그램들이 비용절감과 건강개선을 위해 만들어지고 있습니다. 우리는 Habitica가 건강한 생활 방식을 향한 실질적인 길을 열 수 있다고 믿습니다.",
"username": "아이디",
"emailOrUsername": "이메일 혹은 아이디 (대소문자 구별)",
@@ -68,11 +68,11 @@
"invalidLoginCredentialsLong": "이런! 당신의 이메일 주소 / 아이디 혹은 비밀번호가 바르지 않습니다.\n- 올바르게 입력됐는지 확인해주세요. 아이디와 비밀번호는 대소문자를 구별합니다.\n- Facebook이나 Google 연동으로 계정을 생성하셨다면, 다시 한 번 시도해보세요.\n- 만약 비밀번호를 잊으셨다면, \"비밀번호 찾기\"를 누르세요.",
"usernamePlaceholder": "예: Honggildong",
"emailUsernamePlaceholder": "예: Honggildong 또는 gildong@example.com",
"marketing1Lead3": "\"확률적 보상\" 시스템을 통해 도박처럼 스릴 있는 동기 부여를 얻으세요. Habitica는 스스로를 다양한 방식으로 격려할 수 있습니다. 스스로에게 긍정적인 보상을 주거나, 부정적인 습관에 벌을 주거나, 정해진 보상도 얻고, 랜덤한 보상도 노릴 수 있죠.",
"marketing3Lead1": "**아이폰과 안드로이드** 앱은 이동 중에도 업무를 처리할 수 있게 돕습니다. 웹사이트에 로그인해서 작업 완료 버튼을 누리는 것이 귀찮을 때가 있지요.",
"marketing2Lead1Title": "친구와 함께 올라가는 생산성",
"marketing1Lead3": "몇 주간의 힘든 과제도 기대가 있으면 할 만하죠. 삶은 보상을 주지 않더라도, Habitica는 아닙니다! 과제를 완수할 때마다 보상이 있습니다. 놀라실 거예요. 그러니 계속 진행하세요! ",
"marketing3Lead1": "어디서든 과제를 확인해보세요. 안드로이드와 iOS 기기에서도 Habitica를 이용할 수 있습니다. 과제를 완료하는 새로운 방법을 확인해보세요. 수상 경력이 있는 저희 앱에서요.",
"marketing2Lead1Title": "함께하는 생산성",
"marketing3Lead2": "**서드 파티 툴**은 Habitica를 삶의 다양한 측면과 연결합니다. 우리의 API 서비스는 [Chrome 확장 프로그램](https://chrome.google.com/webstore/detail/habitica/pidkmpibnnnhneohdgjclfdjpijggmjj?hl=en-US) 등과 손쉬운 통합을 가능하게 합니다. 비생산적인 웹서핑할 때 포인트를 잃게 하거나, 생산적일 때는 포인트를 얻게할 수 있죠. [자세한 정보는 여기를 클릭하세요](https://habitica.fandom.com/wiki/Extensions,_Add-Ons,_and_Customizations).",
"marketing3Lead2Title": "서드 파티 지원",
"marketing3Lead2Title": "오픈 소스 커뮤니티",
"marketing4Lead3-1": "삶을 게임화하고 싶으세요?",
"joinMany": "목표를 달성하면서 <%= userCountInMillions %> million이 넘는 유저들과 함께 즐기세요!",
"marketing4Lead2Title": "건강과 웰빙의 게임화",
@@ -89,5 +89,6 @@
"playButton": "플레이",
"enterHabitica": "Habitica 들어가기",
"pkQuestion2": "해비티카는 어떻게 작동하나요?",
"passwordResetPage": "비밀번호 초기화"
"passwordResetPage": "비밀번호 초기화",
"marketing3Lead1Title": "안드로이드 & iOS 앱"
}
+10 -1
View File
@@ -930,5 +930,14 @@
"backgrounds022026": "SET 141: Uitgebracht Februari 2026",
"backgroundElegantPalaceText": "Elegant Paleis",
"backgroundElegantPalaceNotes": "Bewonder de kleurrijke hallen van een Elegant Paleis.",
"backgroundBirthdayBashNotes": "Habitica viert zijn verjaardagsfeest en iedereen is uitgenodigd!"
"backgroundBirthdayBashNotes": "Habitica viert zijn verjaardagsfeest en iedereen is uitgenodigd!",
"backgrounds032026": "SET 142: Uitgebracht Maart 2026",
"backgroundWaterfallWithRainbowText": "Waterval met Regenboog",
"backgroundWaterfallWithRainbowNotes": "Bewonder de adembenemende schoonheid van een Waterval met een Regenboog.",
"backgrounds042026": "SET 143: Uitgebracht April 2026",
"backgroundRidingACometText": "Rijden op een Komeet",
"backgroundRidingACometNotes": "Reis door de ruimte terwijl je rijdt op een Komeet!",
"backgrounds052026": "SET 144: Uitgebracht Mei 2026",
"backgroundElvenCitadelText": "Elfenburcht",
"backgroundElvenCitadelNotes": "Onderneem de schilderachtige reis naar een Elfenburcht."
}
+13 -1
View File
@@ -147,5 +147,17 @@
"subscriptionBenefitsAdjustments": "Aanpassingen aan de voordelen voor abonnees",
"contentAnswer501": "Huidskleuren",
"contentAnswer400": "Huisdier Queesten",
"contentAnswer402": "Magische Uitbroeddranken"
"contentAnswer402": "Magische Uitbroeddranken",
"webFaqAnswer60": "Hier zijn enkele snelle tips om te starten met je nieuwe Habitica Groepsplan:\n\n* Promoveer een lid tot manager zodat die taken kan aanmaken en bewerken\n* Laat taken niet toegewezen als iedereen ze kan voltooien en ze maar één keer gedaan moeten worden\n* Wijs een taak toe aan één persoon om te zorgen dat niemand anders die taak kan voltooien\n* Wijs een taak toe aan meerdere personen als iedereen die taak moet voltooien\n* Schakel de optie in om gedeelde taken op je persoonlijke bord te tonen, zodat je niets mist\n* Je krijgt beloningen voor de taken die je voltooit, zelfs als ze aan meerdere personen zijn toegewezen\n* Taakbeloningen worden niet verdeeld tussen leden\n* Gebruik de taakkleur op het teambord om het gemiddelde voltooiingspercentage van taken te beoordelen\n* Controleer regelmatig de taken op het gedeelde takenbord om te zien of ze nog relevant zijn\n* Als je een Daily mist, krijg jij of je team geen schade, maar de taak zal wel in kleur verslechteren",
"webFaqAnswer67": "Klassen zijn verschillende rollen die je personage kan aannemen. Elke klasse heeft zijn eigen unieke voordelen en vaardigheden naarmate je in niveau stijgt. Deze vaardigheden kunnen de manier waarop je met je taken omgaat ondersteunen of je helpen bij het voltooien van Quests met je Party.\n\nJe klasse bepaalt ook welke uitrusting beschikbaar is om te kopen bij je Beloningen, op de Markt en in de Seizoenswinkel.\n\nHieronder vind je een overzicht van elke klasse om je te helpen kiezen welke het beste bij jouw speelstijl past:\n####**Warrior**\n* Warriors richten veel schade aan bij bazen en hebben een grote kans op kritieke treffers wanneer ze taken voltooien, waardoor je extra Ervaring en Goud krijgt.\n* Strength is hun primaire stat en verhoogt de schade die ze doen.\n* Constitution is hun secundaire stat en vermindert de schade die ze ontvangen.\n* De vaardigheden van Warriors versterken de Constitution en Strength van hun Partyleden.\n* Overweeg Warrior te spelen als je graag bazen bevecht, maar ook wat bescherming wilt wanneer je af en toe een taak mist.\n####**Healer**\n* Healers hebben hoge verdediging en kunnen zowel zichzelf als hun Partyleden genezen.\n* Constitution is hun primaire stat en verhoogt hun genezingen terwijl het schade vermindert.\n* Intelligence is hun secundaire stat en verhoogt hun Mana en Ervaring.\n* De vaardigheden van Healers maken hun taken minder rood en versterken de Constitution van hun Partyleden.\n* Overweeg Healer te spelen als je vaak taken mist en jezelf of je Partyleden wilt kunnen genezen. Healers stijgen ook snel in niveau.\n####**Mage**\n* Mages stijgen snel in niveau, krijgen veel Mana en richten schade aan bij bazen in Quests.\n* Intelligence is hun primaire stat en verhoogt hun Mana en Ervaring.\n* Perception is hun secundaire stat en verhoogt hun Goud en itemdrops.\n* De vaardigheden van Mages bevriezen taakreeksen, herstellen Mana van Partyleden en versterken hun Intelligence.\n* Overweeg Mage te spelen als je gemotiveerd wordt door snel levels te behalen en schade bij te dragen in boss Quests.\n#### **Rogue**\n* Rogues krijgen de meeste itemdrops en Goud door taken te voltooien en hebben een grote kans op kritieke treffers, waardoor ze nog meer Ervaring en Goud krijgen.\n* Perception is hun primaire stat en verhoogt Goud en itemdrops.\n* Strength is hun secundaire stat en verhoogt de schade die ze doen.\n* De vaardigheden van Rogues helpen hen gemiste Dailies te ontwijken, Goud te stelen en de Perception van hun Partyleden te versterken.\n* Overweeg Rogue te spelen als je sterk gemotiveerd wordt door beloningen.",
"webFaqAnswer68": "Als je merkt dat je vaak HP verliest, probeer dan een van deze tips:\n\n- Pauzeer je Dailies. De knop “Schade pauzeren” in Instellingen voorkomt dat je HP verliest wanneer je Dailies mist.\n- Pas het schema van je Dailies aan. Door ze in te stellen zodat ze nooit vervallen, kun je ze nog steeds voltooien voor beloningen zonder het risico op HP-verlies.\n- Probeer klasseskills te gebruiken:\n\t- Rogues kunnen Stealth gebruiken om schade van gemiste Dailies te voorkomen\n\t- Warriors kunnen Brutal Smash gebruiken om de roodheid van een Daily te verminderen, waardoor je minder schade krijgt als je die mist\n\t- Healers kunnen Searing Brightness gebruiken om de roodheid van Dailies te verminderen, waardoor je minder schade krijgt als je die mist",
"webFaqAnswer70": "Statpunten laten je de kernstatistieken van je personage verhogen. Je verdient één statpunt telkens wanneer je in niveau stijgt (tot en met niveau 100). Deze kun je handmatig toewijzen of automatisch laten verdelen met de functie Automatische toewijzing. Het toewijzen van statpunten wordt ontgrendeld samen met het Klassesysteem op niveau 10.",
"webFaqAnswer57": "Zodra je lid wordt van een Party, ontvang je geen verdere uitnodigingen meer.\nAls je uitnodigingen en toekomstige communicatie van een specifieke speler wilt voorkomen, ga dan naar hun profiel en klik op de knop Blokkeren. Op mobiele profielen tik je op de drie puntjes in de bovenhoek en kies je “Blokkeren”.\n\nAls je een situatie tegenkomt waarin je denkt dat een andere speler onze Communityrichtlijnen heeft overtreden in hun naam, profiel of in een bericht dat ze hebben gestuurd, rapporteer het bericht dan of neem contact met ons op via admin@habitica.com.",
"webFaqAnswer55": "Ja! Als je de gebruikersnaam of het e-mailadres van een Habitica-speler hebt, kun je die uitnodigen om lid te worden van je Party. Zo stuur je een uitnodiging op de verschillende platforms:\n\nIemand uitnodigen via de mobiele apps:\n1. Open het menu, selecteer “Party” en scroll naar de sectie Leden\n2. Tik op “Leden zoeken” en ga vervolgens naar het tabblad “Via uitnodiging”\n3. Voer de gebruikersnamen of e-mailadressen in van de spelers die je wilt uitnodigen en tik op “Uitnodiging verzenden”\n\nIemand uitnodigen via de website:\n1. Ga naar je Party en klik op “Uitnodigen voor Party”\n2. Voer de gebruikersnamen of e-mailadressen in van de spelers die je wilt uitnodigen en klik op “Uitnodigingen verzenden”",
"webFaqAnswer59": "Habitica Groepsplannen bieden een gedeelde ervaring doordat leden eenvoudig taken kunnen toevoegen, toewijzen en voltooien via een gedeeld takenbord. Met functies zoals ledenrollen, statusoverzicht en het toewijzen van taken zijn Groepsplannen ideaal voor gezinnen of teams van collegas met gezamenlijke doelen. Ze zijn ook een geweldige manier om elkaar gemotiveerd te houden tijdens jullie reis om monsters te verslaan en je leven te verbeteren.",
"webFaqAnswer62": "Groepsplannen geven je de unieke mogelijkheid om gedeelde taken toe te wijzen aan andere leden van je Groepsplan. Wanneer een gedeelde taak aan een lid wordt toegewezen, kunnen andere leden deze taak niet meer voltooien.\n\nJe kunt een taak ook aan meerdere leden tegelijk toewijzen. Als bijvoorbeeld iedereen zijn tanden moet poetsen, maak je één taak aan en wijs je die toe aan elk lid. Elk lid kan de taak voltooien en zijn of haar individuele beloningen verdienen. De hoofdtaak wordt als voltooid weergegeven zodra iedereen de taak heeft afgerond.",
"webFaqAnswer61": "Alleen de leider van het Groepsplan en managers kunnen gedeelde taken aanmaken. Als je wilt dat een lid taken kan aanmaken, moet je die persoon promoveren tot manager.\n\nZo promoveer je een lid van het Groepsplan tot manager op de website:\n1. Ga naar je Groepsplan en schakel naar het tabblad “Groepsinformatie”\n2. Bekijk de ledenlijst en klik op het puntjes-icoon naast het lid dat je wilt promoveren\n3. Selecteer “Manager toewijzen”",
"webFaqAnswer64": "Gedeelde taken worden voor iedereen op hetzelfde moment gereset zodat het gedeelde takenbord synchroon blijft. Dit tijdstip is zichtbaar op het gedeelde takenbord en wordt bepaald door de dagstarttijd van de leider van het Groepsplan. Omdat gedeelde taken automatisch resetten, krijg je de volgende ochtend geen kans meer om gisteren niet voltooide gedeelde Dailies alsnog af te werken.\n\nGedeelde Dailies veroorzaken geen schade als ze worden gemist, maar ze zullen in kleur achteruitgaan om de voortgang visueel weer te geven.",
"webFaqAnswer65": "Hoewel de mobiele apps nog niet alle functies van Groepsplannen volledig ondersteunen, kun je gedeelde taken wel voltooien via de iOS- en Android-apps!\n\nOp Android kun je bovenaan het scherm op je weergavenaam tikken wanneer je taken bekijkt om over te schakelen naar het gedeelde takenbord. Van daaruit kun je leden bekijken, de chat openen en taken aanmaken, voltooien of toewijzen.\n\nJe kunt ook een instelling inschakelen om gedeelde taken naar het persoonlijke takenbord te kopiëren, zodat alle taken op één plek voltooid kunnen worden.\n\nZo doe je dit in de mobiele apps:\n*Open Instellingen en schakel “Gedeelde taken kopiëren” in\n\nZo doe je dit op de Habitica-website:\n* Ga naar het Groepsplan en schakel de “Taken kopiëren”-schakelaar in op het gedeelde takenbord",
"webFaqAnswer66": "De gedeelde takenborden van Groepsplannen zijn dynamischer dan Challenges, omdat ze voortdurend kunnen worden bijgewerkt en gebruikt door leden. Challenges zijn vooral handig wanneer je één vaste set taken naar veel mensen wilt sturen.\n\nGroepsplannen zijn bovendien een betaalde functie, terwijl Challenges gratis beschikbaar zijn voor iedereen.\n\nJe kunt in Challenges geen specifieke taken toewijzen, en Challenges hebben geen gedeelde dagreset. Over het algemeen bieden Challenges minder controle en directe interactie."
}
+2 -1
View File
@@ -2672,5 +2672,6 @@
"weaponSpecialFall2024MageNotes": "Met een aanraking van dit schitterende wapen worden je de stappen van je taak direct versimpeld. Verhoogt Intelligentie met <%= int %> en Perceptie met <%= per %>. Beperkte Oplage Herfst 2024 Uitrusting.",
"weaponSpecialSummer2024MageNotes": "Deze verschrikkelijke tentakels kunnen tegelijkertijd magie afleiden, afbuigen en sturen. Verhoogt Intelligentie met <%= int %> en Perceptie met <%= per %>. Beperkte Oplage Zomer 2024 Uitrusting.",
"weaponSpecialSummer2024HealerNotes": "Het zal je verbazen wanneer je ontdekt hoe hard de schelp aan het eind van deze staf is. Verhoogt Intelligentie met <%= int %>. Beperkte Oplage Zomer 2024 Uitrusting.",
"weaponSpecialWinter2025RogueNotes": "Verblind die moeilijke taken tot onderwerping! Je zult niet te stoppen zijn! Verhoogt Kracht met <%= str %>. Beperkte Oplage Winter 2024-2025 Uitrusting."
"weaponSpecialWinter2025RogueNotes": "Verblind die moeilijke taken tot onderwerping! Je zult niet te stoppen zijn! Verhoogt Kracht met <%= str %>. Beperkte Oplage Winter 2024-2025 Uitrusting.",
"weaponSpecialWinter2025HealerNotes": "Wat je nu nodig hebt zijn meer lichtjes met een lichtgevende ster er bovenop! Je zal niet te stoppen zijn! Verhoogt Intelligentie met <%= int %>. Beperkte Oplage Winter 2024-2025 Uitrusting."
}
+4 -1
View File
@@ -11,5 +11,8 @@
"rebirthPop": "Herstart je personage direct als een Niveau 1 Krijger zonder je prestaties, verzamelobjecten en uitrusting te verliezen. Je Taken en hun geschiedenis zal hetzelfde blijven maar ze worden gereset naar een gele kleur. Je Reeks word verwijderd behalve van taken die horen bij een actieve Uitdaging of een Groepsplan. Je Goud, Ervaring, Mana en de effecten van al je Vaardigheden gaan verloren. Dit alles gaat direct in werking.",
"rebirthName": "Bol der Hergeboorte",
"rebirthComplete": "Je bent herboren!",
"nextFreeRebirth": "<strong><%= days %> dagen</strong> tot <strong>GRATIS</strong> Bol der Hergeboorte"
"nextFreeRebirth": "<strong><%= days %> dagen</strong> tot <strong>GRATIS</strong> Bol der Hergeboorte",
"rebirthNewAchievement": "Nieuwe prestatie",
"rebirthNewAdventure": "Een nieuw avontuur begint nu!",
"rebirthUnlockedOrb": "Een nieuw avontuur is beschikbaar!"
}
+9 -1
View File
@@ -921,5 +921,13 @@
"backgroundInsideForestWitchsCottageText": "Домик лесной ведьмы",
"backgroundAutumnSwampNotes": "Окунитесь в атмосферу осеннего болота.",
"backgrounds102025": "Набор 137: Выпущен в октябре 2025 года",
"backgroundInsideForestWitchsCottageNotes": "Сплетите заклинания в домике лесной ведьмы."
"backgroundInsideForestWitchsCottageNotes": "Сплетите заклинания в домике лесной ведьмы.",
"backgroundNighttimeStreetWithShopsText": "Ночная улица с магазинами",
"backgrounds122025": "Набор 139: Выпущен в декабре 2025",
"backgroundNighttimeStreetWithShopsNotes": "Наслаждайтесь теплым сиянием ночных торговых улиц.",
"backgroundWinterDesertWithSaguarosNotes": "Вдохните свежий воздух зимней пустыни полной кактусов сагуаро.",
"backgroundElegantPalaceText": "Прекрасный Дворец",
"backgrounds012026": "Набор 140: Выпущен в январе 2026",
"backgrounds022026": "Набор 141: Выпущен в феврале 2026",
"backgroundWinterDesertWithSaguarosText": "Зимняя пустыня с кактусами сагуаро"
}
+10 -3
View File
@@ -1,8 +1,8 @@
{
"rebirthNew": "Переродження: Нова пригода чекає на вас!",
"rebirthUnlock": "Ви розблокували Переродження! Цей особливий предмет дозволить вам розпочати нову гру з 1-го рівня, зберігши ваші завдання, досягнення, улюбленців тощо. Використайте його, щоб вдихнути нове життя у Habitica, якщо ви відчуваєте, що досягли всього, або хочете пройти через нові пригоди з новими враженнями, наче початківець!",
"rebirthAchievement": "Ви розпочали нову пригоду! Це ваше <%= number %> переродження, і найвищий рівень, якого ви досягали - <%= level %>. Аби отримати це досягнення ще раз, почніть вашу нову пригоду після того, як досягнете ще вищого рівня!",
"rebirthAchievement100": "Ви розпочали нову пригоду! Це ваше <%= number %> переродження, і найвищий рівень, який ви отримали - 100 чи більше. Аби отримати це досягнення ще раз, почніть вашу нову пригоду після того, як отримаєте щонайменше рівень 100!",
"rebirthAchievement": "Ви використовували Кулю Переродження <strong><%= number %></strong> разів, а ваш найвищий досягнутий рівень — <strong><%= level %></strong>.",
"rebirthAchievement100": "Ви використовували Кулю Переродження <strong><%= number %></strong> разів, а ваш найвищий досягнутий рівень — <strong>100</strong> або вище.",
"rebirthBegan": "Нова пригода починається",
"rebirthText": "Розпочато <%= rebirths %> нові(их) пригоди",
"rebirthOrb": "Після досягнення <%= level %> рівня використано Кулю Переродження, щоб розпочати заново.",
@@ -11,5 +11,12 @@
"rebirthPop": "Миттєво переродить вашого персонажа як воїна 1-го рівня, зберігши ваші досягнення, предмети колекціонування та спорядження. Ваші завдання та їхня історія залишаться, проте вони будуть скинуті до жовтого кольору. Ваші серії будуть видалені, окрім завдань з активних Випробувань та Спільнот. Ваше золото, досвід, мана та ефекти всіх навичок буде видалено. Все вищеперераховане буде застосовано негайно.",
"rebirthName": "Куля Переродження",
"rebirthComplete": "Ви переродилися!",
"nextFreeRebirth": "<strong><%= days %> дн.</strong> до <strong>БЕЗКОШТОВНОЇ</strong> Кулі Переродження"
"nextFreeRebirth": "<strong><%= days %> дн.</strong> до <strong>БЕЗКОШТОВНОЇ</strong> Кулі Переродження",
"rebirthUnlockedOrb": "Нова пригода доступна!",
"rebirthNewAchievement": "Нове Досягнення",
"rebirthNewAdventure": "Нова пригода починається зараз!",
"rebirthAchievementPlural": "Ви використовували Кулю Переродження <strong><%= number %></strong> разів, а ваш найвищий досягнутий рівень — <strong><%= level %></strong>.",
"rebirthStackInfo": "Це досягнення буде накопичуватися щоразу, коли ви використовуєте Кулю Переродження.",
"rebirthUnlockedNewItem": "Куля Переродження Розблокована",
"rebirthUnlockedDesc": "Використовуйте Кулю Переродження, щоб вдихнути нове життя у свої пригоди в Habitica, коли відчуєте, що досягли всього! Почніть заново з 1-го рівня, зберігши свої завдання, досягнення та улюбленців за допомогою цього особливого предмета, який можна знайти на Ринку."
}
+17 -1
View File
@@ -257,5 +257,21 @@
"contentRelease": "Релізи контенту + Події",
"resetTextLocal": "Якщо ви абсолютно впевнені, введіть свій пароль у полі нижче.",
"resetTextSocial": "Якщо ви абсолютно впевнені, введіть <b>\"<%= magicWord %>\"</b> у полі нижче.",
"transaction_subscription_bonus": "Бонус <b>Підписки</b>"
"transaction_subscription_bonus": "Бонус <b>Підписки</b>",
"acceptAllCookies": "Прийняти всі файли cookie",
"denyNonEssentialCookies": "Заборонити несуттєві файли cookie",
"managePrivacyPreferences": "Керуйте налаштуваннями конфіденційності",
"yourPrivacyPreferences": "Ваші налаштування конфіденційності",
"learnMorePrivacy": "Щоб дізнатися більше, перегляньте нашу <a href='/static/privacy' target='_blank'>Політику конфіденційності</a>.",
"strictlyNecessary": "Строго необхідні",
"alwaysActive": "Завжди активні",
"requiredToRun": "Вони потрібні нашому веб-сайту та додаткам для найкращої роботи.",
"performanceAnalytics": "Ефективність і аналітика",
"savePreferences": "Зберегти налаштування",
"habiticaPrivacyPolicy": "Політика конфіденційності Habitica",
"privacyOverview": "У сучасному світі здається, ніби кожна компанія прагне отримати прибуток з ваших даних. Через це буває важко знайти правильний додаток для вдосконалення своїх звичок. Habitica використовує файли cookie, які зберігають дані виключно для аналізу продуктивності, обробки запитів у службу підтримки та забезпечення найкращого ігрового досвіду. Ви можете змінити ці налаштування в будь-який час у налаштуваннях вашого акаунта.",
"privacySettingsOverview": "Habitica використовує файли cookie для аналізу продуктивності, обробки запитів у службу підтримки та забезпечення найкращого ігрового досвіду. Для цього нам потрібно отримати наступні дозволи. Ви можете змінити їх у будь-який час у налаштуваннях вашого акаунта.",
"usedForSupport": "Вони використовуються для покращення взаємодії з користувачем, продуктивності та послуг нашого веб-сайту та програм. Ці дані використовуються нашою командою підтримки під час обробки запитів і звітів про помилки.",
"gpcWarning": "<a href='<%= url %>' target='_blank'>GPC</a> увімкнено. Увімкнення відстеження нижче скасує цей параметр і надішле дані нашим аналітичним партнерам.",
"gpcPlusAnalytics": "<a href='<%= url %>' target='_blank'>GPC</a> увімкнено. Ви надали згоду на відстеження та надсилання даних нашим аналітичним партнерам."
}
+10 -3
View File
@@ -1,8 +1,8 @@
{
"rebirthNew": "重生:开始新的冒险!",
"rebirthUnlock": "你已经解锁了重生之球! 这个特殊的市场商品可使你从 1 级开始新的游戏,同时保留你的任务、成就、宠物等。 如果你觉得自己已经达成了所有目标,或者想要以新手的视角体验新功能,请使用它为Habitica注入新的活力!",
"rebirthAchievement": "你开始了新的冒险! 这是你的第<%= number %>次重生,你达到的最高等级是<%= level %>。 要叠加此成就,请在达到更高等级时开始下一次新的冒险!",
"rebirthAchievement100": "你开始了新的冒险! 这是你最高等级达到100级以上的第<%= number %>次重生。要叠加此成就,请在达到至少 100 级时开始下次新的冒险!",
"rebirthAchievement": "这是你的第<strong><%= number %></strong>次使用重生之球,你达到的最高等级是<strong><%= level %></strong>。",
"rebirthAchievement100": "这是你的第<strong><%= number %></strong>次使用重生之球,你达到的最高等级是<strong>100</strong> 或更高。",
"rebirthBegan": "已开始新的冒险",
"rebirthText": "已开始<%= rebirths %>次新的冒险",
"rebirthOrb": "在达到<%= level %>级后,使用了重生之球重新开始。",
@@ -11,5 +11,12 @@
"rebirthPop": "立即将你的角色重置成为1级战士,同时保留成就、物品和装备。你的任务及其历史记录会保留,但会被重置为黄色;你的连击次数将被移除,挑战任务和团队计划任务除外;你的金币、经验值以及法力值和及所有技能的效果都将被移除。上述内容将立即生效。",
"rebirthName": "重生之球",
"rebirthComplete": "你已经重生!",
"nextFreeRebirth": "下个<strong>免费</strong>的重生之球可在<strong><%= days %>天</strong>后获得"
"nextFreeRebirth": "下个<strong>免费</strong>的重生之球可在<strong><%= days %>天</strong>后获得",
"rebirthUnlockedNewItem": "已解锁重生之球",
"rebirthUnlockedOrb": "全新冒险现已开启!",
"rebirthUnlockedDesc": "当你感觉已征服Habitica的全部内容时,不妨使用重生之球为你的冒险注入新活力!通过在市场获取的这个特殊道具,你将重启旅程回到1级,同时保留所有任务、成就和宠物。",
"rebirthNewAchievement": "新成就",
"rebirthNewAdventure": "新的冒险现在开始!",
"rebirthAchievementPlural": "这是你的第<strong><%= number %></strong>次使用重生之球,你达到的最高等级是<strong><%= level %></strong>。",
"rebirthStackInfo": "每使用一次重生之球,本成就就会叠加一次。"
}
@@ -696,6 +696,9 @@ const backgrounds = {
birthday_bash: {
price: 0,
},
on_a_strange_planet: {
price: 0,
},
},
timeTravelBackgrounds: {
airship: {
@@ -0,0 +1,39 @@
import eggs from '../eggs';
import stable from '../stable';
const SWAPS = [
'Veggie',
'Dessert',
'VirtualPet',
'TeaShop',
'Fungi',
'Cryptid',
'Alien',
];
export function getMatchingSwap (date = new Date()) {
const year = date.getFullYear();
const diff = year - 2020;
return SWAPS[diff % SWAPS.length];
}
export function makeSubstitutionMap (swappedPotion) {
const substitutions = {
pets: {
default: `Pet-Dragon-${swappedPotion}`,
noPet: `Pet-Wolf-${swappedPotion}`,
noPetIOS: `Pet-TigerCub-${swappedPotion}`,
noPetAndroid: `Pet-Cactus-${swappedPotion}`,
},
};
for (const pet of Object.keys(stable.specialPets)) {
substitutions.pets[`Pet-${pet}`] = `Pet-Dragon-${swappedPotion}`;
}
for (const egg of Object.keys(eggs.drops)) {
substitutions.pets[`Pet-${egg}-`] = `Pet-${egg}-${swappedPotion}`;
}
for (const egg of Object.keys(eggs.quests)) {
substitutions.pets[`Pet-${egg}-`] = `Pet-BearCub-${swappedPotion}`;
}
return substitutions;
}
@@ -1,5 +1,6 @@
/* eslint-disable key-spacing */
import moment from 'moment';
import { getMatchingSwap, makeSubstitutionMap } from './aprilFools';
// gem block: number of gems
const gemsPromo = {
@@ -53,7 +54,7 @@ export const REPEATING_EVENTS = {
aprilFools: {
start: new Date('1970-04-01T04:00-04:00'),
end: new Date('1970-04-02T03:59-04:00'),
aprilFools: 'Cryptid',
spriteSubstitutions: makeSubstitutionMap(getMatchingSwap()),
},
aprilFoolsResale: {
start: new Date('1970-04-03T04:00-04:00'),
@@ -65,6 +66,7 @@ export const REPEATING_EVENTS = {
'virtualpet',
'waffle',
'fungi',
'alien',
],
},
{
@@ -15,6 +15,7 @@ export default [
'Mount_Body_Gryphon-Gryphatrice',
'Mount_Head_Dragon-Hydra',
'Mount_Head_Gryphon-Gryphatrice',
'Pet_HatchingPotion_Alien',
'Pet_HatchingPotion_Cryptid',
'Pet_HatchingPotion_Dessert',
'Pet_HatchingPotion_Fungi',
@@ -24,6 +25,8 @@ export default [
'Pet-Gryphatrice-Jubilant',
'Pet-Gryphon-Gryphatrice',
'Pet-Wolf-Cerberus',
'quest_alien',
'quest_lostMasterclasser4',
'quest_solarSystem',
'quest_virtualpet',
'quest_windup',
@@ -53,4 +53,5 @@ export const HATCHING_POTIONS_RELEASE_DATES = {
Cryptid: { year: 2025, month: 4, day: 3 },
Balloon: { year: 2025, month: 4, day: 21 },
Opal: { year: 2025, month: 5, day: 14 },
Alien: { year: 2026, month: 4, day: 3 },
};
@@ -155,6 +155,10 @@ const wacky = {
canBuy: hasQuestAchievementFunction('fungi'),
},
Cryptid: {},
Alien: {
questPotion: true,
canBuy: hasQuestAchievementFunction('alien'),
},
};
each(drops, (pot, key) => {
@@ -233,6 +233,45 @@ const QUEST_SEASONAL = {
unlock: t('questFungiUnlockText'),
},
},
alien: {
text: t('questAlienText'),
notes: t('questAlienNotes'),
completion: t('questAlienCompletion'),
value: 4,
category: 'hatchingPotion',
boss: {
name: t('questAlienBoss'),
hp: 500,
str: 2,
rage: {
title: t('questAlienRageTitle'),
description: t('questAlienRageDescription'),
value: 50,
healing: 0.3,
effect: t('questAlienRageEffect'),
},
},
drop: {
items: [
{
type: 'hatchingPotions',
key: 'Alien',
text: t('questAlienDropAlienPotion'),
}, {
type: 'hatchingPotions',
key: 'Alien',
text: t('questAlienDropAlienPotion'),
}, {
type: 'hatchingPotions',
key: 'Alien',
text: t('questAlienDropAlienPotion'),
},
],
gp: 40,
exp: 500,
unlock: t('questAlienUnlockText'),
},
},
};
export default QUEST_SEASONAL;
+1 -3
View File
@@ -1,6 +1,4 @@
import each from 'lodash/each';
import moment from 'moment';
import { EVENTS } from './constants/events';
import allEggs from './eggs';
import allPotions from './hatching-potions';
import t from './translation';
@@ -193,7 +191,7 @@ function buildInfo () {
Object.assign(petInfo['Gryphatrice-Jubilant'], {
canBuy () {
return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end);
return false;
},
currency: 'gems',
event: 'birthday10',
+1 -13
View File
@@ -3,10 +3,8 @@ import isFunction from 'lodash/isFunction';
import min from 'lodash/min';
import reduce from 'lodash/reduce';
import filter from 'lodash/filter';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
import size from 'lodash/size';
import moment from 'moment';
import content from '../content/index';
import i18n from '../i18n';
import { daysSince } from '../cron';
@@ -28,7 +26,7 @@ function trueRandom () {
return Math.random();
}
export default function randomDrop (user, options, req = {}, analytics) {
export default function randomDrop (user, options, req = {}) {
let acceptableDrops;
let drop;
let dropMultiplier;
@@ -157,15 +155,5 @@ export default function randomDrop (user, options, req = {}, analytics) {
user._tmp.drop = drop;
user.items.lastDrop.date = Number(new Date());
user.items.lastDrop.count += 1;
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('dropped item', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: drop.key,
category: 'behavior',
headers: req.headers,
});
}
}
}
+1 -12
View File
@@ -1,5 +1,3 @@
import pick from 'lodash/pick';
export function hasCompletedOnboarding (user) {
return (
user.achievements.createdTask === true
@@ -16,18 +14,9 @@ export function onOnboardingComplete (user) {
}
// Add notification and awards (server)
export function checkOnboardingStatus (user, req, analytics) {
export function checkOnboardingStatus (user) {
if (hasCompletedOnboarding(user) && user.addNotification) {
user.addNotification('ONBOARDING_COMPLETE');
if (analytics) {
analytics.track('onboarding complete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
}
onOnboardingComplete(user);
}
}
@@ -1,7 +1,5 @@
/* eslint-disable max-classes-per-file */
import get from 'lodash/get';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import {
NotAuthorized,
@@ -15,12 +13,10 @@ export class AbstractBuyOperation {
/**
* @param {User} user - the User-Object
* @param {Request} req - the Request-Object
* @param {analytics} analytics
*/
constructor (user, req, analytics) {
constructor (user, req) {
this.user = user;
this.req = req || {};
this.analytics = analytics;
const quantity = get(req, 'quantity');
@@ -87,10 +83,6 @@ export class AbstractBuyOperation {
throw new NotImplementedError('executeChanges');
}
analyticsData () { // eslint-disable-line class-methods-use-this
throw new NotImplementedError('sendToAnalytics');
}
async purchase () {
if (!this.multiplePurchaseAllowed() && this.quantity > 1) {
throw new NotAuthorized(this.i18n('messageNotAbleToBuyInBulk'));
@@ -98,34 +90,10 @@ export class AbstractBuyOperation {
this.extractAndValidateParams(this.user, this.req);
const resultObj = await this.executeChanges(this.user, this.item, this.req, this.analytics);
if (this.analytics) {
this.sendToAnalytics(this.analyticsData());
}
const resultObj = await this.executeChanges(this.user, this.item, this.req);
return resultObj;
}
analyticsLabel () { // eslint-disable-line class-methods-use-this
return 'buy';
}
sendToAnalytics (additionalData = {}) {
// spread-operator produces an "unexpected token" error
const analyticsData = merge(additionalData, {
user: pick(this.user, ['preferences', 'registeredThrough']),
uuid: this.user._id,
category: 'behavior',
headers: this.req.headers,
});
if (this.multiplePurchaseAllowed()) {
analyticsData.quantityPurchased = this.quantity;
}
this.analytics.track(this.analyticsLabel(), analyticsData);
}
}
export class AbstractGoldItemOperation extends AbstractBuyOperation {
@@ -149,15 +117,6 @@ export class AbstractGoldItemOperation extends AbstractBuyOperation {
user.stats.gp -= itemValue * this.quantity;
}
analyticsData () {
return {
itemKey: this.getItemKey(this.item),
itemType: this.getItemType(this.item),
currency: 'Gold',
goldCost: this.getItemValue(this.item),
};
}
}
export class AbstractGemItemOperation extends AbstractBuyOperation {
@@ -179,15 +138,6 @@ export class AbstractGemItemOperation extends AbstractBuyOperation {
await updateUserBalance(user, -(itemValue * this.quantity), 'spend', item.key, item.text());
}
analyticsData () {
return {
itemKey: this.getItemKey(this.item),
itemType: this.getItemType(this.item),
currency: 'Gems',
gemCost: this.getItemValue(this.item) * 4,
};
}
}
export class AbstractHourglassItemOperation extends AbstractBuyOperation {
@@ -202,11 +152,4 @@ export class AbstractHourglassItemOperation extends AbstractBuyOperation {
async subtractCurrency (user, item) { // eslint-disable-line class-methods-use-this
await updateUserHourglasses(user, -1, 'spend', item.key);
}
analyticsData () {
return {
itemKey: this.item.key,
currency: 'Hourglass',
};
}
}
+14 -15
View File
@@ -24,7 +24,6 @@ import { BuyHourglassMountOperation } from './buyMount';
export default async function buy (
user,
req = {},
analytics,
options = { quantity: 1, hourglass: false },
) {
const key = get(req, 'params.key');
@@ -42,35 +41,35 @@ export default async function buy (
switch (type) {
case 'armoire': {
const buyOp = new BuyArmoireOperation(user, req, analytics);
const buyOp = new BuyArmoireOperation(user, req);
buyRes = await buyOp.purchase();
break;
}
case 'backgrounds':
if (!hourglass) throw new BadRequest(errorMessage('useUnlockForCosmetics'));
buyRes = await hourglassPurchase(user, req, analytics);
buyRes = await hourglassPurchase(user, req);
break;
case 'mystery':
buyRes = await buyMysterySet(user, req, analytics);
buyRes = await buyMysterySet(user, req);
break;
case 'potion': {
const buyOp = new BuyHealthPotionOperation(user, req, analytics);
const buyOp = new BuyHealthPotionOperation(user, req);
buyRes = await buyOp.purchase();
break;
}
case 'gems': {
const buyOp = new BuyGemOperation(user, req, analytics);
const buyOp = new BuyGemOperation(user, req);
buyRes = await buyOp.purchase();
break;
}
case 'quests': {
if (hourglass) {
buyRes = await hourglassPurchase(user, req, analytics, quantity);
buyRes = await hourglassPurchase(user, req, quantity);
} else {
const buyOp = new BuyQuestWithGemOperation(user, req, analytics);
const buyOp = new BuyQuestWithGemOperation(user, req);
buyRes = await buyOp.purchase();
}
@@ -81,36 +80,36 @@ export default async function buy (
case 'food':
case 'gear':
case 'bundles':
buyRes = await purchaseOp(user, req, analytics);
buyRes = await purchaseOp(user, req);
break;
case 'mounts': {
const buyOp = new BuyHourglassMountOperation(user, req, analytics);
const buyOp = new BuyHourglassMountOperation(user, req);
buyRes = await buyOp.purchase();
break;
}
case 'pets':
if (key === 'Gryphatrice-Jubilant') {
const buyOp = new BuyPetWithGemOperation(user, req, analytics);
const buyOp = new BuyPetWithGemOperation(user, req);
buyRes = await buyOp.purchase();
} else {
buyRes = hourglassPurchase(user, req, analytics);
buyRes = hourglassPurchase(user, req);
}
break;
case 'quest': {
const buyOp = new BuyQuestWithGoldOperation(user, req, analytics);
const buyOp = new BuyQuestWithGoldOperation(user, req);
buyRes = await buyOp.purchase();
break;
}
case 'special': {
const buyOp = new BuySpellOperation(user, req, analytics);
const buyOp = new BuySpellOperation(user, req);
buyRes = await buyOp.purchase();
break;
}
default: {
const buyOp = new BuyMarketGearOperation(user, req, analytics);
const buyOp = new BuyMarketGearOperation(user, req);
buyRes = await buyOp.purchase();
break;
@@ -69,19 +69,6 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
];
}
_trackDropAnalytics (user, key) {
this.analytics.track(
'Enchanted Armoire',
{
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: key,
category: 'behavior',
headers: this.req.headers,
},
);
}
_gearResult (user, eligibleEquipment) {
const emptied = eligibleEquipment.length === 1;
eligibleEquipment.sort();
@@ -105,10 +92,6 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
removeItemByPath(user, `gear.flat.${drop.key}`);
if (this.analytics) {
this._trackDropAnalytics(user, drop.key);
}
const armoireResp = {
type: 'gear',
dropKey: drop.key,
@@ -134,9 +117,6 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
user.items.food[drop.key] += 1;
if (user.markModified) user.markModified('items.food');
if (this.analytics) {
this._trackDropAnalytics(user, drop.key);
}
return {
message: this.i18n('armoireFood', {
image: `<span class="Pet_Food_${drop.key} pull-left"></span>`,
-4
View File
@@ -70,8 +70,4 @@ export class BuyGemOperation extends AbstractGoldItemOperation { // eslint-disab
this.i18n('plusGem', { count: this.quantity }),
];
}
analyticsLabel () { // eslint-disable-line class-methods-use-this
return 'purchase gems';
}
}
@@ -60,7 +60,7 @@ export class BuyMarketGearOperation extends AbstractGoldItemOperation { // eslin
}
}
executeChanges (user, item, req, analytics) {
executeChanges (user, item, req) {
let message;
if (user.preferences.autoEquip) {
@@ -70,7 +70,7 @@ export class BuyMarketGearOperation extends AbstractGoldItemOperation { // eslin
if (!user.achievements.purchasedEquipment && user.addAchievement) {
user.addAchievement('purchasedEquipment');
checkOnboardingStatus(user, req, analytics);
checkOnboardingStatus(user, req);
}
removePinnedGearAddPossibleNewOnes(user, `gear.flat.${item.key}`, item.key);
@@ -49,10 +49,4 @@ export class BuyHourglassMountOperation extends AbstractHourglassItemOperation {
message,
];
}
analyticsData () {
const data = super.analyticsData();
data.itemType = 'mounts';
return data;
}
}
+1 -14
View File
@@ -1,6 +1,5 @@
import get from 'lodash/get';
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import content from '../../content/index';
import {
@@ -13,7 +12,7 @@ import updateUserHourglasses from '../updateUserHourglasses';
import { removeItemByPath } from '../pinnedGearUtils';
import getItemInfo from '../../libs/getItemInfo';
export default async function buyMysterySet (user, req = {}, analytics) {
export default async function buyMysterySet (user, req = {}) {
const key = get(req, 'params.key');
if (!key) throw new BadRequest(errorMessage('missingKeyParam'));
@@ -35,18 +34,6 @@ export default async function buyMysterySet (user, req = {}, analytics) {
const itemInfo = getItemInfo(user, 'mystery_set', mysterySet);
removeItemByPath(user, itemInfo.path);
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: mysterySet.key,
itemType: 'Subscriber Gear',
currency: 'Hourglass',
category: 'behavior',
headers: req.headers,
});
}
// Here we need to trigger vue reactivity through reassign object
user.items.gear.owned = {
...user.items.gear.owned,
@@ -1,7 +1,6 @@
import get from 'lodash/get';
import includes from 'lodash/includes';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import content from '../../content/index';
import {
@@ -13,7 +12,7 @@ import getItemInfo from '../../libs/getItemInfo';
import { removeItemByPath } from '../pinnedGearUtils';
import updateUserHourglasses from '../updateUserHourglasses';
export default async function purchaseHourglass (user, req = {}, analytics, quantity = 1) {
export default async function purchaseHourglass (user, req = {}, quantity = 1) {
const key = get(req, 'params.key');
if (!key) throw new BadRequest(errorMessage('missingKeyParam'));
@@ -94,18 +93,6 @@ export default async function purchaseHourglass (user, req = {}, analytics, quan
}
}
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: key,
itemType: type,
currency: 'Hourglass',
category: 'behavior',
headers: req.headers,
});
}
return [
{ items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive },
i18n.t('hourglassPurchase', req.language),
+1 -14
View File
@@ -76,7 +76,7 @@ async function purchaseItem (user, item, price, type, key) {
const acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'gear', 'bundles'];
const singlePurchaseTypes = ['gear'];
export default async function purchase (user, req = {}, analytics) {
export default async function purchase (user, req = {}) {
const type = get(req.params, 'type');
const key = get(req.params, 'key');
@@ -130,19 +130,6 @@ export default async function purchase (user, req = {}, analytics) {
await purchaseItem(user, item, price, type, key);
}
/* eslint-enable no-await-in-loop */
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: key,
itemType: type,
currency: 'Gems',
gemCost: price * 4,
quantityPurchased: quantity,
category: 'behavior',
headers: req.headers,
});
}
return [
pick(user, splitWhitespace('items balance')),
+3 -15
View File
@@ -34,19 +34,18 @@ async function resetClass (user, req = {}) {
return balanceRemoved;
}
export default async function changeClass (user, req = {}, analytics) {
export default async function changeClass (user, req = {}) {
const klass = get(req, 'query.class');
let balanceRemoved = 0;
// user.flags.classSelected is set to false after the user paid the 3 gems
if (user.stats.lvl < 10) {
throw new NotAuthorized(i18n.t('lvl10ChangeClass', req.language));
} else if (!klass) {
// if no class is specified, reset points and set user.flags.classSelected to false.
// User will have paid 3 gems and will be prompted to select class.
balanceRemoved = await resetClass(user, req);
await resetClass(user, req);
} else if (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer') {
if (user.flags.classSelected) {
balanceRemoved = await resetClass(user, req);
await resetClass(user, req);
}
user.stats.class = klass;
@@ -67,17 +66,6 @@ export default async function changeClass (user, req = {}, analytics) {
if (user.markModified) user.markModified('items.gear.owned');
removePinnedItemsByOwnedGear(user);
if (analytics) {
analytics.track('change class', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
class: klass,
currency: balanceRemoved === 0 ? 'Free' : 'Gems',
category: 'behavior',
headers: req.headers,
});
}
} else {
// if invalid class is specified, throw an error.
throw new BadRequest(i18n.t('invalidClass', req.language));
+2 -15
View File
@@ -2,9 +2,7 @@ import forEach from 'lodash/forEach';
import findIndex from 'lodash/findIndex';
import get from 'lodash/get';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import upperFirst from 'lodash/upperFirst';
import moment from 'moment';
import i18n from '../i18n';
import content from '../content/index';
import {
@@ -36,7 +34,7 @@ function evolve (user, pet, req) {
}, req.language);
}
export default function feed (user, req = {}, analytics) {
export default function feed (user, req = {}) {
let pet = get(req, 'params.pet');
const foodK = get(req, 'params.food');
let amount = Number(get(req.query, 'amount', 1));
@@ -116,7 +114,7 @@ export default function feed (user, req = {}, analytics) {
if (!user.achievements.fedPet && user.addAchievement) {
user.addAchievement('fedPet');
checkOnboardingStatus(user, req, analytics);
checkOnboardingStatus(user, req);
}
}
@@ -141,17 +139,6 @@ export default function feed (user, req = {}, analytics) {
}
});
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('pet feed', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
foodKey: food.key,
petKey: pet.key,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items.pets[pet.key],
message,
+2 -14
View File
@@ -2,9 +2,7 @@ import findIndex from 'lodash/findIndex';
import forEach from 'lodash/forEach';
import get from 'lodash/get';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import upperFirst from 'lodash/upperFirst';
import moment from 'moment';
import i18n from '../i18n';
import content from '../content/index';
import {
@@ -15,7 +13,7 @@ import {
import { errorMessage } from '../libs/errorMessage';
import { checkOnboardingStatus } from '../libs/onboarding';
export default function hatch (user, req = {}, analytics) {
export default function hatch (user, req = {}) {
const egg = get(req, 'params.egg');
const hatchingPotion = get(req, 'params.hatchingPotion');
@@ -57,7 +55,7 @@ export default function hatch (user, req = {}, analytics) {
if (!user.achievements.hatchedPet && user.addAchievement) {
user.addAchievement('hatchedPet');
checkOnboardingStatus(user, req, analytics);
checkOnboardingStatus(user, req);
}
if (content.dropEggs[egg]) {
@@ -152,16 +150,6 @@ export default function hatch (user, req = {}, analytics) {
});
}
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('pet hatch', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
petKey: pet,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items,
i18n.t('messageHatched', req.language),

Some files were not shown because too many files have changed in this diff Show More