Merge branch 'schedule-rc' into develop

This commit is contained in:
Sabe Jones 2024-06-21 06:52:46 -05:00
commit effd729222
239 changed files with 10173 additions and 5925 deletions

3
.gitignore vendored
View file

@ -8,7 +8,7 @@ i18n_cache
apidoc/html
*.swp
.idea*
config.json
config*.json
npm-debug.log*
lib
newrelic_agent.log
@ -48,3 +48,4 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-data
/.nyc_output

View file

@ -89,5 +89,8 @@
"REDIS_HOST": "aaabbbcccdddeeefff",
"REDIS_PORT": "1234",
"REDIS_PASSWORD": "12345678",
"TRUSTED_DOMAINS": "localhost,https://habitica.com"
"TRUSTED_DOMAINS": "localhost,https://habitica.com",
"TIME_TRAVEL_ENABLED": "false",
"DEBUG_ENABLED": "false",
"CONTENT_SWITCHOVER_TIME_OFFSET": 8
}

View file

@ -44,8 +44,8 @@ function runInChildProcess (command, options = {}, envVariables = '') {
return done => pipe(exec(testBin(command, envVariables), options, done));
}
function integrationTestCommand (testDir, coverageDir) {
return `istanbul cover --dir coverage/${coverageDir} --report lcovonly node_modules/mocha/bin/_mocha -- ${testDir} --recursive --require ./test/helpers/start-server`;
function integrationTestCommand (testDir) {
return `nyc --silent --no-clean mocha ${testDir} --recursive --require ./test/helpers/start-server`;
}
/* Test task definitions */
@ -148,7 +148,7 @@ gulp.task('test:content:safe', gulp.series('test:prepare:build', cb => {
gulp.task(
'test:api:unit:run',
runInChildProcess(integrationTestCommand('test/api/unit', 'coverage/api-unit')),
runInChildProcess(integrationTestCommand('test/api/unit')),
);
gulp.task('test:api:unit:watch', () => gulp.watch(['website/server/libs/*', 'test/api/unit/**/*', 'website/server/controllers/**/*'], gulp.series('test:api:unit:run', done => done())));
@ -156,7 +156,7 @@ gulp.task('test:api:unit:watch', () => gulp.watch(['website/server/libs/*', 'tes
gulp.task('test:api-v3:integration', gulp.series(
'test:prepare:mongo',
runInChildProcess(
integrationTestCommand('test/api/v3/integration', 'coverage/api-v3-integration'),
integrationTestCommand('test/api/v3/integration'),
LIMIT_MAX_BUFFER_OPTIONS,
),
));
@ -175,7 +175,7 @@ gulp.task('test:api-v3:integration:separate-server', runInChildProcess(
gulp.task('test:api-v4:integration', gulp.series(
'test:prepare:mongo',
runInChildProcess(
integrationTestCommand('test/api/v4', 'api-v4-integration'),
integrationTestCommand('test/api/v4'),
LIMIT_MAX_BUFFER_OPTIONS,
),
));

@ -1 +1 @@
Subproject commit aa723320199d7f03ce749d431b46e8d7f95cc8de
Subproject commit 97b42447318301eeac36ff50ab847904c3827659

View file

@ -0,0 +1,149 @@
/* eslint-disable no-console */
const MIGRATION_NAME = '20240621_veteran_pet_ladder';
import { model as User } from '../../../website/server/models/user';
const progressCount = 1000;
let count = 0;
async function updateUser (user) {
count++;
const set = {};
let push = { notifications: { $each: [] }};
set.migration = MIGRATION_NAME;
if (user.items.pets['Dragon-Veteran']) {
set['items.pets.Cactus-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_cactus',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Cactus and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user.items.pets['Fox-Veteran']) {
set['items.pets.Dragon-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_dragon',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Dragon and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user.items.pets['Bear-Veteran']) {
set['items.pets.Fox-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_fox',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Fox and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user.items.pets['Lion-Veteran']) {
set['items.pets.Bear-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_bear',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Bear and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user.items.pets['Tiger-Veteran']) {
set['items.pets.Lion-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_lion',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Lion and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user.items.pets['Wolf-Veteran']) {
set['items.pets.Tiger-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_tiger',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Tiger and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
} else {
set['items.pets.Wolf-Veteran'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'icon_pet_veteran_wolf',
title: 'Youve received a Veteran Pet!',
text: 'To commemorate being here for a new era of Habitica, weve awarded you a Veteran Wolf and 24 Gems!',
destination: '/inventory/stable',
},
seen: false,
});
}
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
await user.updateBalance(
6,
'admin_update_balance',
'',
'Veteran Ladder award',
);
return await User.updateOne(
{ _id: user._id },
{ $set: set, $push: push, $inc: { balance: 6 } },
).exec();
}
export default async function processUsers () {
let query = {
migration: {$ne: MIGRATION_NAME},
'auth.timestamps.loggedin': { $gt: new Date('2024-05-21') },
};
const fields = {
_id: 1,
items: 1,
migration: 1,
contributor: 1,
};
while (true) { // eslint-disable-line no-constant-condition
const users = await User // eslint-disable-line no-await-in-loop
.find(query)
.limit(250)
.sort({_id: 1})
.select(fields)
.exec();
if (users.length === 0) {
console.warn('All appropriate users found and modified.');
console.warn(`\n${count} users processed\n`);
break;
} else {
query._id = {
$gt: users[users.length - 1],
};
}
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
}
};

1422
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.25.2",
"version": "5.24.2",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@ -67,6 +67,7 @@
"remove-markdown": "^0.5.0",
"rimraf": "^3.0.2",
"short-uuid": "^4.2.2",
"sinon": "^15.2.0",
"stripe": "^12.18.0",
"superagent": "^8.1.2",
"universal-analytics": "^0.5.3",
@ -93,11 +94,11 @@
"test:api-v3:integration:separate-server": "NODE_ENV=test gulp test:api-v3:integration:separate-server",
"test:api-v4:integration": "gulp test:api-v4:integration",
"test:api-v4:integration:separate-server": "NODE_ENV=test gulp test:api-v4:integration:separate-server",
"test:sanity": "istanbul cover --dir coverage/sanity --report lcovonly node_modules/mocha/bin/_mocha -- test/sanity --recursive",
"test:common": "istanbul cover --dir coverage/common --report lcovonly node_modules/mocha/bin/_mocha -- test/common --recursive",
"test:content": "istanbul cover --dir coverage/content --report lcovonly node_modules/mocha/bin/_mocha -- test/content --recursive",
"test:sanity": "nyc --silent --no-clean mocha test/sanity --recursive",
"test:common": "nyc --silent --no-clean mocha test/common --recursive",
"test:content": "nyc --silent --no-clean mocha test/content --recursive",
"test:nodemon": "gulp test:nodemon",
"coverage": "COVERAGE=true mocha --require register-handlers.js --reporter html-cov > coverage.html; open coverage.html",
"coverage": "nyc report --reporter=html --report-dir coverage/results; open coverage/results/index.html",
"sprites": "gulp sprites:compile",
"client:dev": "cd website/client && npm run serve",
"client:build": "cd website/client && npm run build",
@ -115,13 +116,11 @@
"chai-moment": "^0.1.0",
"chalk": "^5.3.0",
"cross-spawn": "^7.0.3",
"expect.js": "^0.3.1",
"istanbul": "^1.1.0-alpha.1",
"mocha": "^5.1.1",
"monk": "^7.3.4",
"nyc": "^15.1.0",
"require-again": "^2.0.0",
"run-rs": "^0.7.7",
"sinon": "^15.2.0",
"sinon-chai": "^3.7.0",
"sinon-stub-promise": "^4.0.0"
}

View file

@ -1,5 +1,9 @@
import fs from 'fs';
import * as contentLib from '../../../../website/server/libs/content';
import content from '../../../../website/common/script/content';
import {
generateRes,
} from '../../../helpers/api-unit.helper';
describe('contentLib', () => {
describe('CONTENT_CACHE_PATH', () => {
@ -13,5 +17,90 @@ describe('contentLib', () => {
contentLib.getLocalizedContentResponse();
expect(typeof content.backgrounds.backgrounds062014.beach.text).to.equal('function');
});
it('removes keys from the content data', () => {
const response = contentLib.localizeContentData(content, 'en', { backgroundsFlat: true, dropHatchingPotions: true });
expect(response.backgroundsFlat).to.not.exist;
expect(response.backgrounds).to.exist;
expect(response.dropHatchingPotions).to.not.exist;
expect(response.hatchingPotions).to.exist;
});
it('removes nested keys from the content data', () => {
const response = contentLib.localizeContentData(content, 'en', { gear: { tree: true } });
expect(response.gear.tree).to.not.exist;
expect(response.gear.flat).to.exist;
});
});
it('generates a hash for a filter', () => {
const hash = contentLib.hashForFilter('backgroundsFlat,gear.flat');
expect(hash).to.equal('-1791877526');
});
it('serves content', () => {
const resSpy = generateRes();
contentLib.serveContent(resSpy, 'en', '', false);
expect(resSpy.send).to.have.been.calledOnce;
});
it('serves filtered content', () => {
const resSpy = generateRes();
contentLib.serveContent(resSpy, 'en', 'backgroundsFlat,gear.flat', false);
expect(resSpy.send).to.have.been.calledOnce;
});
describe('caches content', async () => {
let resSpy;
beforeEach(() => {
resSpy = generateRes();
if (fs.existsSync(contentLib.CONTENT_CACHE_PATH)) {
fs.rmdirSync(contentLib.CONTENT_CACHE_PATH, { recursive: true });
}
fs.mkdirSync(contentLib.CONTENT_CACHE_PATH);
});
it('does not cache requests in development mode', async () => {
contentLib.serveContent(resSpy, 'en', '', false);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.false;
});
it('caches unfiltered requests', async () => {
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.false;
contentLib.serveContent(resSpy, 'en', '', true);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en.json`)).to.be.true;
});
it('serves cached requests', async () => {
fs.writeFileSync(
`${contentLib.CONTENT_CACHE_PATH}en.json`,
'{"success": true, "data": {"all": {}}}',
'utf8',
);
contentLib.serveContent(resSpy, 'en', '', true);
expect(resSpy.sendFile).to.have.been.calledOnce;
expect(resSpy.sendFile).to.have.been.calledWith(`${contentLib.CONTENT_CACHE_PATH}en.json`);
});
it('caches filtered requests', async () => {
const filter = 'backgroundsFlat,gear.flat';
const hash = contentLib.hashForFilter(filter);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`)).to.be.false;
contentLib.serveContent(resSpy, 'en', filter, true);
expect(fs.existsSync(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`)).to.be.true;
});
it('serves filtered cached requests', async () => {
const filter = 'backgroundsFlat,gear.flat';
const hash = contentLib.hashForFilter(filter);
fs.writeFileSync(
`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`,
'{"success": true, "data": {}}',
'utf8',
);
contentLib.serveContent(resSpy, 'en', filter, true);
expect(resSpy.sendFile).to.have.been.calledOnce;
expect(resSpy.sendFile).to.have.been.calledWith(`${contentLib.CONTENT_CACHE_PATH}en${hash}.json`);
});
});
});

View file

@ -117,7 +117,7 @@ describe('Items Utils', () => {
it('converts values for owned gear to true/false', () => {
expect(castItemVal('items.gear.owned.shield_warrior_0', 'true')).to.equal(true);
expect(castItemVal('items.gear.owned.invalid', 'false')).to.equal(false);
expect(castItemVal('items.gear.owned.invalid', 'null')).to.equal(false);
expect(castItemVal('items.gear.owned.invalid', 'null')).to.equal(undefined);
expect(castItemVal('items.gear.owned.invalid', 'truthy')).to.equal(true);
expect(castItemVal('items.gear.owned.invalid', 0)).to.equal(false);
});

View file

@ -0,0 +1,51 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import ensureDevelopmentMode from '../../../../website/server/middlewares/ensureDevelopmentMode';
import { NotFound } from '../../../../website/server/libs/errors';
describe('developmentMode middleware', () => {
let res; let req; let next;
let nconfStub;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
nconfStub = sandbox.stub(nconf, 'get');
});
it('returns not found when on production URL', () => {
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
ensureDevelopmentMode(req, res, next);
const calledWith = next.getCall(0).args;
expect(calledWith[0] instanceof NotFound).to.equal(true);
});
it('returns not found when intentionally disabled', () => {
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
nconfStub.withArgs('BASE_URL').returns('http://localhost:3000');
ensureDevelopmentMode(req, res, next);
const calledWith = next.getCall(0).args;
expect(calledWith[0] instanceof NotFound).to.equal(true);
});
it('passes when enabled and on non-production URL', () => {
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
nconfStub.withArgs('BASE_URL').returns('http://localhost:3000');
ensureDevelopmentMode(req, res, next);
expect(next).to.be.calledOnce;
expect(next.args[0]).to.be.empty;
});
});

View file

@ -1,38 +0,0 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import ensureDevelpmentMode from '../../../../website/server/middlewares/ensureDevelpmentMode';
import { NotFound } from '../../../../website/server/libs/errors';
describe('developmentMode middleware', () => {
let res; let req; let
next;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('returns not found when in production mode', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
ensureDevelpmentMode(req, res, next);
const calledWith = next.getCall(0).args;
expect(calledWith[0] instanceof NotFound).to.equal(true);
});
it('passes when not in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
ensureDevelpmentMode(req, res, next);
expect(next).to.be.calledOnce;
expect(next.args[0]).to.be.empty;
});
});

View file

@ -0,0 +1,51 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import { NotFound } from '../../../../website/server/libs/errors';
import ensureTimeTravelMode from '../../../../website/server/middlewares/ensureTimeTravelMode';
describe('timetravelMode middleware', () => {
let res; let req; let next;
let nconfStub;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
nconfStub = sandbox.stub(nconf, 'get');
});
it('returns not found when using production URL', () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(false);
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
ensureTimeTravelMode(req, res, next);
const calledWith = next.getCall(0).args;
expect(calledWith[0] instanceof NotFound).to.equal(true);
});
it('returns not found when not in time travel mode', () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(false);
nconfStub.withArgs('BASE_URL').returns('http://localhost:3000');
ensureTimeTravelMode(req, res, next);
const calledWith = next.getCall(0).args;
expect(calledWith[0] instanceof NotFound).to.equal(true);
});
it('passes when in time travel mode', () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(true);
nconfStub.withArgs('BASE_URL').returns('http://localhost:3000');
ensureTimeTravelMode(req, res, next);
expect(next).to.be.calledOnce;
expect(next.args[0]).to.be.empty;
});
});

View file

@ -22,4 +22,38 @@ describe('GET /content', () => {
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText'));
});
it('does not filter content for regular requests', async () => {
const res = await requester().get('/content');
expect(res).to.have.nested.property('backgrounds.backgrounds062014');
expect(res).to.have.nested.property('gear.tree');
});
it('filters content automatically for iOS requests', async () => {
const res = await requester(null, { 'x-client': 'habitica-ios' }).get('/content');
expect(res).to.have.nested.property('appearances.background.beach');
expect(res).to.not.have.nested.property('backgrounds.backgrounds062014');
expect(res).to.not.have.property('backgroundsFlat');
expect(res).to.not.have.nested.property('gear.tree');
});
it('filters content automatically for Android requests', async () => {
const res = await requester(null, { 'x-client': 'habitica-android' }).get('/content');
expect(res).to.not.have.nested.property('appearances.background.beach');
expect(res).to.have.nested.property('backgrounds.backgrounds062014');
expect(res).to.not.have.property('backgroundsFlat');
expect(res).to.not.have.nested.property('gear.tree');
});
it('filters content if the request specifies a filter', async () => {
const res = await requester().get('/content?filter=backgroundsFlat,gear.flat');
expect(res).to.not.have.property('backgroundsFlat');
expect(res).to.have.nested.property('gear.tree');
expect(res).to.not.have.nested.property('gear.flat');
});
it('filters content if the request contains invalid filters', async () => {
const res = await requester().get('/content?filter=backgroundsFlat,invalid');
expect(res).to.not.have.property('backgroundsFlat');
});
});

View file

@ -0,0 +1,46 @@
import nconf from 'nconf';
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('GET /debug/time-travel-time', () => {
let user;
let nconfStub;
before(async () => {
user = await generateUser({ permissions: { fullAccess: true } });
});
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
});
afterEach(() => {
nconfStub.restore();
});
it('returns the shifted time', async () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(true);
const result = await user.get('/debug/time-travel-time');
expect(result.time).to.exist;
await user.post('/debug/jump-time', { disable: true });
});
it('returns shifted when the user is not an admin', async () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(true);
const regularUser = await generateUser();
const result = await regularUser.get('/debug/time-travel-time');
expect(result.time).to.exist;
});
it('returns error when not in time travel mode', async () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(false);
await expect(user.get('/debug/time-travel-time'))
.eventually.be.rejected.and.to.deep.equal({
code: 404,
error: 'NotFound',
message: 'Not found.',
});
});
});

View file

@ -5,16 +5,23 @@ import {
describe('POST /debug/add-hourglass', () => {
let userToGetHourGlass;
let nconfStub;
before(async () => {
userToGetHourGlass = await generateUser();
});
after(() => {
nconf.set('IS_PROD', false);
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://example.com');
});
afterEach(() => {
nconfStub.restore();
});
it('adds Hourglass to the current user', async () => {
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
await userToGetHourGlass.post('/debug/add-hourglass');
const userWithHourGlass = await userToGetHourGlass.get('/user');
@ -23,7 +30,7 @@ describe('POST /debug/add-hourglass', () => {
});
it('returns error when not in production mode', async () => {
nconf.set('IS_PROD', true);
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
await expect(userToGetHourGlass.post('/debug/add-hourglass'))
.eventually.be.rejected.and.to.deep.equal({

View file

@ -5,16 +5,23 @@ import {
describe('POST /debug/add-ten-gems', () => {
let userToGainTenGems;
let nconfStub;
before(async () => {
userToGainTenGems = await generateUser();
});
after(() => {
nconf.set('IS_PROD', false);
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://example.com');
});
afterEach(() => {
nconfStub.restore();
});
it('adds ten gems to the current user', async () => {
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
await userToGainTenGems.post('/debug/add-ten-gems');
const userWithTenGems = await userToGainTenGems.get('/user');
@ -23,7 +30,7 @@ describe('POST /debug/add-ten-gems', () => {
});
it('returns error when not in production mode', async () => {
nconf.set('IS_PROD', true);
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
await expect(userToGainTenGems.post('/debug/add-ten-gems'))
.eventually.be.rejected.and.to.deep.equal({

View file

@ -0,0 +1,82 @@
import nconf from 'nconf';
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('POST /debug/jump-time', () => {
let user;
let today;
let nconfStub;
before(async () => {
user = await generateUser({ permissions: { fullAccess: true } });
today = new Date();
});
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(true);
});
afterEach(() => {
nconfStub.restore();
});
after(async () => {
nconf.set('TIME_TRAVEL_ENABLED', true);
await user.post('/debug/jump-time', { disable: true });
nconf.set('TIME_TRAVEL_ENABLED', false);
});
it('Jumps forward', async () => {
const resultDate = new Date((await user.post('/debug/jump-time', { reset: true })).time);
expect(resultDate.getDate()).to.eql(today.getDate());
expect(resultDate.getMonth()).to.eql(today.getMonth());
expect(resultDate.getFullYear()).to.eql(today.getFullYear());
const newResultDate = new Date((await user.post('/debug/jump-time', { offsetDays: 1 })).time);
expect(newResultDate.getDate()).to.eql(today.getDate() + 1);
expect(newResultDate.getMonth()).to.eql(today.getMonth());
expect(newResultDate.getFullYear()).to.eql(today.getFullYear());
});
it('jumps back', async () => {
const resultDate = new Date((await user.post('/debug/jump-time', { reset: true })).time);
expect(resultDate.getDate()).to.eql(today.getDate());
expect(resultDate.getMonth()).to.eql(today.getMonth());
expect(resultDate.getFullYear()).to.eql(today.getFullYear());
const newResultDate = new Date((await user.post('/debug/jump-time', { offsetDays: -1 })).time);
expect(newResultDate.getDate()).to.eql(today.getDate() - 1);
expect(newResultDate.getMonth()).to.eql(today.getMonth());
expect(newResultDate.getFullYear()).to.eql(today.getFullYear());
});
it('can jump a lot', async () => {
const resultDate = new Date((await user.post('/debug/jump-time', { reset: true })).time);
expect(resultDate.getDate()).to.eql(today.getDate());
expect(resultDate.getMonth()).to.eql(today.getMonth());
expect(resultDate.getFullYear()).to.eql(today.getFullYear());
const newResultDate = new Date((await user.post('/debug/jump-time', { offsetDays: 355 })).time);
expect(newResultDate.getFullYear()).to.eql(today.getFullYear() + 1);
});
it('returns error when the user is not an admin', async () => {
const regularUser = await generateUser();
await expect(regularUser.post('/debug/jump-time', { offsetDays: 1 }))
.eventually.be.rejected.and.to.deep.equal({
code: 400,
error: 'BadRequest',
message: 'You do not have permission to time travel.',
});
});
it('returns error when not in time travel mode', async () => {
nconfStub.withArgs('TIME_TRAVEL_ENABLED').returns(false);
await expect(user.post('/debug/jump-time', { offsetDays: 1 }))
.eventually.be.rejected.and.to.deep.equal({
code: 404,
error: 'NotFound',
message: 'Not found.',
});
});
});

View file

@ -5,16 +5,23 @@ import {
describe('POST /debug/make-admin', () => {
let user;
let nconfStub;
before(async () => {
user = await generateUser();
});
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://example.com');
});
afterEach(() => {
nconf.set('IS_PROD', false);
nconfStub.restore();
});
it('makes user an admin', async () => {
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
await user.post('/debug/make-admin');
await user.sync();
@ -23,7 +30,7 @@ describe('POST /debug/make-admin', () => {
});
it('returns error when not in production mode', async () => {
nconf.set('IS_PROD', true);
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
await expect(user.post('/debug/make-admin'))
.eventually.be.rejected.and.to.deep.equal({

View file

@ -8,6 +8,7 @@ import {
describe('POST /debug/modify-inventory', () => {
let user; let
originalItems;
let nconfStub;
before(async () => {
originalItems = {
@ -39,8 +40,14 @@ describe('POST /debug/modify-inventory', () => {
});
});
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
nconfStub.withArgs('BASE_URL').returns('https://example.com');
});
afterEach(() => {
nconf.set('IS_PROD', false);
nconfStub.restore();
});
it('sets equipment', async () => {
@ -149,7 +156,7 @@ describe('POST /debug/modify-inventory', () => {
});
it('returns error when not in production mode', async () => {
nconf.set('IS_PROD', true);
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
await expect(user.post('/debug/modify-inventory'))
.eventually.be.rejected.and.to.deep.equal({

View file

@ -5,13 +5,20 @@ import {
describe('POST /debug/quest-progress', () => {
let user;
let nconfStub;
beforeEach(async () => {
user = await generateUser();
});
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
nconfStub.withArgs('BASE_URL').returns('https://example.com');
});
afterEach(() => {
nconf.set('IS_PROD', false);
nconfStub.restore();
});
it('errors if user is not on a quest', async () => {
@ -48,7 +55,7 @@ describe('POST /debug/quest-progress', () => {
});
it('returns error when not in production mode', async () => {
nconf.set('IS_PROD', true);
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
await expect(user.post('/debug/quest-progress'))
.eventually.be.rejected.and.to.deep.equal({

View file

@ -5,13 +5,20 @@ import {
describe('POST /debug/set-cron', () => {
let user;
let nconfStub;
before(async () => {
user = await generateUser();
});
beforeEach(() => {
nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('DEBUG_ENABLED').returns(true);
nconfStub.withArgs('BASE_URL').returns('https://example.com');
});
afterEach(() => {
nconf.set('IS_PROD', false);
nconfStub.restore();
});
it('sets last cron', async () => {
@ -27,7 +34,7 @@ describe('POST /debug/set-cron', () => {
});
it('returns error when not in production mode', async () => {
nconf.set('IS_PROD', true);
nconfStub.withArgs('DEBUG_ENABLED').returns(false);
await expect(user.post('/debug/set-cron'))
.eventually.be.rejected.and.to.deep.equal({

View file

@ -17,9 +17,5 @@ describe('GET /shops/backgrounds', () => {
expect(shop.notes).to.eql(t('backgroundShop'));
expect(shop.imageName).to.equal('background_shop');
expect(shop.sets).to.be.an('array');
const sets = shop.sets.map(set => set.identifier);
expect(sets).to.include('incentiveBackgrounds');
expect(sets).to.include('backgrounds062014');
});
});

View file

@ -5,9 +5,15 @@ import {
describe('GET /shops/time-travelers', () => {
let user;
let clock;
beforeEach(async () => {
user = await generateUser();
clock = sinon.useFakeTimers(new Date('2024-06-08'));
});
afterEach(() => {
clock.restore();
});
it('returns a valid shop object', async () => {

View file

@ -33,6 +33,20 @@ describe('POST /user/purchase/:type/:key', () => {
expect(user.items[type][key]).to.equal(1);
});
it('purchases animal ears', async () => {
await user.post('/user/purchase/gear/headAccessory_special_tigerEars');
await user.sync();
expect(user.items.gear.owned.headAccessory_special_tigerEars).to.equal(true);
});
it('purchases animal tails', async () => {
await user.post('/user/purchase/gear/back_special_pandaTail');
await user.sync();
expect(user.items.gear.owned.back_special_pandaTail).to.equal(true);
});
it('can convert gold to gems if subscribed', async () => {
const oldBalance = user.balance;
await user.updateOne({

View file

@ -5,7 +5,7 @@ import {
describe('POST /user/unlock', () => {
let user;
const unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie';
const unlockPath = 'shirt.convict,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie';
const unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars';
const unlockCost = 1.25;
const usersStartingGems = 5;

View file

@ -274,6 +274,14 @@ describe('PUT /user', () => {
expect(get(updatedUser.preferences, type)).to.eql(item);
});
});
it('updates user when background is unequipped', async () => {
expect(get(user.preferences, 'background')).to.not.eql('');
const updatedUser = await user.put('/user', { 'preferences.background': '' });
expect(get(updatedUser.preferences, 'background')).to.eql('');
});
});
context('Improvement Categories', () => {

View file

@ -11,6 +11,7 @@ const { content } = shared;
describe('POST /user/buy/:key', () => {
let user;
let clock;
beforeEach(async () => {
user = await generateUser({
@ -18,6 +19,12 @@ describe('POST /user/buy/:key', () => {
});
});
afterEach(() => {
if (clock) {
clock.restore();
}
});
// More tests in common code unit tests
it('returns an error if the item is not found', async () => {
@ -68,9 +75,9 @@ describe('POST /user/buy/:key', () => {
});
it('buys a special spell', async () => {
clock = sinon.useFakeTimers(new Date('2024-10-31T00:00:00Z'));
const key = 'spookySparkles';
const item = content.special[key];
const stub = sinon.stub(item, 'canOwn').returns(true);
await user.updateOne({ 'stats.gp': 250 });
const res = await user.post(`/user/buy/${key}`);
@ -83,8 +90,6 @@ describe('POST /user/buy/:key', () => {
expect(res.message).to.equal(t('messageBought', {
itemText: item.text(),
}));
stub.restore();
});
it('allows for bulk purchases', async () => {

View file

@ -1,5 +1,3 @@
import { TAVERN_ID } from '../../../../../website/server/models/group';
import { updateDocument } from '../../../../helpers/mongo';
import {
requester,
resetHabiticaDB,
@ -18,7 +16,9 @@ describe('GET /world-state', () => {
});
it('returns Tavern quest data when world boss is active', async () => {
await updateDocument('groups', { _id: TAVERN_ID }, { quest: { active: true, key: 'dysheartener', progress: { hp: 50000, rage: 9999 } } });
sinon.stub(worldState, 'getWorldBoss').returns({
active: true, extra: {}, key: 'dysheartener', progress: { hp: 50000, rage: 9999, collect: {} },
});
const res = await requester().get('/world-state');
expect(res).to.have.nested.property('worldBoss');
@ -33,15 +33,29 @@ describe('GET /world-state', () => {
rage: 9999,
},
});
worldState.getWorldBoss.restore();
});
it('calls getRepeatingEvents for data', async () => {
const getRepeatingEventsOnDate = sinon.stub(common.content, 'getRepeatingEventsOnDate').returns([]);
const getCurrentGalaEvent = sinon.stub(common.schedule, 'getCurrentGalaEvent').returns({});
await requester().get('/world-state');
expect(getRepeatingEventsOnDate).to.have.been.calledOnce;
expect(getCurrentGalaEvent).to.have.been.calledOnce;
getRepeatingEventsOnDate.restore();
getCurrentGalaEvent.restore();
});
context('no current event', () => {
beforeEach(async () => {
sinon.stub(worldState, 'getCurrentEvent').returns(null);
sinon.stub(worldState, 'getCurrentEventList').returns([]);
});
afterEach(() => {
worldState.getCurrentEvent.restore();
worldState.getCurrentEventList.restore();
});
it('returns null for the current event when there is none active', async () => {
@ -51,18 +65,18 @@ describe('GET /world-state', () => {
});
});
context('no current event', () => {
context('active event', () => {
const evt = {
...common.content.events.fall2020,
event: 'fall2020',
};
beforeEach(async () => {
sinon.stub(worldState, 'getCurrentEvent').returns(evt);
sinon.stub(worldState, 'getCurrentEventList').returns([evt]);
});
afterEach(() => {
worldState.getCurrentEvent.restore();
worldState.getCurrentEventList.restore();
});
it('returns the current event when there is an active one', async () => {
@ -71,4 +85,45 @@ describe('GET /world-state', () => {
expect(res.currentEvent).to.eql(evt);
});
});
context('active event with NPC image suffix', () => {
const evt = {
...common.content.events.fall2020,
event: 'fall2020',
npcImageSuffix: 'fall',
};
beforeEach(async () => {
sinon.stub(worldState, 'getCurrentEventList').returns([evt]);
});
afterEach(() => {
worldState.getCurrentEventList.restore();
});
it('returns the NPC image suffix when present', async () => {
const res = await requester().get('/world-state');
expect(res.npcImageSuffix).to.equal('fall');
});
it('returns the NPC image suffix with multiple events present', async () => {
const evt2 = {
...common.content.events.winter2020,
event: 'test',
};
const evt3 = {
...common.content.events.winter2020,
event: 'winter2020',
npcImageSuffix: 'winter',
};
worldState.getCurrentEventList.returns([evt, evt2, evt3]);
const res = await requester().get('/world-state');
expect(res.npcImageSuffix).to.equal('winter');
});
});
});

View file

@ -0,0 +1,67 @@
/* eslint-disable global-require */
import { expect } from 'chai';
import nconf from 'nconf';
const SWITCHOVER_TIME = nconf.get('CONTENT_SWITCHOVER_TIME_OFFSET') || 0;
describe('datedMemoize', () => {
it('should return a function that returns a function', () => {
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(() => {});
expect(memoized).to.be.a('function');
});
it('should not call multiple times', () => {
const stub = sandbox.stub().returns({});
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(stub);
memoized(1, 2);
memoized(1, 3);
expect(stub).to.have.been.calledOnce;
});
it('call multiple times for different identifiers', () => {
const stub = sandbox.stub().returns({});
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(stub);
memoized({ identifier: 'a', memoizeConfig: true }, 1, 2);
memoized({ identifier: 'b', memoizeConfig: true }, 1, 2);
expect(stub).to.have.been.calledTwice;
});
it('call once for the same identifier', () => {
const stub = sandbox.stub().returns({});
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(stub);
memoized({ identifier: 'a', memoizeConfig: true }, 1, 2);
memoized({ identifier: 'a', memoizeConfig: true }, 1, 2);
expect(stub).to.have.been.calledOnce;
});
it('call once on the same day', () => {
const stub = sandbox.stub().returns({});
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(stub);
memoized({ date: new Date('2024-01-01'), memoizeConfig: true }, 1, 2);
memoized({ date: new Date('2024-01-01'), memoizeConfig: true }, 1, 2);
expect(stub).to.have.been.calledOnce;
});
it('call multiple times on different days', () => {
const stub = sandbox.stub().returns({});
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(stub);
memoized({ date: new Date('2024-01-01'), memoizeConfig: true }, 1, 2);
memoized({ date: new Date('2024-01-02'), memoizeConfig: true }, 1, 2);
expect(stub).to.have.been.calledTwice;
});
it('respects switchover time', () => {
const stub = sandbox.stub().returns({});
const datedMemoize = require('../../../website/common/script/fns/datedMemoize').default;
const memoized = datedMemoize(stub);
memoized({ date: new Date('2024-01-01T00:00:00.000Z'), memoizeConfig: true }, 1, 2);
memoized({ date: new Date(`2024-01-01T${String(SWITCHOVER_TIME).padStart(2, '0')}`), memoizeConfig: true }, 1, 2);
expect(stub).to.have.been.calledTwice;
});
});

View file

@ -0,0 +1,123 @@
import {
generateUser,
} from '../../helpers/common.helper';
import cleanupPinnedItems from '../../../website/common/script/libs/cleanupPinnedItems';
describe('cleanupPinnedItems', () => {
let user;
let testPinnedItems;
let clock;
beforeEach(() => {
user = generateUser();
clock = sinon.useFakeTimers(new Date('2024-04-08'));
testPinnedItems = [
{ type: 'armoire', path: 'armoire' },
{ type: 'potion', path: 'potion' },
{ type: 'background', path: 'backgrounds.backgrounds042020.heather_field' },
{ type: 'background', path: 'backgrounds.backgrounds042021.heather_field' },
{ type: 'premiumHatchingPotion', path: 'premiumHatchingPotions.Rainbow' },
{ type: 'premiumHatchingPotion', path: 'premiumHatchingPotions.StainedGlass' },
{ type: 'quests', path: 'quests.rat' },
{ type: 'quests', path: 'quests.spider' },
{ type: 'quests', path: 'quests.moon1' },
{ type: 'quests', path: 'quests.silver' },
{ type: 'marketGear', path: 'gear.flat.head_special_nye2021' },
{ type: 'gear', path: 'gear.flat.armor_special_spring2019Rogue' },
{ type: 'gear', path: 'gear.flat.armor_special_winter2021Rogue' },
{ type: 'mystery_set', path: 'mystery.201804' },
{ type: 'mystery_set', path: 'mystery.201506' },
{ type: 'bundles', path: 'bundles.farmFriends' },
{ type: 'bundles', path: 'bundles.birdBuddies' },
{ type: 'customization', path: 'skin.birdBuddies' },
];
});
afterEach(() => {
clock.restore();
});
it('always keeps armoire and potion', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'armoire')).to.exist;
expect(_.find(result, item => item.path === 'potion')).to.exist;
});
it('removes simple items that are no longer available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'backgrounds.backgrounds042021.heather_field')).to.not.exist;
expect(_.find(result, item => item.path === 'premiumHatchingPotions.Rainbow')).to.not.exist;
});
it('keeps simple items that are still available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'backgrounds.backgrounds042020.heather_field')).to.exist;
expect(_.find(result, item => item.path === 'premiumHatchingPotions.StainedGlass')).to.exist;
});
it('removes gear that is no longer available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'gear.flat.armor_special_winter2021Rogue')).to.not.exist;
});
it('keeps gear that is still available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'gear.flat.armor_special_spring2019Rogue')).to.exist;
});
it('keeps gear that is not seasonal', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'gear.flat.head_special_nye2021')).to.exist;
});
it('removes time traveler gear that is no longer available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'mystery.201506')).to.not.exist;
});
it('keeps time traveler gear that is still available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'mystery.201804')).to.exist;
});
it('removes quests that are no longer available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'quests.rat')).to.not.exist;
expect(_.find(result, item => item.path === 'quests.silver')).to.not.exist;
});
it('keeps quests that are still available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'quests.spider')).to.exist;
});
it('keeps quests that are not seasonal', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'quests.moon1')).to.exist;
});
it('removes bundles that are no longer available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'bundles.farmFriends')).to.not.exist;
});
it('keeps bundles that are still available', () => {
user.pinnedItems = testPinnedItems;
const result = cleanupPinnedItems(user);
expect(_.find(result, item => item.path === 'bundles.birdBuddies')).to.exist;
});
});

View file

@ -1,219 +0,0 @@
import shared from '../../../website/common';
import {
generateUser,
} from '../../helpers/common.helper';
describe('shops', () => {
const user = generateUser();
describe('market', () => {
const shopCategories = shared.shops.getMarketCategories(user);
it('contains at least the 3 default categories', () => {
expect(shopCategories.length).to.be.greaterThan(2);
});
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'locked', 'purchaseType', 'class'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
});
});
it('shows relevant non class gear in special category', () => {
const contributor = generateUser({
contributor: {
level: 7,
critical: true,
},
items: {
gear: {
owned: {
weapon_armoire_basicCrossbow: true, // eslint-disable-line camelcase
},
},
},
});
const gearCategories = shared.shops.getMarketGearCategories(contributor);
const specialCategory = gearCategories.find(o => o.identifier === 'none');
expect(specialCategory.items.find(item => item.key === 'weapon_special_1'));
expect(specialCategory.items.find(item => item.key === 'armor_special_1'));
expect(specialCategory.items.find(item => item.key === 'head_special_1'));
expect(specialCategory.items.find(item => item.key === 'shield_special_1'));
expect(specialCategory.items.find(item => item.key === 'weapon_special_critical'));
expect(specialCategory.items.find(item => item.key === 'weapon_armoire_basicCrossbow'));// eslint-disable-line camelcase
});
it('does not show gear when it is all owned', () => {
const userWithItems = generateUser({
stats: {
class: 'wizard',
},
items: {
gear: {
owned: {
weapon_wizard_0: true, // eslint-disable-line camelcase
weapon_wizard_1: true, // eslint-disable-line camelcase
weapon_wizard_2: true, // eslint-disable-line camelcase
weapon_wizard_3: true, // eslint-disable-line camelcase
weapon_wizard_4: true, // eslint-disable-line camelcase
weapon_wizard_5: true, // eslint-disable-line camelcase
weapon_wizard_6: true, // eslint-disable-line camelcase
armor_wizard_1: true, // eslint-disable-line camelcase
armor_wizard_2: true, // eslint-disable-line camelcase
armor_wizard_3: true, // eslint-disable-line camelcase
armor_wizard_4: true, // eslint-disable-line camelcase
armor_wizard_5: true, // eslint-disable-line camelcase
head_wizard_1: true, // eslint-disable-line camelcase
head_wizard_2: true, // eslint-disable-line camelcase
head_wizard_3: true, // eslint-disable-line camelcase
head_wizard_4: true, // eslint-disable-line camelcase
head_wizard_5: true, // eslint-disable-line camelcase
},
},
},
});
const shopWizardItems = shared.shops.getMarketGearCategories(userWithItems).find(x => x.identifier === 'wizard').items.filter(x => x.klass === 'wizard' && (x.owned === false || x.owned === undefined));
expect(shopWizardItems.length).to.eql(0);
});
it('shows available gear not yet purchased and previously owned', () => {
const userWithItems = generateUser({
stats: {
class: 'wizard',
},
items: {
gear: {
owned: {
weapon_wizard_0: true, // eslint-disable-line camelcase
weapon_wizard_1: true, // eslint-disable-line camelcase
weapon_wizard_2: true, // eslint-disable-line camelcase
weapon_wizard_3: true, // eslint-disable-line camelcase
weapon_wizard_4: true, // eslint-disable-line camelcase
armor_wizard_1: true, // eslint-disable-line camelcase
armor_wizard_2: true, // eslint-disable-line camelcase
armor_wizard_3: false, // eslint-disable-line camelcase
armor_wizard_4: false, // eslint-disable-line camelcase
head_wizard_1: true, // eslint-disable-line camelcase
head_wizard_2: false, // eslint-disable-line camelcase
head_wizard_3: true, // eslint-disable-line camelcase
head_wizard_4: false, // eslint-disable-line camelcase
head_wizard_5: true, // eslint-disable-line camelcase
},
},
},
});
const shopWizardItems = shared.shops.getMarketGearCategories(userWithItems).find(x => x.identifier === 'wizard').items.filter(x => x.klass === 'wizard' && (x.owned === false || x.owned === undefined));
expect(shopWizardItems.find(item => item.key === 'weapon_wizard_5').locked).to.eql(false);
expect(shopWizardItems.find(item => item.key === 'weapon_wizard_6').locked).to.eql(true);
expect(shopWizardItems.find(item => item.key === 'armor_wizard_3').locked).to.eql(false);
expect(shopWizardItems.find(item => item.key === 'armor_wizard_4').locked).to.eql(true);
expect(shopWizardItems.find(item => item.key === 'head_wizard_2').locked).to.eql(false);
expect(shopWizardItems.find(item => item.key === 'head_wizard_4').locked).to.eql(true);
});
});
describe('questShop', () => {
const shopCategories = shared.shops.getQuestShopCategories(user);
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
if (category.identifier === 'bundle') {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'purchaseType', 'class'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
} else {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'locked', 'purchaseType', 'boss', 'class', 'collect', 'drop', 'unlockCondition', 'lvl'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
}
});
});
});
describe('timeTravelers', () => {
const shopCategories = shared.shops.getTimeTravelersCategories(user);
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
_.each(category.items, item => {
_.each(['key', 'text', 'value', 'currency', 'locked', 'purchaseType', 'class', 'notes', 'class'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
});
});
});
describe('seasonalShop', () => {
const shopCategories = shared.shops.getSeasonalShopCategories(user);
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'locked', 'purchaseType', 'type'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
});
});
});
});

View file

@ -0,0 +1,430 @@
import shared from '../../../website/common';
import {
generateUser,
} from '../../helpers/common.helper';
import seasonalConfig from '../../../website/common/script/libs/shops-seasonal.config';
describe('shops', () => {
const user = generateUser();
let clock;
afterEach(() => {
if (clock) {
clock.restore();
}
user.achievements.quests = {};
});
describe('market', () => {
const shopCategories = shared.shops.getMarketCategories(user);
it('contains at least the 3 default categories', () => {
expect(shopCategories.length).to.be.greaterThan(2);
});
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'locked', 'purchaseType', 'class'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
});
});
describe('premium hatching potions', () => {
it('contains current scheduled premium hatching potions', async () => {
clock = sinon.useFakeTimers(new Date('2024-04-01'));
const potions = shared.shops.getMarketCategories(user).find(x => x.identifier === 'premiumHatchingPotions');
expect(potions.items.length).to.eql(2);
});
it('does not contain past scheduled premium hatching potions', async () => {
const potions = shared.shops.getMarketCategories(user).find(x => x.identifier === 'premiumHatchingPotions');
expect(potions.items.filter(x => x.key === 'Aquatic' || x.key === 'Celestial').length).to.eql(0);
});
it('returns end date for scheduled premium potions', async () => {
const potions = shared.shops.getMarketCategories(user).find(x => x.identifier === 'premiumHatchingPotions');
potions.items.forEach(potion => {
expect(potion.end).to.exist;
});
});
it('contains unlocked quest premium hatching potions', async () => {
user.achievements.quests = {
bronze: 1,
blackPearl: 1,
};
const potions = shared.shops.getMarketCategories(user).find(x => x.identifier === 'premiumHatchingPotions');
expect(potions.items.filter(x => x.key === 'Bronze' || x.key === 'BlackPearl').length).to.eql(2);
});
it('does not contain locked quest premium hatching potions', async () => {
clock = sinon.useFakeTimers(new Date('2024-04-01'));
const potions = shared.shops.getMarketCategories(user).find(x => x.identifier === 'premiumHatchingPotions');
expect(potions.items.length).to.eql(2);
expect(potions.items.filter(x => x.key === 'Bronze' || x.key === 'BlackPearl').length).to.eql(0);
});
it('does not return end date for quest premium potions', async () => {
user.achievements.quests = {
bronze: 1,
blackPearl: 1,
};
const potions = shared.shops.getMarketCategories(user).find(x => x.identifier === 'premiumHatchingPotions');
potions.items.filter(x => x.key === 'Bronze' || x.key === 'BlackPearl').forEach(potion => {
expect(potion.end).to.not.exist;
});
});
});
it('does not return items with event data', async () => {
shopCategories.forEach(category => {
category.items.forEach(item => {
expect(item.event).to.not.exist;
expect(item.season).to.not.exist;
});
});
});
it('shows relevant non class gear in special category', () => {
const contributor = generateUser({
contributor: {
level: 7,
critical: true,
},
items: {
gear: {
owned: {
weapon_armoire_basicCrossbow: true, // eslint-disable-line camelcase
},
},
},
});
const gearCategories = shared.shops.getMarketGearCategories(contributor);
const specialCategory = gearCategories.find(o => o.identifier === 'none');
expect(specialCategory.items.find(item => item.key === 'weapon_special_1'), 'weapon_special_1');
expect(specialCategory.items.find(item => item.key === 'armor_special_1'), 'armor_special_1');
expect(specialCategory.items.find(item => item.key === 'head_special_1'), 'head_special_1');
expect(specialCategory.items.find(item => item.key === 'shield_special_1'), 'shield_special_1');
expect(specialCategory.items.find(item => item.key === 'weapon_special_critical'), 'weapon_special_critical');
expect(specialCategory.items.find(item => item.key === 'weapon_armoire_basicCrossbow'), 'weapon_armoire_basicCrossbow');// eslint-disable-line camelcase
});
describe('handles seasonal gear', () => {
beforeEach(() => {
clock = sinon.useFakeTimers(new Date('2024-04-01'));
});
it('shows current seasonal gear for warriors', () => {
const warriorItems = shared.shops.getMarketGearCategories(user).find(x => x.identifier === 'warrior').items.filter(x => x.key.indexOf('spring2024') !== -1);
expect(warriorItems.length, 'Warrior seasonal gear').to.eql(4);
});
it('shows current seasonal gear for mages', () => {
const mageItems = shared.shops.getMarketGearCategories(user).find(x => x.identifier === 'wizard').items.filter(x => x.key.indexOf('spring2024') !== -1);
expect(mageItems.length, 'Mage seasonal gear').to.eql(3);
});
it('shows current seasonal gear for healers', () => {
const healerItems = shared.shops.getMarketGearCategories(user).find(x => x.identifier === 'healer').items.filter(x => x.key.indexOf('spring2024') !== -1);
expect(healerItems.length, 'Healer seasonal gear').to.eql(4);
});
it('shows current seasonal gear for rogues', () => {
const rogueItems = shared.shops.getMarketGearCategories(user).find(x => x.identifier === 'rogue').items.filter(x => x.key.indexOf('spring2024') !== -1);
expect(rogueItems.length, 'Rogue seasonal gear').to.eql(4);
});
it('seasonal gear contains end date', () => {
const categories = shared.shops.getMarketGearCategories(user);
categories.forEach(category => {
category.items.filter(x => x.key.indexOf('spring2024') !== -1).forEach(item => {
expect(item.end, item.key).to.exist;
});
});
});
it('only shows gear for the current season', () => {
const categories = shared.shops.getMarketGearCategories(user);
categories.forEach(category => {
const otherSeasons = category.items.filter(item => item.key.indexOf('winter') !== -1 || item.key.indexOf('summer') !== -1 || item.key.indexOf('fall') !== -1);
expect(otherSeasons.length).to.eql(0);
});
});
it('does not show gear from past seasons', () => {
const categories = shared.shops.getMarketGearCategories(user);
categories.forEach(category => {
const otherYears = category.items.filter(item => item.key.indexOf('spring') !== -1 && item.key.indexOf('2024') === -1);
expect(otherYears.length).to.eql(0);
});
});
});
it('does not show gear when it is all owned', () => {
const userWithItems = generateUser({
stats: {
class: 'wizard',
},
items: {
gear: {
owned: {
weapon_wizard_0: true, // eslint-disable-line camelcase
weapon_wizard_1: true, // eslint-disable-line camelcase
weapon_wizard_2: true, // eslint-disable-line camelcase
weapon_wizard_3: true, // eslint-disable-line camelcase
weapon_wizard_4: true, // eslint-disable-line camelcase
weapon_wizard_5: true, // eslint-disable-line camelcase
weapon_wizard_6: true, // eslint-disable-line camelcase
armor_wizard_1: true, // eslint-disable-line camelcase
armor_wizard_2: true, // eslint-disable-line camelcase
armor_wizard_3: true, // eslint-disable-line camelcase
armor_wizard_4: true, // eslint-disable-line camelcase
armor_wizard_5: true, // eslint-disable-line camelcase
head_wizard_1: true, // eslint-disable-line camelcase
head_wizard_2: true, // eslint-disable-line camelcase
head_wizard_3: true, // eslint-disable-line camelcase
head_wizard_4: true, // eslint-disable-line camelcase
head_wizard_5: true, // eslint-disable-line camelcase
},
},
},
});
const shopWizardItems = shared.shops.getMarketGearCategories(userWithItems).find(x => x.identifier === 'wizard').items.filter(x => x.klass === 'wizard' && (x.owned === false || x.owned === undefined));
expect(shopWizardItems.length).to.eql(0);
});
it('shows available gear not yet purchased and previously owned', () => {
const userWithItems = generateUser({
stats: {
class: 'wizard',
},
items: {
gear: {
owned: {
weapon_wizard_0: true, // eslint-disable-line camelcase
weapon_wizard_1: true, // eslint-disable-line camelcase
weapon_wizard_2: true, // eslint-disable-line camelcase
weapon_wizard_3: true, // eslint-disable-line camelcase
weapon_wizard_4: true, // eslint-disable-line camelcase
armor_wizard_1: true, // eslint-disable-line camelcase
armor_wizard_2: true, // eslint-disable-line camelcase
armor_wizard_3: false, // eslint-disable-line camelcase
armor_wizard_4: false, // eslint-disable-line camelcase
head_wizard_1: true, // eslint-disable-line camelcase
head_wizard_2: false, // eslint-disable-line camelcase
head_wizard_3: true, // eslint-disable-line camelcase
head_wizard_4: false, // eslint-disable-line camelcase
head_wizard_5: true, // eslint-disable-line camelcase
},
},
},
});
const shopWizardItems = shared.shops.getMarketGearCategories(userWithItems).find(x => x.identifier === 'wizard').items.filter(x => x.klass === 'wizard' && (x.owned === false || x.owned === undefined));
expect(shopWizardItems.find(item => item.key === 'weapon_wizard_5').locked).to.eql(false);
expect(shopWizardItems.find(item => item.key === 'weapon_wizard_6').locked).to.eql(true);
expect(shopWizardItems.find(item => item.key === 'armor_wizard_3').locked).to.eql(false);
expect(shopWizardItems.find(item => item.key === 'armor_wizard_4').locked).to.eql(true);
expect(shopWizardItems.find(item => item.key === 'head_wizard_2').locked).to.eql(false);
expect(shopWizardItems.find(item => item.key === 'head_wizard_4').locked).to.eql(true);
});
});
describe('questShop', () => {
const shopCategories = shared.shops.getQuestShopCategories(user);
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length, category.identifier).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
if (category.identifier === 'bundle') {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'purchaseType', 'class'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
} else {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'locked', 'purchaseType', 'boss', 'class', 'collect', 'drop', 'unlockCondition', 'lvl'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
}
});
});
it('does not return items with event data', async () => {
shopCategories.forEach(category => {
category.items.forEach(item => {
expect(item.event).to.not.exist;
});
});
});
});
describe('timeTravelers', () => {
const shopCategories = shared.shops.getTimeTravelersCategories(user);
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
_.each(category.items, item => {
_.each(['key', 'text', 'value', 'currency', 'locked', 'purchaseType', 'class', 'notes', 'class'], key => {
expect(_.has(item, key)).to.eql(true);
});
});
});
});
it('does not return items with event data', async () => {
shopCategories.forEach(category => {
category.items.forEach(item => {
expect(item.event).to.not.exist;
});
});
});
it('returns pets', () => {
const pets = shopCategories.find(cat => cat.identifier === 'pets').items;
expect(pets.length).to.be.greaterThan(0);
});
it('returns mounts', () => {
const mounts = shopCategories.find(cat => cat.identifier === 'mounts').items;
expect(mounts.length).to.be.greaterThan(0);
});
it('returns quests', () => {
const quests = shopCategories.find(cat => cat.identifier === 'quests').items;
expect(quests.length).to.be.greaterThan(0);
});
it('returns backgrounds', () => {
const backgrounds = shopCategories.find(cat => cat.identifier === 'backgrounds').items;
expect(backgrounds.length).to.be.greaterThan(0);
});
});
describe('customizationShop', () => {
const shopCategories = shared.shops.getCustomizationsShopCategories(user, null);
it('does not return items with event data', async () => {
shopCategories.forEach(category => {
category.items.forEach(item => {
expect(item.event, item.key).to.not.exist;
});
});
});
it('backgrounds category contains end date', () => {
const backgroundCategory = shopCategories.find(cat => cat.identifier === 'backgrounds');
expect(backgroundCategory.end).to.exist;
expect(backgroundCategory.end).to.be.greaterThan(new Date());
});
it('hair color category contains end date', () => {
const colorCategory = shopCategories.find(cat => cat.identifier === 'color');
expect(colorCategory.end).to.exist;
expect(colorCategory.end).to.be.greaterThan(new Date());
});
it('skin category contains end date', () => {
const colorCategory = shopCategories.find(cat => cat.identifier === 'color');
expect(colorCategory.end).to.exist;
expect(colorCategory.end).to.be.greaterThan(new Date());
});
});
describe('seasonalShop', () => {
const shopCategories = shared.shops.getSeasonalShopCategories(user, null, seasonalConfig());
const today = new Date();
it('does not contain an empty category', () => {
_.each(shopCategories, category => {
expect(category.items.length).to.be.greaterThan(0);
});
});
it('does not duplicate identifiers', () => {
const identifiers = Array.from(new Set(shopCategories.map(cat => cat.identifier)));
expect(identifiers.length).to.eql(shopCategories.length);
});
it('does not return items with event data', async () => {
shopCategories.forEach(category => {
category.items.forEach(item => {
expect(item.event, item.key).to.not.exist;
});
});
});
it('items contain required fields', () => {
_.each(shopCategories, category => {
_.each(category.items, item => {
_.each(['key', 'text', 'notes', 'value', 'currency', 'locked', 'purchaseType', 'type'], key => {
expect(_.has(item, key), item.key).to.eql(true);
});
});
});
});
it('items have a valid end date', () => {
shopCategories.forEach(category => {
category.items.forEach(item => {
expect(item.end, item.key).to.be.a('date');
expect(item.end, item.key).to.be.greaterThan(today);
});
});
});
it('items match current season', () => {
const currentSeason = seasonalConfig().currentSeason.toLowerCase();
shopCategories.forEach(category => {
category.items.forEach(item => {
if (item.klass === 'special') {
expect(item.season, item.key).to.eql(currentSeason);
}
});
});
});
});
});

View file

@ -1,11 +1,10 @@
import * as armoireSet from '../../../website/common/script/content/gear/sets/armoire';
import armoireSet from '../../../website/common/script/content/gear/sets/armoire';
describe('armoireSet items', () => {
it('checks if canOwn has the same id', () => {
Object.keys(armoireSet).forEach(type => {
Object.keys(armoireSet[type]).forEach(itemKey => {
const ownedKey = `${type}_armoire_${itemKey}`;
expect(armoireSet[type][itemKey].canOwn({
items: {
gear: {

View file

@ -49,7 +49,7 @@ describe('shared.ops.buy', () => {
}
});
it('recovers 15 hp', async () => {
it('buys health potion', async () => {
user.stats.hp = 30;
await buy(user, { params: { key: 'potion' } }, analytics);
expect(user.stats.hp).to.eql(45);

View file

@ -17,9 +17,7 @@ function getFullArmoire () {
_.each(content.gearTypes, type => {
_.each(content.gear.tree[type].armoire, gearObject => {
if (gearObject.released) {
fullArmoire[gearObject.key] = true;
}
fullArmoire[gearObject.key] = true;
});
});

View file

@ -22,6 +22,7 @@ async function buyGear (user, req, analytics) {
describe('shared.ops.buyMarketGear', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
user = generateUser({
@ -54,6 +55,10 @@ describe('shared.ops.buyMarketGear', () => {
shared.fns.predictableRandom.restore();
shared.onboarding.checkOnboardingStatus.restore();
analytics.track.restore();
if (clock) {
clock.restore();
}
});
context('Gear', () => {
@ -184,30 +189,28 @@ describe('shared.ops.buyMarketGear', () => {
});
// TODO after user.ops.equip is done
xit('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', async () => {
it('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', async () => {
user.stats.gp = 100;
user.preferences.autoEquip = true;
await buyGear(user, { params: { key: 'shield_warrior_1' } });
user.ops.equip({ params: { key: 'shield_warrior_1' } });
await buyGear(user, { params: { key: 'weapon_warrior_1' } });
user.ops.equip({ params: { key: 'weapon_warrior_1' } });
user.items.gear.equipped.weapon = 'weapon_warrior_1';
user.items.gear.equipped.shield = 'shield_warrior_1';
user.stats.class = 'wizard';
await buyGear(user, { params: { key: 'weapon_wizard_1' } });
await buyGear(user, { params: { key: 'weapon_wizard_0' } });
expect(user.items.gear.equipped).to.have.property('shield', 'shield_base_0');
expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_1');
expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_0');
});
// TODO after user.ops.equip is done
xit('buyGears two-handed equipment but does not automatically remove sword or shield', async () => {
it('buyGears two-handed equipment but does not automatically remove sword or shield', async () => {
user.stats.gp = 100;
user.preferences.autoEquip = false;
await buyGear(user, { params: { key: 'shield_warrior_1' } });
user.ops.equip({ params: { key: 'shield_warrior_1' } });
await buyGear(user, { params: { key: 'weapon_warrior_1' } });
user.ops.equip({ params: { key: 'weapon_warrior_1' } });
user.items.gear.equipped.weapon = 'weapon_warrior_1';
user.items.gear.equipped.shield = 'shield_warrior_1';
user.stats.class = 'wizard';
await buyGear(user, { params: { key: 'weapon_wizard_1' } });
await buyGear(user, { params: { key: 'weapon_wizard_0' } });
expect(user.items.gear.equipped).to.have.property('shield', 'shield_warrior_1');
expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1');
@ -283,5 +286,40 @@ describe('shared.ops.buyMarketGear', () => {
expect(user.items.gear.owned).to.have.property('shield_armoire_ramHornShield', true);
});
it('buys current seasonal gear', async () => {
user.stats.gp = 200;
clock = sinon.useFakeTimers(new Date('2024-01-01'));
await buyGear(user, { params: { key: 'armor_special_winter2024Warrior' } });
expect(user.items.gear.owned).to.have.property('armor_special_winter2024Warrior', true);
});
it('errors when buying past seasonal gear', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-01'));
user.stats.gp = 200;
try {
await buyGear(user, { params: { key: 'armor_special_winter2023Warrior' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notAvailable'));
expect(user.items.gear.owned).to.not.have.property('armor_special_winter2023Warrior');
}
});
it('errors when buying gear from wrong season', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-01'));
user.stats.gp = 200;
try {
await buyGear(user, { params: { key: 'weapon_special_spring2024Warrior' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notAvailable'));
expect(user.items.gear.owned).to.not.have.property('weapon_special_spring2024Warrior');
}
});
});
});

View file

@ -15,6 +15,7 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyMysterySet', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
user = generateUser({
@ -31,6 +32,9 @@ describe('shared.ops.buyMysterySet', () => {
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
});
context('Mystery Sets', () => {
@ -41,7 +45,7 @@ describe('shared.ops.buyMysterySet', () => {
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.eql(i18n.t('notEnoughHourglasses'));
expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true);
expect(user.items.gear.owned).to.not.have.property('armor_mystery_201501');
}
});
@ -72,6 +76,18 @@ describe('shared.ops.buyMysterySet', () => {
expect(err.message).to.equal(errorMessage('missingKeyParam'));
}
});
it('returns error if the set is not available', async () => {
user.purchased.plan.consecutive.trinkets = 1;
clock = sinon.useFakeTimers(new Date('2024-01-16'));
try {
await buyMysterySet(user, { params: { key: '201501' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.eql(i18n.t('notAvailable'));
expect(user.items.gear.owned).to.not.have.property('armor_mystery_201501');
}
});
});
context('successful purchases', () => {
@ -86,6 +102,16 @@ describe('shared.ops.buyMysterySet', () => {
expect(user.items.gear.owned).to.have.property('head_mystery_301404', true);
expect(user.items.gear.owned).to.have.property('eyewear_mystery_301404', true);
});
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);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('shield_mystery_201601', true);
expect(user.items.gear.owned).to.have.property('head_mystery_201601', true);
});
});
});
});

View file

@ -10,6 +10,7 @@ import { BuyQuestWithGemOperation } from '../../../../website/common/script/ops/
describe('shared.ops.buyQuestGems', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
@ -26,11 +27,13 @@ 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();
});
context('single purchase', () => {
@ -44,7 +47,7 @@ describe('shared.ops.buyQuestGems', () => {
user.pinnedItems.push({ type: 'quests', key: 'gryphon' });
});
it('successfully purchases quest', async () => {
it('successfully purchases pet quest', async () => {
const key = 'gryphon';
await buyQuest(user, { params: { key } });
@ -52,6 +55,61 @@ describe('shared.ops.buyQuestGems', () => {
expect(user.items.quests[key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
});
it('successfully purchases hatching potion quest', async () => {
const key = 'silver';
await buyQuest(user, { params: { key } });
expect(user.items.quests[key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
});
it('successfully purchases seasonal quest', async () => {
const key = 'evilsanta';
await buyQuest(user, { params: { key } });
expect(user.items.quests[key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
});
it('errors if the pet quest is not available', async () => {
const key = 'sabretooth';
try {
await buyQuest(user, { params: { key } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notAvailable'));
expect(user.items.quests[key]).to.eql(undefined);
}
});
it('errors if the hatching potion quest is not available', async () => {
const key = 'ruby';
try {
await buyQuest(user, { params: { key } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notAvailable'));
expect(user.items.quests[key]).to.eql(undefined);
}
});
it('errors if the seasonal quest is not available', async () => {
const key = 'egg';
try {
await buyQuest(user, { params: { key } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notAvailable'));
expect(user.items.quests[key]).to.eql(undefined);
}
});
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 () => {
const key = 'dustbunnies';
user.items.quests[key] = -1;
@ -61,6 +119,7 @@ describe('shared.ops.buyQuestGems', () => {
expect(user.items.quests[key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
});
it('errors if the user has not completed prerequisite quests', async () => {
const key = 'atom3';
user.achievements.quests.atom1 = 1;
@ -73,6 +132,7 @@ describe('shared.ops.buyQuestGems', () => {
expect(user.items.quests[key]).to.eql(undefined);
}
});
it('successfully purchases quest if user has completed all prerequisite quests', async () => {
const key = 'atom3';
user.achievements.quests.atom1 = 1;

View file

@ -1,89 +0,0 @@
import { BuySpellOperation } from '../../../../website/common/script/ops/buy/buySpell';
import {
BadRequest,
NotFound,
NotAuthorized,
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
import {
generateUser,
} from '../../../helpers/common.helper';
import content from '../../../../website/common/script/content/index';
import { errorMessage } from '../../../../website/common/script/libs/errorMessage';
describe('shared.ops.buySpecialSpell', () => {
let user;
const analytics = { track () {} };
async function buySpecialSpell (_user, _req, _analytics) {
const buyOp = new BuySpellOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('throws an error if params.key is missing', async () => {
try {
await buySpecialSpell(user);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(errorMessage('missingKeyParam'));
}
});
it('throws an error if the spell doesn\'t exists', async () => {
try {
await buySpecialSpell(user, {
params: {
key: 'notExisting',
},
});
} catch (err) {
expect(err).to.be.an.instanceof(NotFound);
expect(err.message).to.equal(errorMessage('spellNotFound', { spellId: 'notExisting' }));
}
});
it('throws an error if the user doesn\'t have enough gold', async () => {
user.stats.gp = 1;
try {
await buySpecialSpell(user, {
params: {
key: 'thankyou',
},
});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotEnoughGold'));
}
});
it('buys an item', async () => {
user.stats.gp = 11;
const item = content.special.thankyou;
const [data, message] = await buySpecialSpell(user, {
params: {
key: 'thankyou',
},
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
expect(data).to.eql({
items: user.items,
stats: user.stats,
});
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
});

View file

@ -0,0 +1,172 @@
import { BuySpellOperation } from '../../../../website/common/script/ops/buy/buySpell';
import {
BadRequest,
NotFound,
NotAuthorized,
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
import {
generateUser,
} from '../../../helpers/common.helper';
import content from '../../../../website/common/script/content/index';
import { errorMessage } from '../../../../website/common/script/libs/errorMessage';
describe('shared.ops.buySpecialSpell', () => {
let user;
let clock;
const analytics = { track () {} };
async function buySpecialSpell (_user, _req, _analytics) {
const buyOp = new BuySpellOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
});
it('throws an error if params.key is missing', async () => {
try {
await buySpecialSpell(user);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(errorMessage('missingKeyParam'));
}
});
it('throws an error if the item doesn\'t exists', async () => {
try {
await buySpecialSpell(user, {
params: {
key: 'notExisting',
},
});
} catch (err) {
expect(err).to.be.an.instanceof(NotFound);
expect(err.message).to.equal(errorMessage('spellNotFound', { spellId: 'notExisting' }));
}
});
it('throws an error if the user doesn\'t have enough gold', async () => {
user.stats.gp = 1;
try {
await buySpecialSpell(user, {
params: {
key: 'thankyou',
},
});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotEnoughGold'));
}
});
describe('buying cards', () => {
it('buys a card that is always available', async () => {
user.stats.gp = 11;
const item = content.special.thankyou;
const [data, message] = await buySpecialSpell(user, {
params: {
key: 'thankyou',
},
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
expect(data).to.eql({
items: user.items,
stats: user.stats,
});
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 () => {
user.stats.gp = 11;
const item = content.special.nye;
clock = sinon.useFakeTimers(new Date('2024-01-01'));
const [data, message] = await buySpecialSpell(user, {
params: {
key: 'nye',
},
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.nye).to.equal(1);
expect(data).to.eql({
items: user.items,
stats: user.stats,
});
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 () => {
user.stats.gp = 11;
clock = sinon.useFakeTimers(new Date('2024-06-01'));
try {
await buySpecialSpell(user, {
params: {
key: 'nye',
},
});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('cannotBuyItem'));
}
});
});
describe('buying spells', () => {
it('buys a spell if it is currently available', async () => {
user.stats.gp = 16;
clock = sinon.useFakeTimers(new Date('2024-06-22'));
const item = content.special.seafoam;
const [data, message] = await buySpecialSpell(user, {
params: {
key: 'seafoam',
},
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.seafoam).to.equal(1);
expect(data).to.eql({
items: user.items,
stats: user.stats,
});
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 () => {
user.stats.gp = 50;
clock = sinon.useFakeTimers(new Date('2024-01-22'));
try {
await buySpecialSpell(user, {
params: {
key: 'seafoam',
},
});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('cannotBuyItem'));
}
});
});
});

View file

@ -15,6 +15,7 @@ import {
describe('shared.ops.purchase', () => {
const SEASONAL_FOOD = moment().isBefore('2021-11-02T20:00-04:00') ? 'Candy_Base' : 'Meat';
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
@ -25,11 +26,13 @@ describe('shared.ops.purchase', () => {
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();
});
context('failure conditions', () => {
@ -82,13 +85,77 @@ describe('shared.ops.purchase', () => {
it('returns error when user does not have enough gems to buy an item', async () => {
try {
await purchase(user, { params: { type: 'gear', key: 'headAccessory_special_wolfEars' } });
await purchase(user, { params: { type: 'gear', key: 'shield_special_winter2019Healer' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notEnoughGems'));
}
});
it('returns error when gear is not available', async () => {
try {
await purchase(user, { params: { type: 'gear', key: 'shield_special_spring2019Healer' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAvailable'));
}
});
it('returns an error when purchasing current seasonal gear', async () => {
user.balance = 2;
try {
await purchase(user, { params: { type: 'gear', key: 'shield_special_winter2024Healer' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAvailable'));
}
});
it('returns error when hatching potion is not available', async () => {
try {
await purchase(user, { params: { type: 'hatchingPotions', key: 'PolkaDot' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAvailable'));
}
});
it('returns error when quest for hatching potion was not yet completed', async () => {
try {
await purchase(user, { params: { type: 'hatchingPotions', key: 'BlackPearl' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAvailable'));
}
});
it('returns error when quest for egg was not yet completed', async () => {
try {
await purchase(user, { params: { type: 'eggs', key: 'Octopus' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAvailable'));
}
});
it('returns error when bundle is not available', async () => {
try {
await purchase(user, { params: { type: 'bundles', key: 'forestFriends' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('notAvailable'));
}
});
it('returns error when gear is not gem purchasable', async () => {
try {
await purchase(user, { params: { type: 'gear', key: 'shield_healer_3' } });
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('messageNotAvailable'));
}
});
it('returns error when item is not found', async () => {
const params = { key: 'notExisting', type: 'food' };
@ -99,44 +166,6 @@ describe('shared.ops.purchase', () => {
expect(err.message).to.equal(i18n.t('contentKeyNotFound', params));
}
});
it('returns error when user supplies a non-numeric quantity', async () => {
const type = 'eggs';
const key = 'Wolf';
try {
await purchase(user, { params: { type, key }, quantity: 'jamboree' }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
}
});
it('returns error when user supplies a negative quantity', async () => {
const type = 'eggs';
const key = 'Wolf';
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: -2 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
}
});
it('returns error when user supplies a decimal quantity', async () => {
const type = 'eggs';
const key = 'Wolf';
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: 2.9 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
}
});
});
context('successful purchase', () => {
@ -150,7 +179,7 @@ describe('shared.ops.purchase', () => {
user.pinnedItems.push({ type: 'eggs', key: 'Wolf' });
user.pinnedItems.push({ type: 'hatchingPotions', key: 'Base' });
user.pinnedItems.push({ type: 'food', key: SEASONAL_FOOD });
user.pinnedItems.push({ type: 'gear', key: 'headAccessory_special_tigerEars' });
user.pinnedItems.push({ type: 'gear', key: 'shield_special_winter2019Healer' });
user.pinnedItems.push({ type: 'bundles', key: 'featheredFriends' });
});
@ -185,9 +214,9 @@ describe('shared.ops.purchase', () => {
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
});
it('purchases gear', async () => {
it('purchases past seasonal gear', async () => {
const type = 'gear';
const key = 'headAccessory_special_tigerEars';
const key = 'shield_special_winter2019Healer';
await purchase(user, { params: { type, key } });
@ -195,9 +224,39 @@ describe('shared.ops.purchase', () => {
expect(pinnedGearUtils.removeItemByPath.calledOnce).to.equal(true);
});
it('purchases hatching potion', async () => {
const type = 'hatchingPotions';
const key = 'Peppermint';
await purchase(user, { params: { type, key } });
expect(user.items.hatchingPotions[key]).to.eql(1);
});
it('purchases hatching potion if user completed quest', async () => {
const type = 'hatchingPotions';
const key = 'Bronze';
user.achievements.quests.bronze = 1;
await purchase(user, { params: { type, key } });
expect(user.items.hatchingPotions[key]).to.eql(1);
});
it('purchases egg if user completed quest', async () => {
const type = 'eggs';
const key = 'Deer';
user.achievements.quests.ghost_stag = 1;
await purchase(user, { params: { type, key } });
expect(user.items.eggs[key]).to.eql(1);
});
it('purchases quest bundles', async () => {
const startingBalance = user.balance;
const clock = sandbox.useFakeTimers(moment('2024-03-20').valueOf());
clock.restore();
clock = sandbox.useFakeTimers(moment('2022-03-10').valueOf());
const type = 'bundles';
const key = 'cuddleBuddies';
const price = 1.75;
@ -216,7 +275,6 @@ describe('shared.ops.purchase', () => {
expect(user.balance).to.equal(startingBalance - price);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
clock.restore();
});
});
@ -257,5 +315,43 @@ describe('shared.ops.purchase', () => {
expect(user.items[type][key]).to.equal(2);
});
it('returns error when user supplies a non-numeric quantity', async () => {
const type = 'eggs';
const key = 'Wolf';
try {
await purchase(user, { params: { type, key }, quantity: 'jamboree' }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
}
});
it('returns error when user supplies a negative quantity', async () => {
const type = 'eggs';
const key = 'Wolf';
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: -2 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
}
});
it('returns error when user supplies a decimal quantity', async () => {
const type = 'eggs';
const key = 'Wolf';
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: 2.9 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
}
});
});
});

View file

@ -2,14 +2,17 @@ import get from 'lodash/get';
import unlock from '../../../website/common/script/ops/unlock';
import i18n from '../../../website/common/script/i18n';
import { generateUser } from '../../helpers/common.helper';
import { NotAuthorized, BadRequest } from '../../../website/common/script/libs/errors';
import {
NotAuthorized,
BadRequest,
} from '../../../website/common/script/libs/errors';
describe('shared.ops.unlock', () => {
let user;
const unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie';
let clock;
const unlockPath = 'shirt.convict,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie';
const unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars';
const backgroundUnlockPath = 'background.giant_florals';
const backgroundSetUnlockPath = 'background.archery_range,background.giant_florals,background.rainbows_end';
const hairUnlockPath = 'hair.color.rainbow,hair.color.yellow,hair.color.green,hair.color.purple,hair.color.blue,hair.color.TRUred';
const facialHairUnlockPath = 'hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3';
const usersStartingGems = 50 / 4;
@ -17,6 +20,11 @@ describe('shared.ops.unlock', () => {
beforeEach(() => {
user = generateUser();
user.balance = usersStartingGems;
clock = sandbox.useFakeTimers(new Date('2024-04-10'));
});
afterEach(() => {
clock.restore();
});
it('returns an error when path is not provided', async () => {
@ -31,7 +39,9 @@ describe('shared.ops.unlock', () => {
it('does not unlock lost gear', async () => {
user.items.gear.owned.headAccessory_special_bearEars = false;
await unlock(user, { query: { path: 'items.gear.owned.headAccessory_special_bearEars' } });
await unlock(user, {
query: { path: 'items.gear.owned.headAccessory_special_bearEars' },
});
expect(user.balance).to.equal(usersStartingGems);
});
@ -95,7 +105,9 @@ describe('shared.ops.unlock', () => {
it('returns an error if gear is not from the animal set', async () => {
try {
await unlock(user, { query: { path: 'items.gear.owned.back_mystery_202004' } });
await unlock(user, {
query: { path: 'items.gear.owned.back_mystery_202004' },
});
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidUnlockSet'));
@ -153,7 +165,6 @@ describe('shared.ops.unlock', () => {
await unlock(user, { query: { path: partialUnlockPaths[4] } });
await unlock(user, { query: { path: partialUnlockPaths[5] } });
await unlock(user, { query: { path: partialUnlockPaths[6] } });
await unlock(user, { query: { path: partialUnlockPaths[7] } });
await unlock(user, { query: { path: unlockPath } });
});
@ -163,7 +174,9 @@ describe('shared.ops.unlock', () => {
await unlock(user, { query: { path: backgroundUnlockPath } });
const afterBalance = user.balance;
const response = await unlock(user, { query: { path: backgroundUnlockPath } });
const response = await unlock(user, {
query: { path: backgroundUnlockPath },
});
expect(user.balance).to.equal(afterBalance); // do not bill twice
expect(response.message).to.not.exist;
@ -176,7 +189,9 @@ describe('shared.ops.unlock', () => {
await unlock(user, { query: { path: backgroundUnlockPath } }); // unlock
const afterBalance = user.balance;
await unlock(user, { query: { path: backgroundUnlockPath } }); // equip
const response = await unlock(user, { query: { path: backgroundUnlockPath } });
const response = await unlock(user, {
query: { path: backgroundUnlockPath },
});
expect(user.balance).to.equal(afterBalance); // do not bill twice
expect(response.message).to.not.exist;
@ -192,8 +207,9 @@ describe('shared.ops.unlock', () => {
individualPaths.forEach(path => {
expect(get(user.purchased, path)).to.be.true;
});
expect(Object.keys(user.purchased.shirt).length)
.to.equal(initialShirts + individualPaths.length);
expect(Object.keys(user.purchased.shirt).length).to.equal(
initialShirts + individualPaths.length,
);
expect(user.balance).to.equal(usersStartingGems - 1.25);
});
@ -208,8 +224,9 @@ describe('shared.ops.unlock', () => {
individualPaths.forEach(path => {
expect(get(user.purchased, path)).to.be.true;
});
expect(Object.keys(user.purchased.hair.color).length)
.to.equal(initialHairColors + individualPaths.length);
expect(Object.keys(user.purchased.hair.color).length).to.equal(
initialHairColors + individualPaths.length,
);
expect(user.balance).to.equal(usersStartingGems - 1.25);
});
@ -219,21 +236,28 @@ describe('shared.ops.unlock', () => {
const initialMustache = Object.keys(user.purchased.hair.mustache).length;
const initialBeard = Object.keys(user.purchased.hair.mustache).length;
const [, message] = await unlock(user, { query: { path: facialHairUnlockPath } });
const [, message] = await unlock(user, {
query: { path: facialHairUnlockPath },
});
expect(message).to.equal(i18n.t('unlocked'));
const individualPaths = facialHairUnlockPath.split(',');
individualPaths.forEach(path => {
expect(get(user.purchased, path)).to.be.true;
});
expect(Object.keys(user.purchased.hair.mustache).length + Object.keys(user.purchased.hair.beard).length) // eslint-disable-line max-len
expect(
Object.keys(user.purchased.hair.mustache).length
+ Object.keys(user.purchased.hair.beard).length,
) // eslint-disable-line max-len
.to.equal(initialMustache + initialBeard + individualPaths.length);
expect(user.balance).to.equal(usersStartingGems - 1.25);
});
it('unlocks a full set of gear', async () => {
const initialGear = Object.keys(user.items.gear.owned).length;
const [, message] = await unlock(user, { query: { path: unlockGearSetPath } });
const [, message] = await unlock(user, {
query: { path: unlockGearSetPath },
});
expect(message).to.equal(i18n.t('unlocked'));
@ -241,32 +265,21 @@ describe('shared.ops.unlock', () => {
individualPaths.forEach(path => {
expect(get(user, path)).to.be.true;
});
expect(Object.keys(user.items.gear.owned).length)
.to.equal(initialGear + individualPaths.length);
expect(Object.keys(user.items.gear.owned).length).to.equal(
initialGear + individualPaths.length,
);
expect(user.balance).to.equal(usersStartingGems - 1.25);
});
it('unlocks a full set of backgrounds', async () => {
const initialBackgrounds = Object.keys(user.purchased.background).length;
const [, message] = await unlock(user, { query: { path: backgroundSetUnlockPath } });
expect(message).to.equal(i18n.t('unlocked'));
const individualPaths = backgroundSetUnlockPath.split(',');
individualPaths.forEach(path => {
expect(get(user.purchased, path)).to.be.true;
});
expect(Object.keys(user.purchased.background).length)
.to.equal(initialBackgrounds + individualPaths.length);
expect(user.balance).to.equal(usersStartingGems - 3.75);
});
it('unlocks an item (appearance)', async () => {
const path = unlockPath.split(',')[0];
const initialShirts = Object.keys(user.purchased.shirt).length;
const [, message] = await unlock(user, { query: { path } });
expect(message).to.equal(i18n.t('unlocked'));
expect(Object.keys(user.purchased.shirt).length).to.equal(initialShirts + 1);
expect(Object.keys(user.purchased.shirt).length).to.equal(
initialShirts + 1,
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
});
@ -279,7 +292,9 @@ describe('shared.ops.unlock', () => {
const [, message] = await unlock(user, { query: { path } });
expect(message).to.equal(i18n.t('unlocked'));
expect(Object.keys(user.purchased.hair.color).length).to.equal(initialColorHair + 1);
expect(Object.keys(user.purchased.hair.color).length).to.equal(
initialColorHair + 1,
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
});
@ -295,8 +310,12 @@ describe('shared.ops.unlock', () => {
expect(message).to.equal(i18n.t('unlocked'));
expect(Object.keys(user.purchased.hair.mustache).length).to.equal(initialMustache + 1);
expect(Object.keys(user.purchased.hair.beard).length).to.equal(initialBeard);
expect(Object.keys(user.purchased.hair.mustache).length).to.equal(
initialMustache + 1,
);
expect(Object.keys(user.purchased.hair.beard).length).to.equal(
initialBeard,
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
@ -315,11 +334,24 @@ describe('shared.ops.unlock', () => {
it('unlocks an item (background)', async () => {
const initialBackgrounds = Object.keys(user.purchased.background).length;
const [, message] = await unlock(user, { query: { path: backgroundUnlockPath } });
const [, message] = await unlock(user, {
query: { path: backgroundUnlockPath },
});
expect(message).to.equal(i18n.t('unlocked'));
expect(Object.keys(user.purchased.background).length).to.equal(initialBackgrounds + 1);
expect(Object.keys(user.purchased.background).length).to.equal(
initialBackgrounds + 1,
);
expect(get(user.purchased, backgroundUnlockPath)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 1.75);
});
it('handles an invalid hair path gracefully', async () => {
try {
await unlock(user, { query: { path: 'hair.invalid' } });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidUnlockSet'));
}
});
});

View file

@ -1,27 +0,0 @@
/* eslint-disable prefer-template, no-shadow, func-names, import/no-commonjs */
const expect = require('expect.js');
module.exports.addCustomMatchers = function () {
const { Assertion } = expect;
Assertion.prototype.toHaveGP = function (gp) {
const actual = this.obj.stats.gp;
return this.assert(actual === gp, () => 'expected user to have ' + gp + ' gp, but got ' + actual, () => 'expected user to not have ' + gp + ' gp');
};
Assertion.prototype.toHaveHP = function (hp) {
const actual = this.obj.stats.hp;
return this.assert(actual === hp, () => 'expected user to have ' + hp + ' hp, but got ' + actual, () => 'expected user to not have ' + hp + ' hp');
};
Assertion.prototype.toHaveExp = function (exp) {
const actual = this.obj.stats.exp;
return this.assert(actual === exp, () => 'expected user to have ' + exp + ' experience points, but got ' + actual, () => 'expected user to not have ' + exp + ' experience points');
};
Assertion.prototype.toHaveLevel = function (lvl) {
const actual = this.obj.stats.lvl;
return this.assert(actual === lvl, () => 'expected user to be level ' + lvl + ', but got ' + actual, () => 'expected user to not be level ' + lvl);
};
Assertion.prototype.toHaveMaxMP = function (mp) {
const actual = this.obj._statsComputed.maxMP;
return this.assert(actual === mp, () => 'expected user to have ' + mp + ' max mp, but got ' + actual, () => 'expected user to not have ' + mp + ' max mp');
};
};

View file

@ -0,0 +1,83 @@
/* eslint-disable global-require */
import forEach from 'lodash/forEach';
import {
expectValidTranslationString,
} from '../helpers/content.helper';
function makeArmoireIitemList () {
const armoire = require('../../website/common/script/content/gear/sets/armoire').default;
const items = [];
items.push(...Object.values(armoire.armor));
items.push(...Object.values(armoire.body));
items.push(...Object.values(armoire.eyewear));
items.push(...Object.values(armoire.head));
items.push(...Object.values(armoire.headAccessory));
items.push(...Object.values(armoire.shield));
items.push(...Object.values(armoire.weapon));
return items;
}
describe('armoire', () => {
let clock;
beforeEach(() => {
delete require.cache[require.resolve('../../website/common/script/content/gear/sets/armoire')];
});
afterEach(() => {
clock.restore();
});
it('does not return unreleased gear', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-02'));
const items = makeArmoireIitemList();
expect(items.length).to.equal(377);
expect(items.filter(item => item.set === 'pottersSet' || item.set === 'optimistSet' || item.set === 'schoolUniform')).to.be.an('array').that.is.empty;
});
it('released gear has all required properties', async () => {
clock = sinon.useFakeTimers(new Date('2024-05-08'));
const items = makeArmoireIitemList();
expect(items.length).to.equal(396);
forEach(items, item => {
if (item.set !== undefined) {
expect(item.set, item.key).to.be.a('string');
expect(item.set, item.key).to.not.be.empty;
}
expectValidTranslationString(item.text);
expect(item.value, item.key).to.be.a('number');
});
});
it('releases gear when appropriate', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-01T00:00:00.000Z'));
const items = makeArmoireIitemList();
expect(items.length).to.equal(377);
clock.restore();
delete require.cache[require.resolve('../../website/common/script/content/gear/sets/armoire')];
clock = sinon.useFakeTimers(new Date('2024-01-08'));
const januaryItems = makeArmoireIitemList();
expect(januaryItems.length).to.equal(381);
clock.restore();
delete require.cache[require.resolve('../../website/common/script/content/gear/sets/armoire')];
clock = sinon.useFakeTimers(new Date('2024-02-07'));
const januaryItems2 = makeArmoireIitemList();
expect(januaryItems2.length).to.equal(381);
clock.restore();
delete require.cache[require.resolve('../../website/common/script/content/gear/sets/armoire')];
clock = sinon.useFakeTimers(new Date('2024-02-07T16:00:00.000Z'));
const febuaryItems = makeArmoireIitemList();
expect(febuaryItems.length).to.equal(384);
});
it('sets have at least 2 items', () => {
const armoire = makeArmoireIitemList();
const setMap = {};
forEach(armoire, item => {
if (setMap[item.set] === undefined) {
setMap[item.set] = 0;
}
setMap[item.set] += 1;
});
Object.keys(setMap).forEach(set => {
expect(setMap[set], set).to.be.at.least(2);
});
});
});

View file

@ -0,0 +1,40 @@
import { getRepeatingEvents } from '../../website/common/script/content/constants/events';
describe('events', () => {
let clock;
afterEach(() => {
if (clock) {
clock.restore();
}
});
it('returns empty array when no events are active', () => {
clock = sinon.useFakeTimers(new Date('2024-01-06'));
const events = getRepeatingEvents();
expect(events).to.be.empty;
});
it('returns events when active', () => {
clock = sinon.useFakeTimers(new Date('2024-01-31'));
const events = getRepeatingEvents();
expect(events).to.have.length(1);
expect(events[0].key).to.equal('birthday');
expect(events[0].end).to.be.greaterThan(new Date());
expect(events[0].start).to.be.lessThan(new Date());
});
it('returns nye event at beginning of the year', () => {
clock = sinon.useFakeTimers(new Date('2025-01-01'));
const events = getRepeatingEvents();
expect(events).to.have.length(1);
expect(events[0].key).to.equal('nye');
});
it('returns nye event at end of the year', () => {
clock = sinon.useFakeTimers(new Date('2024-12-30'));
const events = getRepeatingEvents();
expect(events).to.have.length(1);
expect(events[0].key).to.equal('nye');
});
});

94
test/content/food.test.js Normal file
View file

@ -0,0 +1,94 @@
/* eslint-disable global-require */
import {
each,
} from 'lodash';
import {
expectValidTranslationString,
} from '../helpers/content.helper';
import content from '../../website/common/script/content';
describe('food', () => {
let clock;
afterEach(() => {
if (clock) {
clock.restore();
}
delete require.cache[require.resolve('../../website/common/script/content')];
});
describe('all', () => {
it('contains basic information about each food item', () => {
each(content.food, (foodItem, key) => {
if (foodItem.key === 'Saddle') {
expectValidTranslationString(foodItem.sellWarningNote);
} else {
expectValidTranslationString(foodItem.textA);
expectValidTranslationString(foodItem.textThe);
expect(foodItem.target).to.be.a('string');
}
expectValidTranslationString(foodItem.text);
expectValidTranslationString(foodItem.notes);
expect(foodItem.canBuy).to.be.a('function');
expect(foodItem.value).to.be.a('number');
expect(foodItem.key).to.equal(key);
});
});
it('sets canDrop for normal food if there is no food season', () => {
clock = sinon.useFakeTimers(new Date(2024, 5, 8));
const datedContent = require('../../website/common/script/content').default;
each(datedContent.food, foodItem => {
if (foodItem.key.indexOf('Cake') === -1 && foodItem.key.indexOf('Candy_') === -1 && foodItem.key.indexOf('Pie_') === -1 && foodItem.key !== 'Saddle') {
expect(foodItem.canDrop).to.equal(true);
} else {
expect(foodItem.canDrop).to.equal(false);
}
});
});
it('sets canDrop for candy if it is candy season', () => {
clock = sinon.useFakeTimers(new Date(2024, 9, 31));
const datedContent = require('../../website/common/script/content').default;
each(datedContent.food, foodItem => {
if (foodItem.key.indexOf('Candy_') !== -1) {
expect(foodItem.canDrop).to.equal(true);
} else {
expect(foodItem.canDrop).to.equal(false);
}
});
});
it('sets canDrop for cake if it is cake season', () => {
clock = sinon.useFakeTimers(new Date(2024, 0, 31));
const datedContent = require('../../website/common/script/content').default;
each(datedContent.food, foodItem => {
if (foodItem.key.indexOf('Cake_') !== -1) {
expect(foodItem.canDrop).to.equal(true);
} else {
expect(foodItem.canDrop).to.equal(false);
}
});
});
it('sets canDrop for pie if it is pie season', () => {
clock = sinon.useFakeTimers(new Date(2024, 2, 14));
const datedContent = require('../../website/common/script/content').default;
each(datedContent.food, foodItem => {
if (foodItem.key.indexOf('Pie_') !== -1) {
expect(foodItem.canDrop).to.equal(true);
} else {
expect(foodItem.canDrop).to.equal(false);
}
});
});
});
it('sets correct values for saddles', () => {
const saddle = content.food.Saddle;
expect(saddle.canBuy).to.be.a('function');
expect(saddle.value).to.equal(5);
expect(saddle.key).to.equal('Saddle');
expect(saddle.canDrop).to.equal(false);
});
});

View file

@ -4,6 +4,8 @@ import {
expectValidTranslationString,
} from '../helpers/content.helper';
import { CLASSES } from '../../website/common/script/content/constants';
import gearData from '../../website/common/script/content/gear';
import * as backerGear from '../../website/common/script/content/gear/sets/special/special-backer';
import * as contributorGear from '../../website/common/script/content/gear/sets/special/special-contributor';
@ -17,35 +19,48 @@ describe('Gear', () => {
context(`${klass} ${gearType}s`, () => {
it('have a value of at least 0 for each stat', () => {
each(items, gear => {
expect(gear.con).to.be.at.least(0);
expect(gear.int).to.be.at.least(0);
expect(gear.per).to.be.at.least(0);
expect(gear.str).to.be.at.least(0);
expect(gear.con, gear.key).to.be.at.least(0);
expect(gear.int, gear.key).to.be.at.least(0);
expect(gear.per, gear.key).to.be.at.least(0);
expect(gear.str, gear.key).to.be.at.least(0);
});
});
it('have a purchase value of at least 0', () => {
each(items, gear => {
expect(gear.value).to.be.at.least(0);
expect(gear.value, gear.key).to.be.at.least(0);
});
});
it('has a canBuy function', () => {
each(items, gear => {
expect(gear.canBuy).to.be.a('function');
expect(gear.canBuy, gear.key).to.be.a('function');
});
});
it('have valid translation strings for text and notes', () => {
each(items, gear => {
expectValidTranslationString(gear.text);
expectValidTranslationString(gear.notes);
expectValidTranslationString(gear.text, gear.key);
expectValidTranslationString(gear.notes, gear.key);
});
});
it('has a set attribue', () => {
each(items, gear => {
expect(gear.set).to.exist;
expect(gear.set, gear.key).to.exist;
});
});
it('has a valid value for klass or specialClass', () => {
const validClassValues = CLASSES + ['base', 'mystery', 'armoire'];
each(items, gear => {
const field = gear.klass === 'special' ? gear.specialClass : gear.klass;
if (gear.klass === 'special' && field === undefined) {
// some special gear doesn't have a klass
return;
}
expect(field, gear.key).to.exist;
expect(validClassValues, gear.key).to.include(field);
});
});
});

View file

@ -5,27 +5,33 @@ import {
expectValidTranslationString,
} from '../helpers/content.helper';
import * as hatchingPotions from '../../website/common/script/content/hatching-potions';
import { all } from '../../website/common/script/content/hatching-potions';
describe('hatchingPotions', () => {
describe('all', () => {
it('is a combination of drop, premium, and wacky potions', () => {
const dropNumber = Object.keys(hatchingPotions.drops).length;
const premiumNumber = Object.keys(hatchingPotions.premium).length;
const wackyNumber = Object.keys(hatchingPotions.wacky).length;
const allNumber = Object.keys(hatchingPotions.all).length;
let clock;
expect(allNumber).to.be.greaterThan(0);
expect(allNumber).to.equal(dropNumber + premiumNumber + wackyNumber);
});
afterEach(() => {
if (clock) {
clock.restore();
}
});
it('contains basic information about each potion', () => {
each(hatchingPotions.all, (potion, key) => {
expectValidTranslationString(potion.text);
expectValidTranslationString(potion.notes);
expect(potion.canBuy).to.be.a('function');
expect(potion.value).to.be.a('number');
expect(potion.key).to.equal(key);
const potionTypes = [
'drops',
'quests',
'premium',
'wacky',
];
potionTypes.forEach(potionType => {
describe(potionType, () => {
it('contains basic information about each potion', () => {
each(all, (potion, key) => {
expectValidTranslationString(potion.text);
expectValidTranslationString(potion.notes);
expect(potion.canBuy).to.be.a('function');
expect(potion.value).to.be.a('number');
expect(potion.key).to.equal(key);
});
});
});
});

View file

@ -0,0 +1,271 @@
// eslint-disable-next-line max-len
import moment from 'moment';
import nconf from 'nconf';
import {
getAllScheduleMatchingGroups, clearCachedMatchers, MONTHLY_SCHEDULE, GALA_SCHEDULE,
} from '../../website/common/script/content/constants/schedule';
import QUEST_PETS from '../../website/common/script/content/quests/pets';
import QUEST_HATCHINGPOTIONS from '../../website/common/script/content/quests/potions';
import QUEST_BUNDLES from '../../website/common/script/content/bundles';
import { premium } from '../../website/common/script/content/hatching-potions';
import SPELLS from '../../website/common/script/content/spells';
import QUEST_SEASONAL from '../../website/common/script/content/quests/seasonal';
function validateMatcher (matcher, checkedDate) {
expect(matcher.end).to.be.a('date');
expect(matcher.end).to.be.greaterThan(checkedDate);
}
describe('Content Schedule', () => {
let switchoverTime;
beforeEach(() => {
switchoverTime = nconf.get('CONTENT_SWITCHOVER_TIME_OFFSET') || 0;
clearCachedMatchers();
});
it('assembles scheduled items on january 15th', () => {
const date = new Date('2024-01-15');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('assembles scheduled items on january 31th', () => {
const date = new Date('2024-01-31');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('assembles scheduled items on march 2nd', () => {
const date = new Date('2024-03-02');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('assembles scheduled items on march 22st', () => {
const date = new Date('2024-03-22');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('assembles scheduled items on october 7th', () => {
const date = new Date('2024-10-07');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('assembles scheduled items on november 1th', () => {
const date = new Date('2024-11-01');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('assembles scheduled items on december 20th', () => {
const date = new Date('2024-12-20');
const matchers = getAllScheduleMatchingGroups(date);
for (const key in matchers) {
if (matchers[key]) {
validateMatcher(matchers[key], date);
}
}
});
it('sets the end date if its in the same month', () => {
const date = new Date('2024-04-03');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.backgrounds.end).to.eql(moment.utc(`2024-04-07T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date if its in the next day', () => {
const date = new Date('2024-05-06T14:00:00.000Z');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.backgrounds.end).to.eql(moment.utc(`2024-05-07T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date if its on the release day', () => {
const date = new Date('2024-05-07T07:00:00.000Z');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.backgrounds.end).to.eql(moment.utc(`2024-06-07T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date if its next month', () => {
const date = new Date('2024-05-20T01:00:00.000Z');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.backgrounds.end).to.eql(moment.utc(`2024-06-07T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date for a gala', () => {
const date = new Date('2024-05-20');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2024-06-21T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('contains content for repeating events', () => {
const date = new Date('2024-04-15');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.premiumHatchingPotions).to.exist;
expect(matchers.premiumHatchingPotions.items.length).to.equal(4);
expect(matchers.premiumHatchingPotions.items.indexOf('Garden')).to.not.equal(-1);
expect(matchers.premiumHatchingPotions.items.indexOf('Porcelain')).to.not.equal(-1);
});
describe('only contains valid keys for', () => {
it('pet quests', () => {
const petKeys = Object.keys(QUEST_PETS);
Object.keys(MONTHLY_SCHEDULE).forEach(key => {
const petQuests = MONTHLY_SCHEDULE[key][14].find(item => item.type === 'petQuests');
for (const petQuest of petQuests.items) {
expect(petQuest).to.be.a('string');
expect(petKeys).to.include(petQuest);
}
});
});
it('hatchingpotion quests', () => {
const potionKeys = Object.keys(QUEST_HATCHINGPOTIONS);
Object.keys(MONTHLY_SCHEDULE).forEach(key => {
const potionQuests = MONTHLY_SCHEDULE[key][14].find(item => item.type === 'hatchingPotionQuests');
for (const potionQuest of potionQuests.items) {
expect(potionQuest).to.be.a('string');
expect(potionKeys).to.include(potionQuest);
}
});
});
it('bundles', () => {
const bundleKeys = Object.keys(QUEST_BUNDLES);
Object.keys(MONTHLY_SCHEDULE).forEach(key => {
const bundles = MONTHLY_SCHEDULE[key][14].find(item => item.type === 'bundles');
for (const bundle of bundles.items) {
expect(bundle).to.be.a('string');
expect(bundleKeys).to.include(bundle);
}
});
});
it('premium hatching potions', () => {
const potionKeys = Object.keys(premium);
Object.keys(MONTHLY_SCHEDULE).forEach(key => {
const monthlyPotions = MONTHLY_SCHEDULE[key][21].find(item => item.type === 'premiumHatchingPotions');
for (const potion of monthlyPotions.items) {
expect(potion).to.be.a('string');
expect(potionKeys).to.include(potion);
}
});
});
it('seasonal quests', () => {
const questKeys = Object.keys(QUEST_SEASONAL);
Object.keys(GALA_SCHEDULE).forEach(key => {
const quests = GALA_SCHEDULE[key].matchers.find(item => item.type === 'seasonalQuests');
for (const quest of quests.items) {
expect(quest).to.be.a('string');
expect(questKeys).to.include(quest);
}
});
});
it('seasonal spells', () => {
const spellKeys = Object.keys(SPELLS.special);
Object.keys(GALA_SCHEDULE).forEach(key => {
const petQuests = GALA_SCHEDULE[key].matchers.find(item => item.type === 'seasonalSpells');
for (const petQuest of petQuests.items) {
expect(petQuest).to.be.a('string');
expect(spellKeys).to.include(petQuest);
}
});
});
});
describe('backgrounds matcher', () => {
it('allows background matching the month for new backgrounds', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey072024')).to.be.true;
});
it('disallows background in the future', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey072025')).to.be.false;
});
it('disallows background for the inverse month for new backgrounds', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey012024')).to.be.false;
});
it('allows background for the inverse month for old backgrounds', () => {
const date = new Date('2024-08-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey022023')).to.be.true;
expect(matcher.match('backgroundkey022021')).to.be.true;
});
it('allows background even yeared backgrounds in first half of year', () => {
const date = new Date('2025-02-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey022024')).to.be.true;
expect(matcher.match('backgroundkey082022')).to.be.true;
});
it('allows background odd yeared backgrounds in second half of year', () => {
const date = new Date('2024-08-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey022023')).to.be.true;
expect(matcher.match('backgroundkey082021')).to.be.true;
});
});
describe('timeTravelers matcher', () => {
it('allows sets matching the month', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).timeTravelers;
expect(matcher.match('202307')).to.be.true;
expect(matcher.match('202207')).to.be.true;
});
it('disallows sets not matching the month', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).timeTravelers;
expect(matcher.match('202306')).to.be.false;
expect(matcher.match('202402')).to.be.false;
});
it('disallows sets from current month', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).timeTravelers;
expect(matcher.match('202407')).to.be.false;
});
it('disallows sets from the future', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('202507')).to.be.false;
});
});
});

View file

@ -0,0 +1,52 @@
import Sinon from 'sinon';
import featuredItems from '../../website/common/script/content/shop-featuredItems';
describe('Shop Featured Items', () => {
let clock;
afterEach(() => {
if (clock !== undefined) {
clock.restore();
clock = undefined;
}
});
describe('Market', () => {
it('contains armoire', () => {
const items = featuredItems.market();
expect(_.find(items, item => item.path === 'armoire')).to.exist;
});
it('contains the current premium hatching potions', () => {
clock = Sinon.useFakeTimers(new Date('2024-04-08'));
const items = featuredItems.market();
expect(_.find(items, item => item.path === 'premiumHatchingPotions.Porcelain')).to.exist;
});
it('is featuring 4 items', () => {
clock = Sinon.useFakeTimers(new Date('2024-02-08'));
const items = featuredItems.market();
expect(items.length).to.eql(4);
});
});
describe('Quest Shop', () => {
it('contains bundle', () => {
clock = Sinon.useFakeTimers(new Date('2024-03-08'));
const items = featuredItems.quests();
expect(_.find(items, item => item.path === 'quests.pinkMarble')).to.exist;
});
it('contains pet quests', () => {
clock = Sinon.useFakeTimers(new Date('2024-04-08'));
const items = featuredItems.quests();
expect(_.find(items, item => item.path === 'quests.frog')).to.exist;
});
it('is featuring 3 items', () => {
clock = Sinon.useFakeTimers(new Date('2024-02-08'));
const items = featuredItems.quests();
expect(items.length).to.eql(3);
});
});
});

View file

@ -0,0 +1,63 @@
import {
generateUser,
} from '../helpers/common.helper';
import spells from '../../website/common/script/content/spells';
import {
expectValidTranslationString,
} from '../helpers/content.helper';
import { TRANSFORMATION_DEBUFFS_LIST } from '../../website/common/script/constants';
// TODO complete the test suite...
describe('shared.ops.spells', () => {
let user;
let target;
beforeEach(() => {
user = generateUser();
target = generateUser();
});
it('all spells have required properties', () => {
for (const category of Object.values(spells)) {
for (const spell of Object.values(category)) {
expectValidTranslationString(spell.text, spell.key);
expectValidTranslationString(spell.notes);
expect(spell.target, spell.key).to.be.oneOf(['self', 'party', 'task', 'tasks', 'user']);
}
}
});
it('all special spells have a working cast method', async () => {
for (const s of Object.values(spells.special)) {
user.items.special[s.key] = 1;
s.cast(user, target, { language: 'en' });
}
});
it('all debuff spells cost 5 gold', () => {
for (const s of Object.values(spells.special)) {
if (s.purchaseType === 'debuffPotion') {
user.stats.gp = 5;
s.cast(user);
expect(user.stats.gp).to.equal(0);
}
}
});
it('all debuff spells remove the buff', () => {
const debuffMapping = {};
Object.keys(TRANSFORMATION_DEBUFFS_LIST).forEach(key => {
debuffMapping[TRANSFORMATION_DEBUFFS_LIST[key]] = key;
});
for (const s of Object.values(spells.special)) {
if (s.purchaseType === 'debuffPotion') {
user.stats.gp = 5;
user.stats.buffs[debuffMapping[s.key]] = true;
expect(user.stats.buffs[debuffMapping[s.key]]).to.equal(true);
s.cast(user);
expect(user.stats.buffs[debuffMapping[s.key]]).to.equal(false);
}
}
});
});

View file

@ -6,23 +6,105 @@ import timeTravelers from '../../website/common/script/content/time-travelers';
describe('time-travelers store', () => {
let user;
let date;
beforeEach(() => {
user = generateUser();
});
it('removes owned sets from the time travelers store', () => {
user.items.gear.owned.head_mystery_201602 = true; // eslint-disable-line camelcase
expect(timeTravelers.timeTravelerStore(user)['201602']).to.not.exist;
expect(timeTravelers.timeTravelerStore(user)['201603']).to.exist;
describe('on january 15th', () => {
beforeEach(() => {
date = new Date('2024-01-15');
});
it('returns the correct gear', () => {
const items = timeTravelers.timeTravelerStore(user, date);
for (const [key] of Object.entries(items)) {
if (key.startsWith('20')) {
expect(key).to.match(/20[0-9]{2}(01|07)/);
}
}
});
it('removes owned sets from the time travelers store', () => {
user.items.gear.owned.head_mystery_201601 = true; // eslint-disable-line camelcase
const items = timeTravelers.timeTravelerStore(user, date);
expect(items['201601']).to.not.exist;
expect(items['201801']).to.exist;
expect(items['202207']).to.exist;
});
it('removes unopened mystery item sets from the time travelers store', () => {
user.purchased = {
plan: {
mysteryItems: ['head_mystery_201601'],
},
};
const items = timeTravelers.timeTravelerStore(user, date);
expect(items['201601']).to.not.exist;
expect(items['201607']).to.exist;
});
});
it('removes unopened mystery item sets from the time travelers store', () => {
user.purchased = {
plan: {
mysteryItems: ['head_mystery_201602'],
},
};
expect(timeTravelers.timeTravelerStore(user)['201602']).to.not.exist;
expect(timeTravelers.timeTravelerStore(user)['201603']).to.exist;
describe('on may 1st', () => {
beforeEach(() => {
date = new Date('2024-05-01');
});
it('returns the correct gear', () => {
const items = timeTravelers.timeTravelerStore(user, date);
for (const [key] of Object.entries(items)) {
if (key.startsWith('20')) {
expect(key).to.match(/20[0-9]{2}(05|11)/);
}
}
});
it('removes owned sets from the time travelers store', () => {
user.items.gear.owned.head_mystery_201705 = true; // eslint-disable-line camelcase
const items = timeTravelers.timeTravelerStore(user, date);
expect(items['201705']).to.not.exist;
expect(items['201805']).to.exist;
expect(items['202211']).to.exist;
});
it('removes unopened mystery item sets from the time travelers store', () => {
user.purchased = {
plan: {
mysteryItems: ['head_mystery_201705'],
},
};
const items = timeTravelers.timeTravelerStore(user, date);
expect(items['201705']).to.not.exist;
expect(items['201611']).to.exist;
});
});
describe('on october 21st', () => {
beforeEach(() => {
date = new Date('2024-10-21');
});
it('returns the correct gear', () => {
const items = timeTravelers.timeTravelerStore(user, date);
for (const [key] of Object.entries(items)) {
if (key.startsWith('20')) {
expect(key).to.match(/20[0-9]{2}(10|04)/);
}
}
});
it('removes owned sets from the time travelers store', () => {
user.items.gear.owned.head_mystery_201810 = true; // eslint-disable-line camelcase
user.items.gear.owned.armor_mystery_201810 = true; // eslint-disable-line camelcase
const items = timeTravelers.timeTravelerStore(user, date);
expect(items['201810']).to.not.exist;
expect(items['201910']).to.exist;
expect(items['202204']).to.exist;
});
it('removes unopened mystery item sets from the time travelers store', () => {
user.purchased = {
plan: {
mysteryItems: ['armor_mystery_201710'],
},
};
const items = timeTravelers.timeTravelerStore(user, date);
expect(items['201710']).to.not.exist;
expect(items['201604']).to.exist;
});
});
});

View file

@ -40,6 +40,7 @@ export function generateRes (options = {}) {
redirect: sandbox.stub(),
render: sandbox.stub(),
send: sandbox.stub(),
sendFile: sandbox.stub(),
sendStatus: sandbox.stub().returnsThis(),
set: sandbox.stub(),
status: sandbox.stub().returnsThis(),

View file

@ -7,13 +7,13 @@ i18n.translations = translations;
export const STRING_ERROR_MSG = /^Error processing the string ".*". Please see Help > Report a Bug.$/;
export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/;
export function expectValidTranslationString (attribute) {
expect(attribute).to.be.a('function');
export function expectValidTranslationString (attribute, contextKey) {
expect(attribute, contextKey).to.be.a('function');
const translatedString = attribute();
expect(translatedString.trim()).to.not.be.empty;
expect(translatedString).to.not.contain('function func(lang)');
expect(translatedString).to.not.eql(STRING_ERROR_MSG);
expect(translatedString).to.not.match(STRING_DOES_NOT_EXIST_MSG);
expect(translatedString.trim(), contextKey).to.not.be.empty;
expect(translatedString, contextKey).to.not.contain('function func(lang)');
expect(translatedString, contextKey).to.not.eql(STRING_ERROR_MSG);
expect(translatedString, contextKey).to.not.match(STRING_DOES_NOT_EXIST_MSG);
}

View file

@ -16,12 +16,12 @@
"@vue/cli-service": "^5.0.8",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
"assert": "^2.1.0",
"axios": "^0.28.0",
"axios-progress-bar": "^1.2.0",
"babel-eslint": "^10.1.0",
"bootstrap": "^4.6.0",
"bootstrap-vue": "^2.23.1",
"chai": "^5.1.0",
"core-js": "^3.33.1",
"dompurify": "^3.0.3",
"eslint": "7.32.0",
@ -30,16 +30,18 @@
"eslint-plugin-vue": "7.20.0",
"habitica-markdown": "^3.0.0",
"hellojs": "^1.20.0",
"inspectpack": "^4.7.1",
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-locales-webpack-plugin": "^1.2.0",
"nconf": "^0.12.1",
"sass": "^1.63.4",
"sass-loader": "^14.1.1",
"sinon": "^17.0.1",
"smartbanner.js": "^1.19.3",
"stopword": "^2.0.8",
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vue": "^2.7.10",
@ -49,11 +51,15 @@
"vue-template-babel-compiler": "^2.0.0",
"vue-template-compiler": "^2.7.10",
"vuedraggable": "^2.24.3",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0",
"webpack": "^5.89.0"
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0"
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.21.0"
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"babel-plugin-lodash": "^3.3.4",
"chai": "^5.1.0",
"inspectpack": "^4.7.1",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.89.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -2120,6 +2126,45 @@
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
},
"node_modules/@sinonjs/commons": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
"integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
"dependencies": {
"type-detect": "4.0.8"
}
},
"node_modules/@sinonjs/fake-timers": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",
"integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==",
"dependencies": {
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@sinonjs/samsam": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz",
"integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==",
"dependencies": {
"@sinonjs/commons": "^2.0.0",
"lodash.get": "^4.4.2",
"type-detect": "^4.0.8"
}
},
"node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
"integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
"dependencies": {
"type-detect": "4.0.8"
}
},
"node_modules/@sinonjs/text-encoding": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz",
"integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ=="
},
"node_modules/@soda/friendly-errors-webpack-plugin": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@soda/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-1.8.1.tgz",
@ -3601,10 +3646,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
@ -3683,9 +3741,9 @@
}
},
"node_modules/axios": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.28.0.tgz",
"integrity": "sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.28.1.tgz",
"integrity": "sha512-iUcGA5a7p0mVb4Gm/sy+FSECNkPFT4y7wt6OM/CDpO/OnNCvSs3PoMG8ibrC9jRoGYU0gUK5pXVC4NPXq6lHRQ==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@ -3759,6 +3817,19 @@
"object.assign": "^4.1.0"
}
},
"node_modules/babel-plugin-lodash": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/babel-plugin-lodash/-/babel-plugin-lodash-3.3.4.tgz",
"integrity": "sha512-yDZLjK7TCkWl1gpBeBGmuaDIFhZKmkoL+Cu2MUUjv5VxUZx/z7tBGBCBcQs5RI1Bkz5LLmNdjx7paOyQtMovyg==",
"dev": true,
"dependencies": {
"@babel/helper-module-imports": "^7.0.0-beta.49",
"@babel/types": "^7.0.0-beta.49",
"glob": "^7.1.1",
"lodash": "^4.17.10",
"require-package-name": "^2.0.1"
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.7.tgz",
@ -4063,13 +4134,18 @@
}
},
"node_modules/call-bind": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz",
"integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
"integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.1",
"set-function-length": "^1.1.1"
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -4142,12 +4218,13 @@
}
},
"node_modules/chai": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.0.tgz",
"integrity": "sha512-kDZ7MZyM6Q1DhR9jy7dalKohXQ2yrlXkk59CR52aRKxJrobmlBNqnFQxX9xOX8w+4mz8SYlKJa/7D7ddltFXCw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
"integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.0.0",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
@ -4170,9 +4247,10 @@
}
},
"node_modules/check-error": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.0.0.tgz",
"integrity": "sha512-tjLAOBHKVxtPoHe/SA7kNOMvhCRdCJ3vETdeY0RuAc9popf+hyaSV6ZEg9hr4cpWF7jmo/JSWEnLDrnijS9Tog==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true,
"engines": {
"node": ">= 16"
}
@ -5046,6 +5124,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.1.tgz",
"integrity": "sha512-nwQCf6ne2gez3o1MxWifqkciwt0zhl0LO1/UwVu4uMBuPmflWM4oQ70XMqHqnBJA+nhzncaqL9HVL6KkHJ28lw==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -5141,16 +5220,19 @@
}
},
"node_modules/define-data-property": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz",
"integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dependencies": {
"get-intrinsic": "^1.2.1",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.0"
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": {
@ -5521,6 +5603,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"dependencies": {
"get-intrinsic": "^1.2.4"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz",
@ -6929,7 +7030,8 @@
"node_modules/fp-ts": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.1.tgz",
"integrity": "sha512-by7U5W8dkIzcvDofUcO42yl9JbnHTEDBrzu3pt5fKT+Z4Oy85I21K80EYJYdjQGC2qum4Vo55Ag57iiIK4FYuA=="
"integrity": "sha512-by7U5W8dkIzcvDofUcO42yl9JbnHTEDBrzu3pt5fKT+Z4Oy85I21K80EYJYdjQGC2qum4Vo55Ag57iiIK4FYuA==",
"dev": true
},
"node_modules/fraction.js": {
"version": "4.3.7",
@ -6975,19 +7077,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -7046,20 +7135,25 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz",
"integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -7253,11 +7347,11 @@
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz",
"integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dependencies": {
"get-intrinsic": "^1.2.2"
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -7660,6 +7754,7 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/inspectpack/-/inspectpack-4.7.1.tgz",
"integrity": "sha512-XoDJbKSM9I2KA+8+OLFJHm8m4NM2pMEgsDD2hze6swVfynEed9ngCx36mRR+otzOsskwnxIZWXjI23FTW1uHqA==",
"dev": true,
"dependencies": {
"chalk": "^4.1.0",
"fp-ts": "^2.6.1",
@ -7680,6 +7775,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -7694,6 +7790,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@ -7709,6 +7806,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -7719,12 +7817,14 @@
"node_modules/inspectpack/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/inspectpack/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -7733,6 +7833,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -7770,6 +7871,7 @@
"version": "2.2.21",
"resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.21.tgz",
"integrity": "sha512-zz2Z69v9ZIC3mMLYWIeoUcwWD6f+O7yP92FMVVaXEOSZH1jnVBmET/urd/uoarD1WGBY4rCj8TAyMPzsGNzMFQ==",
"dev": true,
"peerDependencies": {
"fp-ts": "^2.5.0"
}
@ -7778,6 +7880,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/io-ts-reporters/-/io-ts-reporters-1.2.2.tgz",
"integrity": "sha512-igASwWWkDY757OutNcM6zTtdJf/eTZYkoe2ymsX2qpm5bKZLo74FJYjsCtMQOEdY7dRHLLEulCyFQwdN69GBCg==",
"dev": true,
"peerDependencies": {
"fp-ts": "^2.0.2",
"io-ts": "^2.0.0"
@ -7791,6 +7894,21 @@
"node": ">= 10"
}
},
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz",
@ -7931,6 +8049,20 @@
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
"integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==",
"dependencies": {
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -7950,6 +8082,21 @@
"node": ">=8"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-negative-zero": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
@ -8335,6 +8482,11 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/just-extend": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
"integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw=="
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -8470,6 +8622,16 @@
"resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz",
"integrity": "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
},
"node_modules/lodash.kebabcase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
@ -8682,9 +8844,10 @@
}
},
"node_modules/loupe": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.0.tgz",
"integrity": "sha512-qKl+FrLXUhFuHUoDJG7f8P8gEMHq9NFS0c6ghXG1J0rldmZFQZoNVv/vyirE9qwCIhWZDsvEFd1sbFu3GvRQFg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz",
"integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==",
"dev": true,
"dependencies": {
"get-func-name": "^2.0.1"
}
@ -9447,6 +9610,18 @@
"node": "*"
}
},
"node_modules/moment-locales-webpack-plugin": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/moment-locales-webpack-plugin/-/moment-locales-webpack-plugin-1.2.0.tgz",
"integrity": "sha512-QAi5v0OlPUP7GXviKMtxnpBAo8WmTHrUNN7iciAhNOEAd9evCOvuN0g1N7ThIg3q11GLCkjY1zQ2saRcf/43nQ==",
"dependencies": {
"lodash.difference": "^4.5.0"
},
"peerDependencies": {
"moment": "^2.8.0",
"webpack": "^1 || ^2 || ^3 || ^4 || ^5"
}
},
"node_modules/mrmime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
@ -9530,6 +9705,23 @@
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"node_modules/nise": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz",
"integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==",
"dependencies": {
"@sinonjs/commons": "^3.0.0",
"@sinonjs/fake-timers": "^11.2.2",
"@sinonjs/text-encoding": "^0.7.2",
"just-extend": "^6.2.0",
"path-to-regexp": "^6.2.1"
}
},
"node_modules/nise/node_modules/path-to-regexp": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw=="
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@ -9693,6 +9885,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@ -10135,6 +10342,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
@ -10159,6 +10367,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
"integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==",
"dev": true,
"engines": {
"node": ">=10"
},
@ -11275,6 +11484,12 @@
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/require-package-name": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/require-package-name/-/require-package-name-2.0.1.tgz",
"integrity": "sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==",
"dev": true
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -11434,9 +11649,9 @@
}
},
"node_modules/sass-loader": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.1.1.tgz",
"integrity": "sha512-QX8AasDg75monlybel38BZ49JP5Z+uSKfKwF2rO7S74BywaRmGQMUBw9dtkS+ekyM/QnP+NOrRYq8ABMZ9G8jw==",
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.2.1.tgz",
"integrity": "sha512-G0VcnMYU18a4N7VoNDegg2OuMjYtxnqzQWARVWCIVSZwJeiL9kg8QMsuIZOplsJgTzZLF6jGxI3AClj8I9nRdQ==",
"dependencies": {
"neo-async": "^2.6.2"
},
@ -11533,7 +11748,8 @@
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true
},
"node_modules/send": {
"version": "0.18.0",
@ -11674,15 +11890,16 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/set-function-length": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
"integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dependencies": {
"define-data-property": "^1.1.1",
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.1"
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
@ -11701,6 +11918,11 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -11762,6 +11984,50 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"node_modules/sinon": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz",
"integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==",
"dependencies": {
"@sinonjs/commons": "^3.0.0",
"@sinonjs/fake-timers": "^11.2.2",
"@sinonjs/samsam": "^8.0.0",
"diff": "^5.1.0",
"nise": "^5.1.5",
"supports-color": "^7.2.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/sinon"
}
},
"node_modules/sinon/node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/sinon/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/sinon/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
@ -12261,15 +12527,15 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.9",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz",
"integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==",
"version": "5.3.10",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz",
"integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.17",
"@jridgewell/trace-mapping": "^0.3.20",
"jest-worker": "^27.4.5",
"schema-utils": "^3.1.1",
"serialize-javascript": "^6.0.1",
"terser": "^5.16.8"
"terser": "^5.26.0"
},
"engines": {
"node": ">= 10.13.0"
@ -12404,6 +12670,17 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
},
"node_modules/timers-browserify": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
"integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
"dependencies": {
"setimmediate": "^1.0.4"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
@ -12515,6 +12792,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
"engines": {
"node": ">=4"
}
},
"node_modules/type-fest": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
@ -12718,6 +13003,18 @@
"requires-port": "^1.0.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View file

@ -18,12 +18,12 @@
"@vue/cli-service": "^5.0.8",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
"assert": "^2.1.0",
"axios": "^0.28.0",
"axios-progress-bar": "^1.2.0",
"babel-eslint": "^10.1.0",
"bootstrap": "^4.6.0",
"bootstrap-vue": "^2.23.1",
"chai": "^5.1.0",
"core-js": "^3.33.1",
"dompurify": "^3.0.3",
"eslint": "7.32.0",
@ -32,16 +32,18 @@
"eslint-plugin-vue": "7.20.0",
"habitica-markdown": "^3.0.0",
"hellojs": "^1.20.0",
"inspectpack": "^4.7.1",
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-locales-webpack-plugin": "^1.2.0",
"nconf": "^0.12.1",
"sass": "^1.63.4",
"sass-loader": "^14.1.1",
"sinon": "^17.0.1",
"smartbanner.js": "^1.19.3",
"stopword": "^2.0.8",
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vue": "^2.7.10",
@ -51,10 +53,14 @@
"vue-template-babel-compiler": "^2.0.0",
"vue-template-compiler": "^2.7.10",
"vuedraggable": "^2.24.3",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0",
"webpack": "^5.89.0"
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0"
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.21.0"
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"babel-plugin-lodash": "^3.3.4",
"chai": "^5.1.0",
"inspectpack": "^4.7.1",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.89.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because it is too large Load diff

View file

@ -217,16 +217,13 @@
.btn-show-more {
display: block;
width: 50%;
max-width: 448px;
margin: 0 auto;
margin-top: 12px;
width: 100%;
padding: 8px;
font-size: 14px;
line-height: 1.43;
font-weight: bold;
text-align: center;
background: $gray-600;
background: $gray-500;
color: $gray-200 !important; // Otherwise it gets ignored when used on an A element
box-shadow: none;

View file

@ -12,7 +12,7 @@
}
&.color {
svg path {
svg path, svg polygon {
fill: currentColor;
}
}

View file

@ -1,5 +1,11 @@
// TODO move to item component?
.item, .item-wrapper, .item > div > div {
&:focus-visible {
outline: none;
}
}
.items > div {
display: inline-block;
margin-right: 24px;
@ -9,34 +15,22 @@
position: relative;
display: inline-block;
margin-bottom: 12px;
&:focus {
outline: 2px solid $purple-400;
border-radius: 2px;
}
&:hover {
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
border-radius: 4px;
}
}
.items-one-line .item-wrapper {
margin-bottom: 8px;
}
.item.pet-slot {
// Desktop XL (1440)
@media only screen and (min-width: 1440px){
margin-right: 1.71em;
}
// Desktop L (1280)
@media only screen and (min-width: 1280px) and (max-width: 1439px) {
margin-right: 0.43em;
}
// Desktop M (1024)
@media only screen and (min-width: 1024px) and (max-width: 1279px) {
margin-right: 0.86em;
}
// Tablets and mobile
@media only screen and (max-width: 1023px) {
margin-right: 1.71em;
}
}
.item {
position: relative;
width: 94px;
@ -56,11 +50,6 @@
background: $purple-500;
}
&:hover {
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
border-color: $purple-400;
}
&.highlight {
box-shadow: 0 0 8px 8px rgba($black, 0.16), 0 5px 10px 0 rgba($black, 0.12) !important;
}
@ -70,9 +59,15 @@
}
}
.flat .item {
box-shadow: none;
border: none;
.flat {
.item {
box-shadow: none;
border: none;
}
.item-wrapper:hover {
box-shadow: none;
}
}
.bordered-item .item {

View file

@ -0,0 +1,90 @@
.featured-label {
margin: 24px auto;
}
.group {
display: inline-block;
width: 33%;
margin-bottom: 24px;
.items {
border-radius: 2px;
background-color: #edecee;
display: inline-block;
padding: 8px;
}
.item-wrapper {
margin-bottom: 0;
}
.items > div:not(:last-of-type) {
margin-right: 16px;
}
}
.timeTravelers {
.standard-page {
position: relative;
}
.badge-pin:not(.pinned) {
display: none;
}
.item:hover .badge-pin {
display: block;
}
.avatar {
cursor: default;
margin: 0 auto;
}
.featuredItems {
height: 192px;
.background {
background-repeat: repeat-x;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.background-open, .background-closed {
height: 216px;
}
.content {
display: flex;
flex-direction: column;
}
.npc {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 216px;
background-repeat: no-repeat;
&.closed {
background-repeat: no-repeat;
}
.featured-label {
position: absolute;
bottom: -14px;
margin: 0;
left: 79px;
}
}
}
}

View file

@ -158,7 +158,6 @@ function collateItemData (self) {
if (
// ignore items the user owns because we captured them above:
!(key in ownedItems)
&& allItems[key].price > 0
) {
const item = allItems[key];
itemData.push({

View file

@ -282,20 +282,16 @@ export default {
item.modified = true;
// for non-integer items, toggle through the allowed values:
if (item.itemType === 'gear') {
// Allowed starting values are true, false, and '' (never owned)
// Allowed values to switch to are true and false
item.value = !item.value;
} else if (item.itemType === 'mounts') {
// Allowed starting values are true, null, and "never owned"
// Allowed values to switch to are true and null
if (item.value === true) {
item.value = null;
if (item.itemType === 'gear' || item.itemType === 'mounts') {
// Allowed starting values are true, false, and undefined (never owned)
if (item.value && item.value !== '') {
item.value = false;
} else if (typeof item.value === 'boolean') {
item.value = '';
} else {
item.value = true;
}
}
// @TODO add a delete option
},
},
};

View file

@ -28,15 +28,15 @@
<div class="form-group">
<label>About</label>
<div class="row about-row">
<textarea
v-model="hero.profile.blurb"
class="form-control col"
rows="10"
></textarea>
<div
v-markdown="hero.profile.blurb"
class="markdownPreview col"
></div>
<textarea
v-model="hero.profile.blurb"
class="form-control col"
rows="10"
></textarea>
<div
v-markdown="hero.profile.blurb"
class="markdownPreview col"
></div>
</div>
</div>
<input

View file

@ -291,7 +291,44 @@
</div>
<div
v-if="!IS_PRODUCTION && isUserLoaded"
v-if="TIME_TRAVEL_ENABLED && user.permissions && user.permissions.fullAccess"
:key="lastTimeJump"
>
<a
class="btn btn-secondary mr-1"
@click="jumpTime(-1)"
>-1 Day</a>
<a
class="btn btn-secondary mr-1"
@click="jumpTime(-7)"
>-7 Days</a>
<a
class="btn btn-secondary mr-1"
@click="jumpTime(-30)"
>-30 Days</a>
<div class="my-2">
Time Traveling! It is {{ new Date().toLocaleDateString() }}
<a
class="btn btn-warning mr-1"
@click="resetTime()"
>Reset</a>
</div>
<a
class="btn btn-secondary mr-1"
@click="jumpTime(1)"
>+1 Day</a>
<a
class="btn btn-secondary mr-1"
@click="jumpTime(7)"
>+7 Days</a>
<a
class="btn btn-secondary mr-1"
@click="jumpTime(30)"
>+30 Days</a>
</div>
<div
v-if="DEBUG_ENABLED && isUserLoaded"
class="debug-toggle"
>
<button
@ -772,6 +809,7 @@ h3 {
// modules
import axios from 'axios';
import moment from 'moment';
import Vue from 'vue';
// images
import melior from '@/assets/svg/melior.svg';
@ -785,13 +823,24 @@ import heart from '@/assets/svg/heart.svg';
import { mapState } from '@/libs/store';
import buyGemsModal from './payments/buyGemsModal.vue';
import reportBug from '@/mixins/reportBug.js';
import { worldStateMixin } from '@/mixins/worldState';
const DEBUG_ENABLED = process.env.DEBUG_ENABLED === 'true'; // eslint-disable-line no-process-env
const TIME_TRAVEL_ENABLED = process.env.TIME_TRAVEL_ENABLED === 'true'; // eslint-disable-line no-process-env
let sinon;
if (TIME_TRAVEL_ENABLED) {
// eslint-disable-next-line global-require
sinon = await import('sinon');
}
const IS_PRODUCTION = process.env.NODE_ENV === 'production'; // eslint-disable-line no-process-env
export default {
components: {
buyGemsModal,
},
mixins: [reportBug],
mixins: [
reportBug,
worldStateMixin,
],
data () {
return {
icons: Object.freeze({
@ -803,7 +852,9 @@ export default {
heart,
}),
debugMenuShown: false,
IS_PRODUCTION,
DEBUG_ENABLED,
TIME_TRAVEL_ENABLED,
lastTimeJump: null,
};
},
computed: {
@ -865,6 +916,27 @@ export default {
'stats.mp': this.user.stats.mp + 10000,
});
},
async jumpTime (amount) {
const response = await axios.post('/api/v4/debug/jump-time', { offsetDays: amount });
if (amount > 0) {
Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000);
} else {
Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate());
}
this.lastTimeJump = response.data.data.time;
this.triggerGetWorldState(true);
},
async resetTime () {
const response = await axios.post('/api/v4/debug/jump-time', { reset: true });
const time = new Date(response.data.data.time);
Vue.config.clock.restore();
Vue.config.clock = sinon.useFakeTimers({
now: time,
shouldAdvanceTime: true,
});
this.lastTimeJump = response.data.data.time;
this.triggerGetWorldState(true);
},
addExp () {
// @TODO: Name these variables better
let exp = 0;

View file

@ -35,7 +35,7 @@
<span :class="[skinClass, specialMountClass]"></span>
<!-- eslint-disable max-len-->
<span
:class="[member.preferences.size + '_shirt_' + member.preferences.shirt, specialMountClass]"
:class="[shirtClass, specialMountClass]"
></span>
<!-- eslint-enable max-len-->
<span :class="['head_0', specialMountClass]"></span>
@ -46,12 +46,10 @@
<template
v-for="type in ['bangs', 'base', 'mustache', 'beard']"
>
<!-- eslint-disable max-len-->
<span
:key="type"
:class="['hair_' + type + '_' + member.preferences.hair[type] + '_' + member.preferences.hair.color, specialMountClass]"
:class="[hairClass(type), specialMountClass]"
></span>
<!-- eslint-enable max-len-->
</template>
<span :class="[getGearClass('body'), specialMountClass]"></span>
<span :class="[getGearClass('eyewear'), specialMountClass]"></span>
@ -233,10 +231,20 @@ export default {
},
skinClass () {
if (!this.member) return '';
if (this.overrideAvatarGear?.skin) {
return `skin_${this.overrideAvatarGear.skin}`;
}
const baseClass = `skin_${this.member.preferences.skin}`;
return `${baseClass}${this.member.preferences.sleep ? '_sleep' : ''}`;
},
shirtClass () {
if (!this.member) return '';
if (this.overrideAvatarGear?.shirt) {
return `${this.member.preferences.size}_shirt_${this.overrideAvatarGear.shirt}`;
}
return `${this.member.preferences.size}_shirt_${this.member.preferences.shirt}`;
},
costumeClass () {
return this.member?.preferences.costume ? 'costume' : 'equipped';
},
@ -269,6 +277,17 @@ export default {
return result;
},
hairClass (slot) {
if (this.overrideAvatarGear?.hair) {
if (this.overrideAvatarGear.hair[slot]) {
return `hair_${slot}_${this.overrideAvatarGear.hair[slot]}_${this.member.preferences.hair.color}`;
}
if (this.overrideAvatarGear.hair.color) {
return `hair_${slot}_${this.member.preferences.hair[slot]}_${this.overrideAvatarGear.hair.color}`;
}
}
return `hair_${slot}_${this.member.preferences.hair[slot]}_${this.member.preferences.hair.color}`;
},
hideGear (gearType) {
if (!this.member) return true;
if (gearType === 'weapon') {

View file

@ -1,7 +1,8 @@
<template>
<div
id="body"
class="section customize-section"
class="customize-section d-flex flex-column"
:class="{ 'justify-content-between': editing }"
>
<sub-menu
class="text-center"
@ -17,17 +18,11 @@
</div>
<div v-if="activeSubPage === 'shirt'">
<customize-options
:items="freeShirts"
:items="userShirts"
:current-value="user.preferences.shirt"
/>
<customize-options
v-if="editing"
:items="specialShirts"
:current-value="user.preferences.shirt"
:full-set="!userOwnsSet('shirt', specialShirtKeys)"
@unlock="unlock(`shirt.${specialShirtKeys.join(',shirt.')}`)"
/>
</div>
<customize-banner v-if="editing" />
</div>
</template>
@ -35,33 +30,27 @@
import appearance from '@/../../common/script/content/appearance';
import { subPageMixin } from '../../mixins/subPage';
import { userStateMixin } from '../../mixins/userState';
import { avatarEditorUtilies } from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import { avatarEditorUtilities } from '../../mixins/avatarEditUtilities';
import customizeBanner from './customize-banner.vue';
import customizeOptions from './customize-options';
import gem from '@/assets/svg/gem.svg';
const freeShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price === 0);
const specialShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price !== 0);
import subMenu from './sub-menu';
export default {
components: {
subMenu,
customizeBanner,
customizeOptions,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
avatarEditorUtilities,
],
props: [
'editing',
],
data () {
return {
specialShirtKeys,
icons: Object.freeze({
gem,
}),
items: [
{
id: 'size',
@ -78,25 +67,19 @@ export default {
sizes () {
return ['slim', 'broad'].map(s => this.mapKeysToFreeOption(s, 'size'));
},
freeShirts () {
return freeShirtKeys.map(s => this.mapKeysToFreeOption(s, 'shirt'));
},
specialShirts () {
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.specialShirtKeys;
const options = keys.map(key => this.mapKeysToOption(key, 'shirt'));
return options;
userShirts () {
const freeShirts = Object.keys(appearance.shirt)
.filter(k => appearance.shirt[k].price === 0)
.map(s => this.mapKeysToFreeOption(s, 'shirt'));
const ownedShirts = Object.keys(this.user.purchased.shirt)
.filter(k => this.user.purchased.shirt[k])
.map(s => this.mapKeysToFreeOption(s, 'shirt'));
return [...freeShirts, ...ownedShirts];
},
},
mounted () {
this.changeSubPage('size');
},
methods: {
},
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,69 @@
<template>
<div class="bottom-banner">
<div class="d-flex justify-content-center align-items-center mt-3">
<span
class="svg svg-icon sparkles"
v-html="icons.sparkles"
></span>
<strong
v-once
class="mx-2"
> {{ $t('lookingForMore') }}
</strong>
<span
v-once
class="svg svg-icon sparkles mirror"
v-html="icons.sparkles"
></span>
</div>
<div
class="check-link"
>
<span>Check out the </span>
<a href="/shops/customizations">Customizations Shop</a>
<span> for even more ways to customize your avatar!</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.bottom-banner {
background: linear-gradient(114.26deg, $purple-300 0%, $purple-200 100%);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
color: $white;
height: 80px;
line-height: 24px;
.check-link, a {
color: $purple-600;
}
a {
text-decoration: underline;
}
}
.sparkles {
width: 32px;
&.mirror {
transform: scaleX(-1);
}
}
</style>
<script>
import sparkles from '@/assets/svg/sparkles-left.svg';
export default {
data () {
return {
icons: Object.freeze({
sparkles,
}),
};
},
};
</script>

View file

@ -1,14 +1,13 @@
<template>
<div
class="customize-options"
:class="{'background-set': fullSet}"
v-if="items.length > 1"
class="customize-options mb-4"
>
<div
v-for="option in items"
:key="option.key"
class="outer-option-background"
:class="{
locked: option.gemLocked || option.goldLocked,
premium: Boolean(option.gem),
active: option.active || currentValue === option.key,
none: option.none,
@ -28,38 +27,6 @@
</div>
</div>
</div>
<div
v-if="option.gemLocked"
class="gem-lock"
>
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span>{{ option.gem }}</span>
</div>
<div
v-if="option.goldLocked"
class="gold-lock"
>
<div
class="svg-icon gold"
v-html="icons.gold"
></div>
<span>{{ option.gold }}</span>
</div>
</div>
<div
v-if="fullSet"
class="purchase-set"
@click="unlock()"
>
<span class="label">{{ $t('purchaseAll') }}</span>
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="price">5</span>
</div>
</div>
</template>
@ -67,13 +34,13 @@
<script>
import gem from '@/assets/svg/gem.svg';
import gold from '@/assets/svg/gold.svg';
import { avatarEditorUtilies } from '../../mixins/avatarEditUtilities';
import { avatarEditorUtilities } from '../../mixins/avatarEditUtilities';
export default {
mixins: [
avatarEditorUtilies,
avatarEditorUtilities,
],
props: ['items', 'currentValue', 'fullSet'],
props: ['items', 'currentValue'],
data () {
return {
icons: Object.freeze({
@ -150,7 +117,7 @@ export default {
&:not(.locked):not(.active) {
.option:hover {
background-color: rgba(213, 200, 255, .32);
background-color: rgba($purple-300, .25);
}
}
@ -216,9 +183,6 @@ export default {
margin-top: 0;
margin-left: 0;
&.color-bangs {
margin-top: 3px;
}
&.skin {
margin-top: -4px;
margin-left: -4px;
@ -237,14 +201,14 @@ export default {
margin-top: -5px;
}
}
&.color, &.bangs {
margin-top: 4px;
margin-left: -3px;
&.color, &.bangs, &.beard, &.flower, &.mustache {
background-position-x: -6px;
background-position-y: -12px;
}
&.hair.base {
margin-top: 0px;
margin-left: -5px;
background-position-x: -6px;
background-position-y: -4px;
}
&.headAccessory {
@ -258,89 +222,4 @@ export default {
}
}
}
.text-center {
.gem-lock, .gold-lock {
display: inline-block;
margin: 0 auto 8px;
vertical-align: bottom;
}
}
.gem-lock, .gold-lock {
.svg-icon {
width: 16px;
}
span {
font-weight: bold;
margin-left: .5em;
}
.svg-icon, span {
display: inline-block;
vertical-align: bottom;
}
}
.gem-lock span {
color: $green-10
}
.purchase-set {
background: #fff;
padding: 0.5em;
border-radius: 0 0 2px 2px;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
cursor: pointer;
span {
font-weight: bold;
font-size: 12px;
}
span.price {
color: #24cc8f;
}
.gem, .coin {
width: 16px;
}
&.single {
width: 141px;
}
width: 100%;
span {
font-size: 14px;
}
.gem, .coin {
width: 20px;
margin: 0 .5em;
display: inline-block;
vertical-align: bottom;
}
}
.background-set {
background-color: #edecee;
border-radius: 2px;
padding-top: 12px;
margin-left: 12px;
margin-right: 12px;
margin-bottom: 12px;
width: calc(100% - 24px);
padding-left: 0;
padding-right: 0;
max-width: unset; // disable col12 styling
flex: unset;
}
</style>

View file

@ -1,7 +1,8 @@
<template>
<div
id="extra"
class="section container customize-section"
class="customize-section d-flex flex-column"
:class="{ 'justify-content-between': !showEmptySection}"
>
<sub-menu
class="text-center"
@ -20,9 +21,8 @@
id="animal-ears"
>
<customize-options
v-if="animalItems('back').length > 0"
:items="animalItems('headAccessory')"
:full-set="!animalItemsOwned('headAccessory')"
@unlock="unlock(animalItemsUnlockString('headAccessory'))"
/>
</div>
<div
@ -30,9 +30,8 @@
id="animal-tails"
>
<customize-options
v-if="animalItems('back').length > 0"
:items="animalItems('back')"
:full-set="!animalItemsOwned('back')"
@unlock="unlock(animalItemsUnlockString('back'))"
/>
</div>
<div
@ -53,6 +52,24 @@
>
<customize-options :items="flowers" />
</div>
<div
v-if="showEmptySection"
class="my-5"
>
<h3
v-once
>
{{ $t('noItemsOwned') }}
</h3>
<p
v-once
class="w-50 mx-auto"
v-html="$t('visitCustomizationsShop')"
></p>
</div>
<customize-banner
v-else-if="editing"
/>
</div>
</template>
@ -60,23 +77,24 @@
import appearance from '@/../../common/script/content/appearance';
import { subPageMixin } from '../../mixins/subPage';
import { userStateMixin } from '../../mixins/userState';
import { avatarEditorUtilies } from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import { avatarEditorUtilities } from '../../mixins/avatarEditUtilities';
import customizeBanner from './customize-banner';
import customizeOptions from './customize-options';
import gem from '@/assets/svg/gem.svg';
import subMenu from './sub-menu';
const freeShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price === 0);
const specialShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price !== 0);
export default {
components: {
subMenu,
customizeBanner,
customizeOptions,
subMenu,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
avatarEditorUtilities,
],
props: [
'editing',
@ -89,9 +107,6 @@ export default {
},
chairKeys: ['none', 'black', 'blue', 'green', 'pink', 'red', 'yellow', 'handleless_black', 'handleless_blue', 'handleless_green', 'handleless_pink', 'handleless_red', 'handleless_yellow'],
specialShirtKeys,
icons: Object.freeze({
gem,
}),
items: [
{
id: 'size',
@ -178,7 +193,7 @@ export default {
return freeShirtKeys.map(s => this.mapKeysToFreeOption(s, 'shirt'));
},
specialShirts () {
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.specialShirtKeys;
const options = keys.map(key => this.mapKeysToOption(key, 'shirt'));
return options;
@ -193,6 +208,11 @@ export default {
for (const key of keys) {
const option = this.createGearItem(key, 'headAccessory', 'special', 'headband');
const newKey = `headAccessory_special_${key}`;
option.click = () => {
const type = this.user.preferences.costume ? 'costume' : 'equipped';
return this.equip(newKey, type);
};
options.push(option);
}
@ -222,12 +242,22 @@ export default {
option.none = true;
}
option.active = this.user.preferences.hair.flower === key;
option.class = `hair_flower_${key} flower`;
option.class = `icon_hair_flower_${key} flower`;
option.click = () => this.set({ 'preferences.hair.flower': key });
return option;
});
return options;
},
showEmptySection () {
switch (this.activeSubPage) {
case 'ears':
return this.editing && this.animalItems('headAccessory').length === 1;
case 'tails':
return this.editing && this.animalItems('back').length === 1;
default:
return false;
}
},
},
mounted () {
this.changeSubPage(this.extraSubMenuItems[0].id);
@ -236,7 +266,7 @@ export default {
animalItems (category) {
// @TODO: For some resonse when I use $set on the
// user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.animalItemKeys[category];
const noneOption = this.createGearItem(0, category, 'base', category);
@ -248,36 +278,22 @@ export default {
for (const key of keys) {
const newKey = `${category}_special_${key}`;
const userPurchased = this.user.items.gear.owned[newKey];
const option = {};
option.key = key;
option.active = this.user.preferences.costume
? this.user.items.gear.costume[category] === newKey
: this.user.items.gear.equipped[category] === newKey;
option.class = `headAccessory_special_${option.key} ${category}`;
if (category === 'back') {
option.class = `icon_back_special_${option.key} back`;
}
option.gemLocked = userPurchased === undefined;
option.goldLocked = userPurchased === false;
if (option.goldLocked) {
option.gold = 20;
}
if (option.gemLocked) {
option.gem = 2;
}
option.locked = option.gemLocked || option.goldLocked;
option.click = () => {
if (option.gemLocked) {
return this.unlock(`items.gear.owned.${newKey}`);
} if (option.goldLocked) {
return this.buy(newKey);
if (userPurchased) {
const option = {};
option.key = key;
option.active = this.user.preferences.costume
? this.user.items.gear.costume[category] === newKey
: this.user.items.gear.equipped[category] === newKey;
option.class = `headAccessory_special_${option.key} ${category}`;
if (category === 'back') {
option.class = `icon_back_special_${option.key} back`;
}
const type = this.user.preferences.costume ? 'costume' : 'equipped';
return this.equip(newKey, type);
};
options.push(option);
option.click = () => {
const type = this.user.preferences.costume ? 'costume' : 'equipped';
return this.equip(newKey, type);
};
options.push(option);
}
}
return options;
@ -287,17 +303,6 @@ export default {
return keys.join(',');
},
animalItemsOwned (category) {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let own = true;
this.animalItemKeys[category].forEach(key => {
if (this.user.items.gear.owned[`${category}_special_${key}`] === undefined) own = false;
});
return own;
},
createGearItem (key, gearType, subGearType, additionalClass) {
const newKey = `${gearType}_${subGearType ? `${subGearType}_` : ''}${key}`;
const option = {};
@ -339,7 +344,3 @@ export default {
},
};
</script>
<style scoped>
</style>

View file

@ -1,7 +1,8 @@
<template>
<div
id="hair"
class="section customize-section"
class="customize-section d-flex flex-column"
:class="{ 'justify-content-between': editing && !showEmptySection}"
>
<sub-menu
class="text-center"
@ -14,37 +15,9 @@
id="hair-color"
>
<customize-options
:items="freeHairColors"
:items="userHairColors"
:current-value="user.preferences.hair.color"
/>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
<div
v-for="set in seasonalHairColors"
v-if="editing && set.key !== 'undefined'"
:key="set.key"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<customize-options
:items="set.options"
:current-value="user.preferences.hair.color"
:full-set="!hideSet(set.key) && !userOwnsSet('hair', set.keys, 'color')"
@unlock="unlock(`hair.color.${set.keys.join(',hair.color.')}`)"
/>
</div>
</div>
<div
v-if="activeSubPage === 'style'"
id="style"
>
<!-- eslint-disable vue/require-v-for-key NO KEY AVAILABLE HERE -->
<div v-for="set in styleSets">
<customize-options
:items="set.options"
:full-set="set.fullSet"
@unlock="set.unlock()"
/>
</div>
<!-- eslint-enable vue/require-v-for-key -->
</div>
<div
v-if="activeSubPage === 'bangs'"
@ -55,67 +28,73 @@
:current-value="user.preferences.hair.bangs"
/>
</div>
<div
v-if="activeSubPage === 'style'"
id="style"
>
<customize-options
:items="userHairStyles"
:current-value="user.preferences.hair.base"
/>
</div>
<div
v-if="activeSubPage === 'facialhair'"
id="facialhair"
>
<customize-options
v-if="editing"
:items="mustacheList"
v-if="userMustaches.length > 1"
:items="userMustaches"
/>
<!-- eslint-disable max-len -->
<customize-options
v-if="editing"
:items="beardList"
:full-set="isPurchaseAllNeeded('hair', ['baseHair5', 'baseHair6'], ['mustache', 'beard'])"
@unlock="unlock(`hair.mustache.${baseHair5Keys.join(',hair.mustache.')},hair.beard.${baseHair6Keys.join(',hair.beard.')}`)"
v-if="userBeards.length > 1"
:items="userBeards"
/>
<!-- eslint-enable max-len -->
<div
v-if="showEmptySection"
class="my-5"
>
<h3
v-once
>
{{ $t('noItemsOwned') }}
</h3>
<p
v-once
class="w-50 mx-auto"
v-html="$t('visitCustomizationsShop')"
></p>
</div>
</div>
<customize-banner
v-if="editing && !showEmptySection"
/>
</div>
</template>
<script>
import groupBy from 'lodash/groupBy';
import appearance from '@/../../common/script/content/appearance';
import appearanceSets from '@/../../common/script/content/appearance/sets';
import { subPageMixin } from '../../mixins/subPage';
import { userStateMixin } from '../../mixins/userState';
import { avatarEditorUtilies } from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import { avatarEditorUtilities } from '../../mixins/avatarEditUtilities';
import customizeBanner from './customize-banner';
import customizeOptions from './customize-options';
import gem from '@/assets/svg/gem.svg';
const hairColorBySet = groupBy(appearance.hair.color, 'set.key');
const freeHairColorKeys = hairColorBySet[undefined].map(s => s.key);
import subMenu from './sub-menu';
export default {
components: {
subMenu,
customizeBanner,
customizeOptions,
subMenu,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
avatarEditorUtilities,
],
props: [
'editing',
],
data () {
return {
freeHairColorKeys,
icons: Object.freeze({
gem,
}),
baseHair1: [1, 3],
baseHair2Keys: [2, 4, 5, 6, 7, 8],
baseHair3Keys: [9, 10, 11, 12, 13, 14],
baseHair4Keys: [15, 16, 17, 18, 19, 20],
baseHair5Keys: [1, 2],
baseHair6Keys: [1, 2, 3],
};
},
computed: {
hairSubMenuItems () {
const items = [
@ -142,91 +121,46 @@ export default {
return items;
},
freeHairColors () {
return freeHairColorKeys.map(s => this.mapKeysToFreeOption(s, 'hair', 'color'));
userHairColors () {
const freeHairColors = groupBy(appearance.hair.color, 'set.key')[undefined]
.map(s => s.key).map(s => this.mapKeysToFreeOption(s, 'hair', 'color'));
const ownedHairColors = Object.keys(this.user.purchased.hair.color || {})
.filter(k => this.user.purchased.hair.color[k])
.map(h => this.mapKeysToFreeOption(h, 'hair', 'color'));
return [...freeHairColors, ...ownedHairColors];
},
seasonalHairColors () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
userHairStyles () {
const emptyHairStyle = {
...this.mapKeysToFreeOption(0, 'hair', 'base'),
none: true,
};
const freeHairStyles = [1, 3].map(s => this.mapKeysToFreeOption(s, 'hair', 'base'));
const ownedHairStyles = Object.keys(this.user.purchased.hair.base || {})
.filter(k => this.user.purchased.hair.base[k])
.map(h => this.mapKeysToFreeOption(h, 'hair', 'base'));
return [emptyHairStyle, ...freeHairStyles, ...ownedHairStyles];
},
userMustaches () {
const emptyMustache = {
...this.mapKeysToFreeOption(0, 'hair', 'mustache'),
none: true,
};
const ownedMustaches = Object.keys(this.user.purchased.hair.mustache || {})
.filter(k => this.user.purchased.hair.mustache[k])
.map(h => this.mapKeysToFreeOption(h, 'hair', 'mustache'));
const seasonalHairColors = [];
for (const key of Object.keys(hairColorBySet)) {
const set = hairColorBySet[key];
return [emptyMustache, ...ownedMustaches];
},
userBeards () {
const emptyBeard = {
...this.mapKeysToFreeOption(0, 'hair', 'beard'),
none: true,
};
const ownedBeards = Object.keys(this.user.purchased.hair.beard || {})
.filter(k => this.user.purchased.hair.beard[k])
.map(h => this.mapKeysToFreeOption(h, 'hair', 'beard'));
const keys = set.map(item => item.key);
const options = keys.map(optionKey => {
const option = this.mapKeysToOption(optionKey, 'hair', 'color', key);
return option;
});
let text = this.$t(key);
if (appearanceSets[key] && appearanceSets[key].text) {
text = appearanceSets[key].text();
}
const compiledSet = {
key,
options,
keys,
text,
};
seasonalHairColors.push(compiledSet);
}
return seasonalHairColors;
},
premiumHairColors () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.premiumHairColorKeys;
const options = keys.map(key => this.mapKeysToOption(key, 'hair', 'color'));
return options;
},
baseHair2 () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.baseHair2Keys;
const options = keys.map(key => this.mapKeysToOption(key, 'hair', 'base'));
return options;
},
baseHair3 () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.baseHair3Keys;
const options = keys.map(key => {
const option = this.mapKeysToOption(key, 'hair', 'base');
return option;
});
return options;
},
baseHair4 () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.baseHair4Keys;
const options = keys.map(key => this.mapKeysToOption(key, 'hair', 'base'));
return options;
},
baseHair5 () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.baseHair5Keys;
const options = keys.map(key => this.mapKeysToOption(key, 'hair', 'mustache'));
return options;
},
baseHair6 () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const keys = this.baseHair6Keys;
const options = keys.map(key => this.mapKeysToOption(key, 'hair', 'beard'));
return options;
return [emptyBeard, ...ownedBeards];
},
hairBangs () {
const none = this.mapKeysToFreeOption(0, 'hair', 'bangs');
@ -236,136 +170,13 @@ export default {
return [none, ...options];
},
mustacheList () {
const noneOption = this.mapKeysToFreeOption(0, 'hair', 'mustache');
noneOption.none = true;
return [noneOption, ...this.baseHair5];
},
beardList () {
const noneOption = this.mapKeysToFreeOption(0, 'hair', 'beard');
noneOption.none = true;
return [noneOption, ...this.baseHair6];
},
styleSets () {
const sets = [];
const emptyHairBase = {
...this.mapKeysToFreeOption(0, 'hair', 'base'),
none: true,
};
sets.push({
options: [
emptyHairBase,
...this.baseHair1.map(key => this.mapKeysToFreeOption(key, 'hair', 'base')),
],
});
if (this.editing) {
sets.push({
fullSet: !this.userOwnsSet('hair', this.baseHair3Keys, 'base'),
unlock: () => this.unlock(`hair.base.${this.baseHair3Keys.join(',hair.base.')}`),
options: [
...this.baseHair3,
],
});
sets.push({
fullSet: !this.userOwnsSet('hair', this.baseHair4Keys, 'base'),
unlock: () => this.unlock(`hair.base.${this.baseHair4Keys.join(',hair.base.')}`),
options: [
...this.baseHair4,
],
});
}
if (this.editing) {
sets.push({
fullSet: !this.userOwnsSet('hair', this.baseHair2Keys, 'base'),
unlock: () => this.unlock(`hair.base.${this.baseHair2Keys.join(',hair.base.')}`),
options: [
...this.baseHair2,
],
});
}
return sets;
showEmptySection () {
return this.activeSubPage === 'facialhair'
&& this.userMustaches.length === 1 && this.userBeards.length === 1;
},
},
mounted () {
this.changeSubPage('color');
},
methods: {
/**
* Allows you to find out whether you need the "Purchase All" button or not.
* If there are more than 2 unpurchased items, returns true, otherwise returns false.
* @param {string} category - The selected category.
* @param {string[]} keySets - The items keySets.
* @param {string[]} [types] - The items types (subcategories). Optional.
* @returns {boolean} - Determines whether the "Purchase All" button
* is needed (true) or not (false).
*/
isPurchaseAllNeeded (category, keySets, types) {
const purchasedItemsLengths = [];
// If item types are specified, count them
if (types && types.length > 0) {
// Types can be undefined, so we must check them.
types.forEach(type => {
if (this.user.purchased[category][type]) {
purchasedItemsLengths
.push(Object.keys(this.user.purchased[category][type]).length);
}
});
} else {
let purchasedItemsCounter = 0;
// If types are not specified, recursively
// search for purchased items in the category
const findPurchasedItems = item => {
if (typeof item === 'object') {
Object.values(item)
.forEach(innerItem => {
if (typeof innerItem === 'boolean' && innerItem === true) {
purchasedItemsCounter += 1;
}
return findPurchasedItems(innerItem);
});
}
return purchasedItemsCounter;
};
findPurchasedItems(this.user.purchased[category]);
if (purchasedItemsCounter > 0) {
purchasedItemsLengths.push(purchasedItemsCounter);
}
}
// We don't need to count the key sets (below)
// if there are no purchased items at all.
if (purchasedItemsLengths.length === 0) {
return true;
}
const allItemsLengths = [];
// Key sets must be specify correctly.
keySets.forEach(keySet => {
allItemsLengths.push(Object.keys(this[keySet]).length);
});
// Simply sum all the length values and
// write them into variables for the convenience.
const allItems = allItemsLengths.reduce((acc, val) => acc + val);
const purchasedItems = purchasedItemsLengths.reduce((acc, val) => acc + val);
const unpurchasedItems = allItems - purchasedItems;
return unpurchasedItems > 2;
},
},
};
</script>
<style scoped>
</style>

View file

@ -1,7 +1,8 @@
<template>
<div
id="skin"
class="section customize-section"
class="customize-section d-flex flex-column"
:class="{ 'justify-content-between': editing }"
>
<sub-menu
class="text-center"
@ -10,63 +11,39 @@
@changeSubPage="changeSubPage($event)"
/>
<customize-options
:items="freeSkins"
:items="userSkins"
:current-value="user.preferences.skin"
/>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
<div
v-for="set in seasonalSkins"
v-if="editing && set.key !== 'undefined'"
:key="set.key"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<customize-options
:items="set.options"
:current-value="user.preferences.skin"
:full-set="!hideSet(set.key) && !userOwnsSet('skin', set.keys)"
@unlock="unlock(`skin.${set.keys.join(',skin.')}`)"
/>
</div>
<customize-banner v-if="editing" />
</div>
</template>
<script>
import groupBy from 'lodash/groupBy';
import appearance from '@/../../common/script/content/appearance';
import appearanceSets from '@/../../common/script/content/appearance/sets';
import { subPageMixin } from '../../mixins/subPage';
import { userStateMixin } from '../../mixins/userState';
import { avatarEditorUtilies } from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import { avatarEditorUtilities } from '../../mixins/avatarEditUtilities';
import customizeBanner from './customize-banner.vue';
import customizeOptions from './customize-options';
import gem from '@/assets/svg/gem.svg';
const skinsBySet = groupBy(appearance.skin, 'set.key');
const freeSkinKeys = skinsBySet[undefined].map(s => s.key);
// const specialSkinKeys = Object.keys(appearance.shirt)
// .filter(k => appearance.shirt[k].price !== 0);
import subMenu from './sub-menu';
export default {
components: {
subMenu,
customizeBanner,
customizeOptions,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
avatarEditorUtilities,
],
props: [
'editing',
],
data () {
return {
freeSkinKeys,
icons: Object.freeze({
gem,
}),
skinSubMenuItems: [
{
id: 'color',
@ -76,41 +53,13 @@ export default {
};
},
computed: {
freeSkins () {
return freeSkinKeys.map(s => this.mapKeysToFreeOption(s, 'skin'));
},
seasonalSkins () {
// @TODO: For some resonse when I use $set on the user purchases object,
// this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const seasonalSkins = [];
for (const setKey of Object.keys(skinsBySet)) {
const set = skinsBySet[setKey];
const keys = set.map(item => item.key);
const options = keys.map(optionKey => {
const option = this.mapKeysToOption(optionKey, 'skin', '', setKey);
return option;
});
let text = this.$t(setKey);
if (appearanceSets[setKey] && appearanceSets[setKey].text) {
text = appearanceSets[setKey].text();
}
const compiledSet = {
key: setKey,
options,
keys,
text,
};
seasonalSkins.push(compiledSet);
}
return seasonalSkins;
userSkins () {
const freeSkins = groupBy(appearance.skin, 'set.key')[undefined]
.map(s => s.key).map(s => this.mapKeysToFreeOption(s, 'skin'));
const ownedSkins = Object.keys(this.user.purchased.skin)
.filter(k => this.user.purchased.skin[k])
.map(s => this.mapKeysToFreeOption(s, 'skin'));
return [...freeSkins, ...ownedSkins];
},
},
mounted () {

File diff suppressed because it is too large Load diff

View file

@ -134,6 +134,12 @@
>
{{ $t('quests') }}
</router-link>
<router-link
class="topbar-dropdown-item dropdown-item"
:to="{name: 'customizations'}"
>
{{ $t('customizations') }}
</router-link>
<router-link
class="topbar-dropdown-item dropdown-item"
:to="{name: 'seasonal'}"

View file

@ -35,13 +35,9 @@
/>
</a>
<a
class="topbar-dropdown-item dropdown-item"
class="topbar-dropdown-item dropdown-item dropdown-separated"
@click="showAvatar('body', 'size')"
>{{ $t('editAvatar') }}</a>
<a
class="topbar-dropdown-item dropdown-item dropdown-separated"
@click="showAvatar('backgrounds', '2024')"
>{{ $t('backgrounds') }}</a>
<a
class="topbar-dropdown-item dropdown-item"
@click="showProfile('profile')"

View file

@ -66,6 +66,7 @@
:right="true"
:hide-icon="false"
:inline-dropdown="false"
:direct-select="true"
@select="groupBy = $event"
>
<template #item="{ item }">

View file

@ -444,7 +444,7 @@ export default {
const isSearched = !searchText || item.text()
.toLowerCase()
.indexOf(searchText) !== -1;
if (isSearched) {
if (isSearched && item) {
itemsArray.push({
...item,
class: `${group.classPrefix}${item.key}`,

View file

@ -134,56 +134,57 @@
v-for="(petGroup) in petGroups"
v-if="!anyFilterSelected || viewOptions[petGroup.key].selected"
:key="petGroup.key"
:class="{ hide: viewOptions[petGroup.key].animalCount === 0 }"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<h4 v-if="viewOptions[petGroup.key].animalCount !== 0">
{{ petGroup.label }}
</h4>
<!-- eslint-disable vue/no-use-v-if-with-v-for, max-len -->
<div
v-for="(group, key, index) in pets(petGroup, hideMissing, selectedSortBy, searchTextThrottled)"
v-if="index === 0 || $_openedItemRows_isToggled(petGroup.key)"
:key="key"
class="pet-row d-flex"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div class="d-inline-flex flex-column">
<div
v-for="item in group"
v-show="show('pet', item)"
:key="item.key"
v-drag.drop.food="item.key"
class="pet-group"
:class="{'last': item.isLastInRow}"
@itemDragOver="onDragOver($event, item)"
@itemDropped="onDrop($event, item)"
@itemDragLeave="onDragLeave()"
v-for="(group, key, index) in pets(petGroup, hideMissing, selectedSortBy, searchTextThrottled)"
v-if="index === 0 || $_openedItemRows_isToggled(petGroup.key)"
:key="key"
class="pet-row d-flex"
>
<petItem
:item="item"
:popover-position="'top'"
:show-popover="currentDraggingFood == null"
:highlight-border="highlightPet == item.key"
@click="petClicked(item)"
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div
v-for="item in group"
v-show="show('pet', item)"
:key="item.key"
v-drag.drop.food="item.key"
class="pet-group"
@itemDragOver="onDragOver($event, item)"
@itemDropped="onDrop($event, item)"
@itemDragLeave="onDragLeave()"
>
<template
slot="itemBadge"
slot-scope="context"
<petItem
:item="item"
:popover-position="'top'"
:show-popover="currentDraggingFood == null"
:highlight-border="highlightPet == item.key"
@click="petClicked(item)"
>
<equip-badge
:equipped="context.item.key === currentPet"
:show="isOwned('pet', context.item)"
@click="selectPet(context.item)"
/>
</template>
</petItem>
<template
slot="itemBadge"
slot-scope="context"
>
<equip-badge
:equipped="context.item.key === currentPet"
:show="isOwned('pet', context.item)"
@click="selectPet(context.item)"
/>
</template>
</petItem>
</div>
</div>
<show-more-button
v-if="petRowCount[petGroup.key] > 1 && petGroup.key !== 'specialPets' && !(petGroup.key === 'wackyPets' && selectedSortBy !== 'sortByColor')"
:show-all="$_openedItemRows_isToggled(petGroup.key)"
@click="setShowMore(petGroup.key)"
/>
</div>
<show-more-button
v-if="petRowCount[petGroup.key] > 1 && petGroup.key !== 'specialPets' && !(petGroup.key === 'wackyPets' && selectedSortBy !== 'sortByColor')"
:show-all="$_openedItemRows_isToggled(petGroup.key)"
class="show-more-button"
@click="setShowMore(petGroup.key)"
/>
</div>
<h2>
{{ $t('mounts') }}
@ -196,52 +197,55 @@
v-for="mountGroup in mountGroups"
v-if="!anyFilterSelected || viewOptions[mountGroup.key].selected"
:key="mountGroup.key"
:class="{ hide: viewOptions[mountGroup.key].animalCount === 0 }"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<h4 v-if="viewOptions[mountGroup.key].animalCount != 0">
{{ mountGroup.label }}
</h4>
<!-- eslint-disable vue/no-use-v-if-with-v-for, max-len -->
<div
v-for="(group, key, index) in mounts(mountGroup, hideMissing, selectedSortBy, searchTextThrottled)"
v-if="index === 0 || $_openedItemRows_isToggled(mountGroup.key)"
:key="key"
class="pet-row d-flex"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div class="d-inline-flex flex-column">
<div
v-for="item in group"
v-show="show('mount', item)"
:key="item.key"
class="pet-group"
v-for="(group, key, index) in mounts(mountGroup, hideMissing, selectedSortBy, searchTextThrottled)"
v-if="index === 0 || $_openedItemRows_isToggled(mountGroup.key)"
:key="key"
class="pet-row d-flex"
>
<mountItem
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div
v-for="item in group"
v-show="show('mount', item)"
:key="item.key"
:item="item"
:popover-position="'top'"
:show-popover="true"
@click="selectMount(item)"
class="pet-group"
>
<span slot="popoverContent">
<h4 class="popover-content-title">{{ item.name }}</h4>
</span>
<template
slot="itemBadge"
<mountItem
:key="item.key"
:item="item"
:popover-position="'top'"
:show-popover="true"
@click="selectMount(item)"
>
<equip-badge
:equipped="item.key === currentMount"
:show="isOwned('mount', item)"
@click="selectMount(item)"
/>
</template>
</mountItem>
<span slot="popoverContent">
<h4 class="popover-content-title">{{ item.name }}</h4>
</span>
<template
slot="itemBadge"
>
<equip-badge
:equipped="item.key === currentMount"
:show="isOwned('mount', item)"
@click="selectMount(item)"
/>
</template>
</mountItem>
</div>
</div>
<show-more-button
v-if="mountRowCount[mountGroup.key] > 1 && mountGroup.key !== 'specialMounts'"
:show-all="$_openedItemRows_isToggled(mountGroup.key)"
@click="setShowMore(mountGroup.key)"
/>
</div>
<show-more-button
v-if="mountRowCount[mountGroup.key] > 1 && mountGroup.key !== 'specialMounts'"
:show-all="$_openedItemRows_isToggled(mountGroup.key)"
@click="setShowMore(mountGroup.key)"
/>
</div>
<inventoryDrawer>
<template
@ -310,13 +314,8 @@
overflow: hidden;
}
.pet-row {
max-width: 100%;
flex-wrap: wrap;
.item {
margin-right: .5em;
}
.hide {
height: 0px;
}
</style>
@ -330,6 +329,14 @@
display: inline-block;
}
.pet-row {
flex-wrap: wrap;
.pet-group:not(:last-of-type) {
margin-right: 24px;
}
}
.GreyedOut {
opacity: 0.3;
}
@ -343,24 +350,11 @@
}
.stable {
.standard-page {
padding-right:0;
}
.standard-page .clearfix .float-right {
margin-right: 24px;
}
.svg-icon.inline.icon-16 {
vertical-align: bottom;
}
}
.last {
margin-right: 0 !important;
}
.no-focus:focus {
background-color: inherit;
color: inherit;

View file

@ -1,47 +1,41 @@
<template>
<div class="d-flex justify-content-around">
<span
<div class="d-flex align-items-center">
<div
v-for="currency of currencies"
:key="currency.key"
class="d-flex align-items-center"
>
<div
class="svg-icon ml-1"
class="svg-icon icon-16 ml-1"
v-html="currency.icon"
></div>
<span
<div
:class="{'notEnough': currency.notEnough}"
class="mx-1"
class="currency-value mx-1 my-auto"
>
{{ currency.value | roundBigNumber }}
</span>
</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
span {
font-size: 0.75rem;
line-height: 1.33;
color: $gray-100;
margin-bottom: 16px;
margin-top: -4px;
display: inline-block;
}
.svg-icon {
vertical-align: middle;
width: 16px;
height: 16px;
display: inline-block;
}
.currency-value {
font-size: 0.75rem;
line-height: 1.33;
color: $gray-100;
display: inline-block;
}
.notEnough {
color: #f23035 !important;
}
.svg-icon {
margin-top: 1px;
}
</style>
<script>

View file

@ -89,19 +89,19 @@
v-if="item.value > 0 && !(item.key === 'gem' && gemsLeft < 1)"
class="purchase-amount"
>
<!-- this is where the pretty item cost element lives -->
<div class="item-cost">
<div class="item-cost justify-content-center my-3">
<span
class="cost"
class="cost d-flex mx-auto"
:class="getPriceClass()"
>
<span
class="svg-icon inline icon-24"
class="svg-icon icon-24 my-auto mr-1"
aria-hidden="true"
v-html="icons[getPriceClass()]"
>
</span>
<span
class="my-auto"
:class="getPriceClass()"
>{{ item.value }}</span>
</span>
@ -181,7 +181,7 @@
</div>
</div>
<countdown-banner
v-if="item.event && item.owned == null"
v-if="item.end && item.owned == null"
:end-date="endDate"
class="limitedTime available"
/>
@ -218,11 +218,10 @@
</div>
<div
slot="modal-footer"
class="clearfix"
>
<span class="user-balance float-left">{{ $t('yourBalance') }}</span>
<span class="user-balance ml-3 my-auto">{{ $t('yourBalance') }}</span>
<balanceInfo
class="currency-totals"
class="mr-3"
:currency-needed="getPriceClass()"
:amount-needed="item.value"
/>
@ -250,24 +249,21 @@
border-bottom-left-radius: 8px;
display: block;
margin: 24px 0 0 0;
padding: 16px 24px;
align-content: center;
padding: 0px;
> div {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0px;
height: 100%;
}
.user-balance {
width: 150px;
height: 16px;
font-size: 0.75rem;
font-weight: bold;
line-height: 1.33;
color: $gray-100;
margin-bottom: 16px;
margin-top: -4px;
margin-left: -4px;
}
.currency-totals {
margin-right: -8px;
float: right;
}
}
@ -452,14 +448,11 @@
}
.item-cost {
display: inline-flex;
margin: 16px 0;
align-items: center;
height: 40px;
}
.cost {
display: inline-block;
width: fit-content;
font-family: sans-serif;
font-size: 1.25rem;
font-weight: bold;
@ -470,19 +463,16 @@
&.gems {
color: $green-10;
background-color: rgba(36, 204, 143, 0.15);
align-items: center;
}
&.gold {
color: $yellow-5;
background-color: rgba(255, 190, 93, 0.15);
align-items: center;
}
&.hourglasses {
color: $hourglass-color;
background-color: rgba(41, 149, 205, 0.15);
align-items: center;
}
}
@ -547,10 +537,6 @@
margin: auto -1rem -1rem;
}
// .pt-015 {
// padding-top: 0.15rem;
// }
.gems-left {
height: 32px;
background-color: $green-100;
@ -602,8 +588,10 @@ import moment from 'moment';
import planGemLimits from '@/../../common/script/libs/planGemLimits';
import { drops as dropEggs } from '@/../../common/script/content/eggs';
import { drops as dropPotions } from '@/../../common/script/content/hatching-potions';
import spellsMixin from '@/mixins/spells';
import { avatarEditorUtilities } from '@/mixins/avatarEditUtilities';
import numberInvalid from '@/mixins/numberInvalid';
import spellsMixin from '@/mixins/spells';
import sync from '@/mixins/sync';
import svgClose from '@/assets/svg/close.svg';
import svgGold from '@/assets/svg/gold.svg';
@ -637,7 +625,7 @@ const amountOfDropPotions = size(dropPotions);
const hideAmountSelectionForPurchaseTypes = [
'gear', 'backgrounds', 'mystery_set', 'card',
'rebirth_orb', 'fortify', 'armoire', 'keys',
'debuffPotion', 'pets', 'mounts',
'debuffPotion', 'pets', 'mounts', 'customization',
];
export default {
@ -650,7 +638,15 @@ export default {
CountdownBanner,
numberIncrement,
},
mixins: [buyMixin, currencyMixin, notifications, numberInvalid, spellsMixin],
mixins: [
avatarEditorUtilities,
buyMixin,
currencyMixin,
notifications,
numberInvalid,
spellsMixin,
sync,
],
props: {
// eslint-disable-next-line vue/require-default-prop
item: {
@ -690,7 +686,8 @@ export default {
computed: {
...mapState({ user: 'user.data' }),
showAvatar () {
return ['backgrounds', 'gear', 'mystery_set'].includes(this.item.purchaseType);
return ['backgrounds', 'gear', 'mystery_set', 'customization']
.includes(this.item.purchaseType);
},
preventHealthPotion () {
@ -741,7 +738,7 @@ export default {
return (!this.user.purchased.plan.customerId && !this.user.purchased.plan.consecutive.trinkets && this.getPriceClass() === 'hourglasses');
},
endDate () {
return moment(this.item.event.end);
return moment(this.item.end);
},
totalOwned () {
return this.user.items[this.item.purchaseType][this.item.key] || 0;
@ -759,7 +756,7 @@ export default {
this.selectedAmountToBuy = 1;
},
buyItem () {
async buyItem () {
// @TODO: I think we should buying to the items.
// Turn the items into classes, and use polymorphism
if (this.item.buy) {
@ -824,17 +821,25 @@ export default {
) return;
}
const shouldConfirmPurchase = this.item.currency === 'gems' || this.item.currency === 'hourglasses';
if (
shouldConfirmPurchase
&& !this.confirmPurchase(this.item.currency, this.item.value * this.selectedAmountToBuy)
) {
return;
}
if (this.genericPurchase) {
this.makeGenericPurchase(this.item, 'buyModal', this.selectedAmountToBuy);
if (this.item.purchaseType === 'customization') {
const buySuccess = await this.unlock(this.item.path);
if (!buySuccess) return;
this.sync();
this.$root.$emit('playSound', 'Reward');
this.$root.$emit('buyModal::boughtItem', this.item);
this.purchased(this.item.text);
} else {
const shouldConfirmPurchase = this.item.currency === 'gems' || this.item.currency === 'hourglasses';
if (
shouldConfirmPurchase
&& !this.confirmPurchase(this.item.currency, this.item.value * this.selectedAmountToBuy)
) {
return;
}
if (this.genericPurchase) {
this.makeGenericPurchase(this.item, 'buyModal', this.selectedAmountToBuy);
this.purchased(this.item.text);
}
}
this.$emit('buyPressed', this.item);
@ -891,6 +896,27 @@ export default {
return gear;
}
case 'customization': {
if (item.type === 'skin') {
return {
skin: item.key,
};
}
if (item.type === 'shirt') {
return {
shirt: item.key,
armor: 'armor_base_0',
};
}
if (['base', 'beard', 'color', 'mustache'].includes(item.type)) {
return {
hair: {
[item.type]: item.key,
},
head: 'head_base_0',
};
}
}
}
return {};

View file

@ -0,0 +1,265 @@
<template>
<div class="row market">
<div class="standard-sidebar">
<filter-sidebar>
<div
slot="search"
class="form-group"
>
<input
v-model="searchText"
class="form-control input-search"
type="text"
:placeholder="$t('search')"
>
</div>
<filter-group>
<checkbox
v-for="category in unfilteredCategories"
:id="`category-${category.identifier}`"
:key="category.identifier"
:checked.sync="viewOptions[category.identifier].selected"
:text="category.text"
/>
</filter-group>
</filter-sidebar>
</div>
<div class="standard-page p-0">
<div
class="background"
:style="{'background-image': imageURLs.background}"
>
<div
class="npc"
:style="{'background-image': imageURLs.npc}"
>
<div class="featured-label">
<span class="rectangle"></span><span
v-once
class="text"
>{{ $t('customizationsNPC') }}</span><span class="rectangle"></span>
</div>
</div>
</div>
<div class="p-4">
<h1
v-once
>
{{ $t('customizations') }}
</h1>
<div
v-for="category in categories"
:key="category.identifier"
>
<h2 class="mb-3 mt-4">
{{ category.text }}
</h2>
<item-rows
:items="customizationsItems({category, searchBy: searchTextThrottled})"
:type="category.identifier"
:fold-button="category.identifier === 'background'"
:item-width="94"
:item-margin="24"
:max-items-per-row="8"
:no-items-label="emptyStateString(category.identifier)"
@emptyClick="emptyClick(category.identifier, $event)"
>
<template
slot="item"
slot-scope="ctx"
>
<shop-item
:key="ctx.item.path"
:item="ctx.item"
:price="ctx.item.value"
:price-type="ctx.item.currency"
:empty-item="false"
:show-popover="Boolean(ctx.item.text)"
@click="selectItem(ctx.item)"
/>
</template>
</item-rows>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/shops.scss';
h1 {
line-height: 32px;
color: $purple-200;
}
.background, .npc {
height: 216px;
}
.featured-label {
margin-left: 90px;
margin-top: 200px;
}
.npc {
background-repeat: no-repeat;
}
</style>
<script>
import shops from '@/../../common/script/libs/shops';
import throttle from 'lodash/throttle';
import { mapState } from '@/libs/store';
import Checkbox from '@/components/ui/checkbox';
import FilterGroup from '@/components/ui/filterGroup';
import FilterSidebar from '@/components/ui/filterSidebar';
import ItemRows from '@/components/ui/itemRows';
import ShopItem from '../shopItem';
export default {
components: {
Checkbox,
FilterGroup,
FilterSidebar,
ItemRows,
ShopItem,
},
data () {
return {
searchText: null,
searchTextThrottled: null,
unfilteredCategories: [],
viewOptions: {},
};
},
computed: {
...mapState({
// content: 'content',
user: 'user.data',
currentEventList: 'worldState.data.currentEventList',
}),
anyFilterSelected () {
return Object.values(this.viewOptions).some(g => g.selected);
},
imageURLs () {
return {
background: 'url(/static/npc/normal/customizations_background.png)',
npc: 'url(/static/npc/normal/customizations_npc.png)',
};
},
categories () {
const { unfilteredCategories } = this;
return unfilteredCategories.filter(category => !this.anyFilterSelected
|| this.viewOptions[category.identifier].selected);
},
},
watch: {
// TODO mixin?
searchText: throttle(function throttleSearch () {
this.searchTextThrottled = this.searchText.toLowerCase();
}, 250),
},
mounted () {
this.$store.dispatch('common:setTitle', {
subSection: this.$t('customizations'),
section: this.$t('shops'),
});
this.updateShop();
this.$root.$on('buyModal::boughtItem', () => {
this.updateShop();
});
},
methods: {
customizationsItems (options = {}) {
const { category, searchBy } = options;
return category.items.filter(item => !searchBy
|| item.text.toLowerCase().includes(searchBy));
},
emptyClick (identifier, event) {
if (event.target.tagName !== 'A') return;
this.$store.state.avatarEditorOptions.editingUser = true;
switch (identifier) {
case 'animalEars':
this.$store.state.avatarEditorOptions.startingPage = 'extra';
this.$store.state.avatarEditorOptions.subpage = 'ears';
break;
case 'animalTails':
this.$store.state.avatarEditorOptions.startingPage = 'extra';
this.$store.state.avatarEditorOptions.subpage = 'tails';
break;
case 'backgrounds':
this.$store.state.avatarEditorOptions.startingPage = 'background';
this.$store.state.avatarEditorOptions.subpage = '2024';
break;
case 'facialHair':
this.$store.state.avatarEditorOptions.startingPage = 'hair';
this.$store.state.avatarEditorOptions.subpage = 'beard';
break;
case 'color':
this.$store.state.avatarEditorOptions.startingPage = 'hair';
this.$store.state.avatarEditorOptions.subpage = 'color';
break;
case 'base':
this.$store.state.avatarEditorOptions.startingPage = 'hair';
this.$store.state.avatarEditorOptions.subpage = 'style';
break;
case 'shirt':
this.$store.state.avatarEditorOptions.startingPage = 'body';
this.$store.state.avatarEditorOptions.subpage = 'shirt';
break;
case 'skin':
this.$store.state.avatarEditorOptions.startingPage = 'skin';
this.$store.state.avatarEditorOptions.subpage = 'color';
break;
default:
throw new Error(`Unknown identifier ${identifier}`);
}
this.$root.$emit('bv::show::modal', 'avatar-modal');
},
emptyStateString (identifier) {
const { $t } = this;
switch (identifier) {
case 'animalEars':
return $t('allCustomizationsOwned');
case 'animalTails':
return $t('allCustomizationsOwned');
case 'backgrounds':
return `${$t('allCustomizationsOwned')} ${$t('checkNextMonth')}`;
case 'facialHair':
return $t('allCustomizationsOwned');
case 'color':
return `${$t('allCustomizationsOwned')} ${$t('checkNextSeason')}`;
case 'base':
return $t('allCustomizationsOwned');
case 'shirt':
return $t('allCustomizationsOwned');
case 'skin':
return `${$t('allCustomizationsOwned')} ${$t('checkNextSeason')}`;
default:
return `Unknown identifier ${identifier}`;
}
},
selectItem (item) {
this.$root.$emit('buyModal::showItem', item);
},
updateShop () {
const shop = shops.getCustomizationsShop(this.user);
shop.categories.forEach(category => {
// do not reset the viewOptions if already set once
if (typeof this.viewOptions[category.identifier] === 'undefined') {
this.$set(this.viewOptions, category.identifier, {
selected: false,
});
}
});
this.unfilteredCategories = shop.categories;
},
},
};
</script>

View file

@ -89,7 +89,7 @@ export default {
<style lang="scss" scoped>
.featuredItems {
height: 216px;
height: 192px;
.background {
width: 100%;

View file

@ -3,26 +3,32 @@
<secondary-menu class="col-12">
<router-link
class="nav-link"
:to="{name: 'market'}"
:to="{ name: 'market' }"
exact="exact"
>
{{ $t('market') }}
</router-link>
<router-link
class="nav-link"
:to="{name: 'quests'}"
:to="{ name: 'quests' }"
>
{{ $t('quests') }}
</router-link>
<router-link
class="nav-link"
:to="{name: 'seasonal'}"
:to="{ name: 'customizations' }"
>
{{ $t('customizations') }}
</router-link>
<router-link
class="nav-link"
:to="{ name: 'seasonal' }"
>
{{ $t('titleSeasonalShop') }}
</router-link>
<router-link
class="nav-link"
:to="{name: 'time'}"
:to="{ name: 'time' }"
>
{{ $t('titleTimeTravelers') }}
</router-link>

View file

@ -6,6 +6,7 @@
:initial-item="selectedGearCategory"
:items="marketGearCategories"
:with-icon="true"
:direct-select="true"
@selected="selectedGroupGearByClass = $event.id"
>
<span
@ -23,6 +24,7 @@
:label="$t('sortBy')"
:initial-item="selectedSortGearBy"
:items="sortGearBy"
:direct-select="true"
@selected="selectedSortGearBy = $event"
>
<span
@ -40,7 +42,7 @@
:item-width="94"
:item-margin="24"
:type="'gear'"
:no-items-label="$t('noGearItemsOfClass')"
:no-items-label="noItemsLabel"
>
<template
slot="item"
@ -75,6 +77,7 @@
import _filter from 'lodash/filter';
import _orderBy from 'lodash/orderBy';
import shops from '@/../../common/script/libs/shops';
import { remainingGearInSet } from '@/../../common/script/count';
import { getClassName } from '@/../../common/script/libs/getClassName';
import { mapState } from '@/libs/store';
import LayoutSection from '@/components/ui/layoutSection';
@ -93,7 +96,7 @@ import pinUtils from '../../../mixins/pinUtils';
const sortGearTypes = [
'sortByType', 'sortByPrice', 'sortByCon',
'sortByPer', 'sortByStr', 'sortByInt',
].map(g => ({ id: g }));
].map(g => ({ id: g, identifier: g }));
const sortGearTypeMap = {
sortByType: 'type',
@ -134,6 +137,17 @@ export default {
userItems: 'user.data.items',
userStats: 'user.data.stats',
}),
armoireCount () {
return remainingGearInSet(this.userItems.gear.owned, 'armoire');
},
noItemsLabel () {
if (this.armoireCount > 0) {
return `${this.$t('gearItemsCompleted', { klass: this.$t(this.selectedGroupGearByClass) })}
${this.$t('moreArmoireGearAvailable', { armoireCount: this.armoireCount })}`;
}
return `${this.$t('gearItemsCompleted', { klass: this.$t(this.selectedGroupGearByClass) })}
${this.$t('moreArmoireGearComing')}`;
},
marketGearCategories () {
return shops.getMarketGearCategories(this.user).map(c => {
c.id = c.identifier;

View file

@ -26,7 +26,7 @@
/>
<h1
v-once
class="mb-4 page-header"
class="page-header mt-4 mb-4"
>
{{ $t('market') }}
</h1>
@ -35,6 +35,7 @@
:hide-pinned="hidePinned"
:hide-locked="hideLocked"
:search-by="searchTextThrottled"
class="mb-4"
/>
<layout-section :title="$t('items')">
<div slot="filters">
@ -42,6 +43,7 @@
:label="$t('sortBy')"
:initial-item="selectedSortItemsBy"
:items="sortItemsBy"
:direct-select="true"
@selected="selectedSortItemsBy = $event"
>
<span
@ -121,6 +123,10 @@
height: 112px;
}
.items {
max-width: 944px;
}
.market {
.avatar {
cursor: default;
@ -133,7 +139,7 @@
position: absolute;
bottom: -14px;
margin: 0;
left: 80px;
left: 75px;
}
}
}
@ -152,6 +158,7 @@ import _map from 'lodash/map';
import _throttle from 'lodash/throttle';
import getItemInfo from '@/../../common/script/libs/getItemInfo';
import shops from '@/../../common/script/libs/shops';
import { getScheduleMatchingGroup } from '@/../../common/script/content/constants/schedule';
import { mapState } from '@/libs/store';
import KeysToKennel from './keysToKennel';
@ -175,7 +182,7 @@ import inventoryUtils from '@/mixins/inventoryUtils';
import pinUtils from '@/mixins/pinUtils';
import { worldStateMixin } from '@/mixins/worldState';
const sortItems = ['AZ', 'sortByNumber'].map(g => ({ id: g }));
const sortItems = ['AZ', 'sortByNumber'].map(g => ({ id: g, identifier: g }));
export default {
components: {
@ -218,6 +225,7 @@ export default {
hideLocked: false,
hidePinned: false,
cardMatcher: getScheduleMatchingGroup('cards'),
};
},
computed: {
@ -241,7 +249,8 @@ export default {
categories.push({
identifier: 'cards',
text: this.$t('cards'),
items: _map(_filter(this.content.cardTypes, value => value.yearRound), value => ({
items: _map(_filter(this.content.cardTypes, value => value.yearRound
|| this.cardMatcher.items.indexOf(value.key) !== -1), value => ({
...getItemInfo(this.user, 'card', value),
showCount: false,
})),

View file

@ -99,7 +99,7 @@
</div>
</div>
<countdown-banner
v-if="item.event"
v-if="item.end"
:end-date="endDate"
/>
<div
@ -470,7 +470,7 @@ export default {
return this.icons.gems;
},
endDate () {
return moment(this.item.event.end);
return moment(this.item.end);
},
},
watch: {

View file

@ -115,23 +115,25 @@
</div>
</div>
</div>
<h1
v-once
class="mb-4 page-header"
>
{{ $t('quests') }}
</h1>
<div class="clearfix">
<div class="float-right">
<span class="dropdown-label">{{ $t('sortBy') }}</span>
<select-translated-array
:right="true"
:value="selectedSortItemsBy"
:items="sortItemsBy"
:inline-dropdown="false"
class="inline"
@select="selectedSortItemsBy = $event"
/>
<div class="d-flex justify-content-between w-75">
<h1
v-once
class="mb-4 page-header"
>
{{ $t('quests') }}
</h1>
<div class="clearfix">
<div class="float-right">
<span class="dropdown-label">{{ $t('sortBy') }}</span>
<select-translated-array
:right="true"
:value="selectedSortItemsBy"
:items="sortItemsBy"
:inline-dropdown="false"
class="inline"
@select="selectedSortItemsBy = $event"
/>
</div>
</div>
</div>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
@ -197,48 +199,52 @@
</itemRows>
<div
v-else-if="category.identifier === 'unlockable' || category.identifier === 'gold'"
class="grouped-parent"
class="d-flex justify-content-between flex-wrap w-75"
>
<!-- eslint-disable vue/no-use-v-if-with-v-for, max-len -->
<div
v-for="(items, key) in getGrouped(questItems(category, selectedSortItemsBy,searchTextThrottled, hideLocked, hidePinned))"
v-for="(items, key) in getGrouped(
questItems(
category, selectedSortItemsBy,searchTextThrottled, hideLocked, hidePinned
)
)"
:key="key"
class="group"
class="quest-group mb-3"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for, max-len -->
<h3>{{ $t(key) }}</h3>
<div class="items">
<shopItem
v-for="item in items"
:key="item.key"
:item="item"
:price="item.value"
:empty-item="false"
:popover-position="'top'"
:owned="!isNaN(userItems.quests[item.key])"
@click="selectItem(item)"
>
<span slot="popoverContent">
<quest-popover :item="item" />
</span>
<template
slot="itemBadge"
slot-scope="ctx"
<div class="quest-container">
<h3>{{ $t(key) }}</h3>
<div class="items d-flex justify-content-left">
<shopItem
v-for="item in items"
:key="item.key"
:item="item"
:price="item.value"
:empty-item="false"
:popover-position="'top'"
:owned="!isNaN(userItems.quests[item.key])"
@click="selectItem(item)"
>
<span
class="badge-top"
@click.prevent.stop="togglePinned(ctx.item)"
>
<pin-badge
:pinned="ctx.item.pinned"
/>
<span slot="popoverContent">
<quest-popover :item="item" />
</span>
<countBadge
:show="userItems.quests[ctx.item.key] > 0"
:count="userItems.quests[ctx.item.key] || 0"
/>
</template>
</shopItem>
<template
slot="itemBadge"
slot-scope="ctx"
>
<span
class="badge-top"
@click.prevent.stop="togglePinned(ctx.item)"
>
<pin-badge
:pinned="ctx.item.pinned"
/>
</span>
<countBadge
:show="userItems.quests[ctx.item.key] > 0"
:count="userItems.quests[ctx.item.key] || 0"
/>
</template>
</shopItem>
</div>
</div>
</div>
</div>
@ -317,18 +323,16 @@
margin: 24px auto;
}
.group {
display: inline-block;
width: 33%;
margin-bottom: 24px;
vertical-align: top;
.quest-container {
min-width: 330px;
}
.quest-group {
.items {
border-radius: 2px;
width: fit-content;
background-color: #edecee;
display: inline-block;
padding: 0;
margin-right: 12px;
}
.item-wrapper {
@ -389,7 +393,7 @@
position: absolute;
bottom: -14px;
margin: 0;
left: 70px;
left: 62px;
}
}
}
@ -526,7 +530,7 @@ export default {
}
});
return this.shop.categories;
return this.shop.categories.filter(category => category.items.length > 0);
}
return [];
},

View file

@ -38,7 +38,7 @@
</div>
</div>
<div
v-if="quest.event && !abbreviated"
v-if="quest.end && !abbreviated"
class="m-auto"
>
{{ limitedString }}
@ -210,14 +210,14 @@ export default {
return collect.text;
},
countdownString () {
if (!this.quest.event || this.purchased) return;
const diffDuration = moment.duration(moment(this.quest.event.end).diff(moment()));
if (!this.quest.end || this.purchased) return;
const diffDuration = moment.duration(moment(this.quest.end).diff(moment()));
if (diffDuration.asSeconds() <= 0) {
this.limitedString = this.$t('noLongerAvailable');
} else if (diffDuration.days() > 0 || diffDuration.months() > 0) {
this.limitedString = this.$t('limitedAvailabilityDays', {
days: moment(this.quest.event.end).diff(moment(), 'days'),
days: moment(this.quest.end).diff(moment(), 'days'),
hours: diffDuration.hours(),
minutes: diffDuration.minutes(),
});

View file

@ -40,7 +40,6 @@
<div class="featuredItems">
<div
class="background"
:class="{opened: seasonal.opened}"
:style="{'background-image': imageURLs.background}"
>
<div
@ -54,20 +53,7 @@
</div>
</div>
<div
v-if="!seasonal.opened"
class="content"
>
<div class="featured-label with-border closed">
<span class="rectangle"></span>
<span
class="text"
v-html="seasonal.notes"
></span>
<span class="rectangle"></span>
</div>
</div>
<div
v-else-if="seasonal.featured.items.length !== 0"
v-if="seasonal.featured.items.length !== 0"
class="content"
>
<div
@ -135,17 +121,6 @@
<h2 class="float-left mb-3">
{{ $t('classArmor') }}
</h2>
<div class="float-right">
<span class="dropdown-label">{{ $t('sortBy') }}</span>
<select-translated-array
:right="true"
:value="selectedSortItemsBy"
:items="sortItemsBy"
:inline-dropdown="false"
class="inline"
@select="selectedSortItemsBy = $event"
/>
</div>
</div>
<div
v-for="(groupSets, categoryGroup) in getGroupedCategories(categories)"
@ -174,7 +149,7 @@
<div class="items">
<!-- eslint-disable max-len -->
<shopItem
v-for="item in seasonalItems(category, selectedSortItemsBy, searchTextThrottled, viewOptions, hidePinned)"
v-for="item in seasonalItems(category, 'AZ', searchTextThrottled, viewOptions, hidePinned)"
:key="item.key"
:item="item"
:price="item.value"
@ -227,6 +202,10 @@
background-color: #edecee;
display: inline-block;
padding: 8px;
> div {
margin-right: auto;
}
}
.item-wrapper {
@ -319,7 +298,7 @@
position: absolute;
bottom: -14px;
margin: 0;
left: 60px;
left: 32px;
}
}
@ -367,15 +346,11 @@ import svgWizard from '@/assets/svg/wizard.svg';
import svgRogue from '@/assets/svg/rogue.svg';
import svgHealer from '@/assets/svg/healer.svg';
import SelectTranslatedArray from '@/components/tasks/modal-controls/selectTranslatedArray';
import FilterSidebar from '@/components/ui/filterSidebar';
import FilterGroup from '@/components/ui/filterGroup';
import { worldStateMixin } from '@/mixins/worldState';
export default {
components: {
SelectTranslatedArray,
FilterGroup,
FilterSidebar,
Checkbox,
PinBadge,
@ -407,13 +382,14 @@ export default {
eyewear: i18n.t('eyewear'),
}),
sortItemsBy: ['AZ'],
selectedSortItemsBy: 'AZ',
hidePinned: false,
featuredGearBought: false,
currentEvent: null,
backgroundUpdate: new Date(),
imageURLs: {
background: '',
npc: '',
},
};
},
computed: {
@ -484,29 +460,16 @@ export default {
}
return [];
},
anyFilterSelected () {
return Object.values(this.viewOptions).some(g => g.selected);
},
imageURLs () {
if (!this.seasonal.opened || !this.currentEvent || !this.currentEvent.season) {
return {
background: 'url(/static/npc/normal/seasonal_shop_closed_background.png)',
npc: 'url(/static/npc/normal/seasonal_shop_closed_npc.png)',
};
}
return {
background: `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_background.png)`,
npc: `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_npc.png)`,
};
},
},
watch: {
searchText: _throttle(function throttleSearch () {
this.searchTextThrottled = this.searchText.toLowerCase();
}, 250),
},
mounted () {
async mounted () {
this.$store.dispatch('common:setTitle', {
subSection: this.$t('seasonalShop'),
section: this.$t('shops'),
@ -516,8 +479,10 @@ export default {
this.backgroundUpdate = new Date();
});
this.triggerGetWorldState();
await this.triggerGetWorldState();
this.currentEvent = _find(this.currentEventList, event => Boolean(['winter', 'spring', 'summer', 'fall'].includes(event.season)));
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_npc.png)`;
},
beforeDestroy () {
this.$root.$off('buyModal::boughtItem');

View file

@ -18,7 +18,7 @@
:emptyItem="emptyItem"
></slot>
<span
v-if="item.event && item.owned == null && showEventBadge"
v-if="item.end && item.owned == null && showEventBadge"
class="badge badge-round badge-item badge-clock"
>
<span
@ -114,7 +114,7 @@
</div>
</div>
<div
v-if="item.event && item.purchaseType !== 'quests'"
v-if="item.end && item.purchaseType !== 'quests'"
:class="item.purchaseType === 'gear' ? 'mt-4' : 'mt-2'"
>
{{ limitedString }}
@ -142,6 +142,22 @@
&.locked .price {
opacity: 0.5;
}
.hair, .facial-hair, .shirt, .skin {
height: 68px;
}
.hair {
background-position: -24px -2px;
}
.facial-hair, .skin {
background-position: -24px -10px;
}
.shirt {
background-position: -23px -32px;
}
}
.image {
@ -149,11 +165,12 @@
}
.price {
height: 1.75rem;
width: 94px;
border-radius: 0px 0px 4px 4px;
font-size: 0.75rem;
line-height: 1;
margin-left: -1px;
margin-right: -1px;
border-radius: 0px 0px 4px 4px;
padding: 0.375rem 0;
&.gems {
background-color: rgba($green-100, 0.15);
@ -174,9 +191,7 @@
.price-label {
font-family: Roboto;
font-size: 12px;
font-weight: bold;
line-height: 1.33;
&.gems {
color: $green-1;
@ -351,6 +366,7 @@ export default {
this.$emit('click', {});
},
blur () {
if (!this.$refs?.popover) return;
this.$refs.popover.$emit('enable');
},
getPrice () {
@ -370,14 +386,14 @@ export default {
};
},
countdownString () {
if (!this.item.event) return;
const diffDuration = moment.duration(moment(this.item.event.end).diff(moment()));
if (!this.item.end) return;
const diffDuration = moment.duration(moment(this.item.end).diff(moment()));
if (diffDuration.asSeconds() <= 0) {
this.limitedString = this.$t('noLongerAvailable');
} else if (diffDuration.days() > 0 || diffDuration.months() > 0) {
this.limitedString = this.$t('limitedAvailabilityDays', {
days: moment(this.item.event.end).diff(moment(), 'days'),
days: moment(this.item.end).diff(moment(), 'days'),
hours: diffDuration.hours(),
minutes: diffDuration.minutes(),
});

View file

@ -1,7 +1,6 @@
<template>
<div class="row timeTravelers">
<div
v-if="!closed"
class="standard-sidebar d-none d-sm-block"
>
<filter-sidebar>
@ -69,26 +68,34 @@
</div>
</div>
</div>
</div><div
v-if="!closed"
class="clearfix"
>
<div class="float-right">
<span class="dropdown-label">{{ $t('sortBy') }}</span>
<select-translated-array
:right="true"
:value="selectedSortItemsBy"
:items="sortItemsBy"
:inline-dropdown="false"
class="inline"
@select="selectedSortItemsBy = $event"
/>
</div>
<div class="d-flex justify-content-between w-items">
<h1
v-once
class="page-header mt-4 mb-4"
>
{{ $t('timeTravelers') }}
</h1>
<div
class="clearfix mt-4"
>
<div class="float-right">
<span class="dropdown-label">{{ $t('sortBy') }}</span>
<select-translated-array
:right="true"
:value="selectedSortItemsBy"
:items="sortItemsBy"
:inline-dropdown="false"
class="inline"
@select="selectedSortItemsBy = $event"
/>
</div>
</div>
</div>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
<div
v-for="category in categories"
v-if="!anyFilterSelected || (!closed && viewOptions[category.identifier].selected)"
v-if="!anyFilterSelected || viewOptions[category.identifier].selected"
:key="category.identifier"
:class="category.identifier"
>
@ -100,6 +107,9 @@
:item-width="94"
:item-margin="24"
:type="category.identifier"
:fold-button="false"
:no-items-label="$t('allEquipmentOwned')"
:click-handler="false"
>
<template
slot="item"
@ -112,34 +122,7 @@
:price-type="ctx.item.currency"
:empty-item="false"
@click="selectItemToBuy(ctx.item)"
>
<span
v-if="category !== 'quests'"
slot="popoverContent"
slot-scope="ctx"
><div><h4 class="popover-content-title">{{ ctx.item.text }}</h4></div></span>
<span
v-if="category === 'quests'"
slot="popoverContent"
><div class="questPopover">
<h4 class="popover-content-title">{{ item.text }}</h4>
<questInfo :quest="item" />
</div></span>
<template
slot="itemBadge"
slot-scope="ctx"
>
<span
v-if="ctx.item.pinType !== 'IGNORE'"
class="badge-top"
@click.prevent.stop="togglePinned(ctx.item)"
>
<pin-badge
:pinned="ctx.item.pinned"
/>
</span>
</template>
</shopItem>
/>
</template>
</itemRows>
</div>
@ -164,108 +147,14 @@
</div>
</template>
<!-- eslint-disable max-len -->
<style lang="scss">
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/shops.scss';
// these styles may be applied to other pages too
.featured-label {
margin: 24px auto;
}
.group {
display: inline-block;
width: 33%;
margin-bottom: 24px;
.items {
border-radius: 2px;
background-color: #edecee;
display: inline-block;
padding: 8px;
}
.item-wrapper {
margin-bottom: 0;
}
.items > div:not(:last-of-type) {
margin-right: 16px;
}
}
.timeTravelers {
.standard-page {
position: relative;
}
.badge-pin:not(.pinned) {
display: none;
}
.item:hover .badge-pin {
display: block;
}
.avatar {
cursor: default;
margin: 0 auto;
}
.featuredItems {
height: 216px;
.background {
background-repeat: repeat-x;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.background-open {
height: 188px;
}
.background-closed {
height: 216px;
}
.content {
display: flex;
flex-direction: column;
}
.npc {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 216px;
background-repeat: no-repeat;
&.closed {
background-repeat: no-repeat;
}
.featured-label {
position: absolute;
bottom: -14px;
margin: 0;
left: 40px;
}
}
}
.w-items {
max-width: 920px;
}
</style>
<!-- eslint-enable max-len -->
<script>
import _filter from 'lodash/filter';
@ -281,8 +170,6 @@ import { mapState } from '@/libs/store';
import ShopItem from '../shopItem';
import Item from '@/components/inventory/item';
import ItemRows from '@/components/ui/itemRows';
import QuestInfo from '../quests/questInfo.vue';
import PinBadge from '@/components/ui/pinBadge';
import toggleSwitch from '@/components/ui/toggleSwitch';
import BuyQuestModal from '../quests/buyQuestModal.vue';
@ -304,9 +191,7 @@ export default {
ShopItem,
Item,
ItemRows,
PinBadge,
toggleSwitch,
QuestInfo,
BuyQuestModal,
},
@ -332,6 +217,11 @@ export default {
backgroundUpdate: new Date(),
currentEvent: null,
imageURLs: {
background: '',
npc: '',
},
};
},
computed: {
@ -392,19 +282,6 @@ export default {
anyFilterSelected () {
return Object.values(this.viewOptions).some(g => g.selected);
},
imageURLs () {
if (!this.currentEvent || !this.currentEvent.season || this.currentEvent.season === 'thanksgiving') {
return {
background: 'url(/static/npc/normal/time_travelers_background.png)',
npc: this.closed ? 'url(/static/npc/normal/time_travelers_closed_banner.png)'
: 'url(/static/npc/normal/time_travelers_open_banner.png)',
};
}
return {
background: `url(/static/npc/${this.currentEvent.season}/time_travelers_background.png)`,
npc: `url(/static/npc/${this.currentEvent.season}/time_travelers_open_banner.png)`,
};
},
},
watch: {
searchText: _throttle(function throttleSearch () {
@ -425,6 +302,14 @@ export default {
}
});
this.currentEvent = _find(this.currentEventList, event => Boolean(['winter', 'spring', 'summer', 'fall'].includes(event.season)));
if (!this.currentEvent || !this.currentEvent.season || this.currentEvent.season === 'thanksgiving' || this.closed) {
this.imageURLs.background = 'url(/static/npc/normal/time_travelers_background.png)';
this.imageURLs.npc = this.closed ? 'url(/static/npc/normal/time_travelers_closed_banner.png)'
: 'url(/static/npc/normal/time_travelers_open_banner.png)';
} else {
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/time_travelers_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/time_travelers_open_banner.png)`;
}
},
beforeDestroy () {
this.$root.$off('buyModal::boughtItem');

View file

@ -1,5 +1,8 @@
<template>
<div v-once class="top-container mx-auto">
<div
v-once
class="top-container mx-auto"
>
<div class="main-text mr-4">
<div class="title-details">
<h1>{{ $t('contentFaqTitle') }}</h1>
@ -23,7 +26,8 @@
</ul>
<h3>{{ $t('contentQuestion2') }}</h3>
<ul>
<li>{{ $t('contentAnswer20') }}
<li>
{{ $t('contentAnswer20') }}
<ul>
<li v-html="$t('contentAnswer200')"></li>
<li v-html="$t('contentAnswer201')"></li>
@ -54,7 +58,8 @@
<p>{{ $t('contentAnswer410') }}</p>
<h3>{{ $t('contentQuestion5') }}</h3>
<ul>
<li>{{ $t('contentAnswer50') }}
<li>
{{ $t('contentAnswer50') }}
<ul>
<li>{{ $t('backgrounds') }}</li>
<li>{{ $t('contentAnswer501') }}</li>
@ -78,9 +83,11 @@
<li>{{ $t('contentAnswer70') }}</li>
<li>{{ $t('contentAnswer71') }}</li>
</ul>
<p v-html="$t('contentFaqPara3',
{ mailto: '<a href=mailto:admin@habitica.com>admin@habitica.com</a>'}
)"></p>
<p
v-html="$t('contentFaqPara3',
{ mailto: '<a href=mailto:admin@habitica.com>admin@habitica.com</a>'}
)"
></p>
</div>
<faq-sidebar />
</div>

View file

@ -7,6 +7,7 @@
:disabled="disabled"
:value="selected"
:hide-icon="true"
:direct-select="true"
@select="$emit('select', $event.value)"
>
<template #item="{ item, button }">

View file

@ -10,6 +10,7 @@
:hide-icon="false"
:inline-dropdown="inlineDropdown"
:placeholder="placeholder"
:direct-select="true"
@select="selectItem($event)"
>
<template #item="{ item }">

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