From 2d672fd5ed1636e7a00d1c8a0abe309e2999ba9c Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Sun, 20 Mar 2022 20:28:38 +0100 Subject: [PATCH 01/52] docs(README): fix issue links --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4d0f92c..22b8f20 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@

A website to view your AzerothCore server's characters
- Report a Bug + Report a Bug · - Request a Feature + Suggest a Feature · - Ask a Question + Ask a Question

@@ -324,4 +324,4 @@ Distributed under the MIT License. See the [`LICENSE`][license-url] file for mor [issues-url]: https://github.com/r-o-b-o-t-o/azerothcore-armory/issues [license-shield]: https://img.shields.io/github/license/r-o-b-o-t-o/azerothcore-armory.svg?style=flat [license-url]: https://github.com/r-o-b-o-t-o/azerothcore-armory/blob/master/LICENSE -[feature-request]: https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?template=feature_request.yml +[feature-request]: https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?assignees=&labels=enhancement&template=feature_request.yml From af497b7ea670ed841ad24147a70fcb25123d9b7e Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Sun, 20 Mar 2022 21:02:17 +0100 Subject: [PATCH 02/52] fix(character): fix equipment slot icons size on mobile (#4) --- static/css/icons.css | 1 + 1 file changed, 1 insertion(+) diff --git a/static/css/icons.css b/static/css/icons.css index 6eb341e..b7b4e8b 100644 --- a/static/css/icons.css +++ b/static/css/icons.css @@ -45,6 +45,7 @@ position: absolute; background-repeat: no-repeat; z-index: 1; + background-size: 36px; } .iconlarge { From 91f3ec424a783229e302daae5189e5871fbe5f4c Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Mon, 21 Mar 2022 00:28:13 +0100 Subject: [PATCH 03/52] fix(character): fix sheath type when mounted (#5) --- src/armory/controllers/CharacterController.ts | 13 +++- src/armory/data/DbcReader.ts | 3 +- static/character.hbs | 67 +++++++++++++++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index bc56c90..fcdbf19 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -29,6 +29,8 @@ interface IEquipmentData { flags: number; enchantments: string; randomPropertyId: number; + classId: number; + subclassId: number; } interface ICustomizationOption { @@ -338,7 +340,16 @@ export class CharacterController { values: [charGuid], timeout: this.armory.config.dbQueryTimeout, }); - return rows as RowDataPacket[] as IEquipmentData[]; + + const data = rows as RowDataPacket[] as IEquipmentData[]; + + for (const row of data) { + const item = await this.armory.dbc.item().find(item => item.id === row.itemEntry); + row.classId = item.classId; + row.subclassId = item.subclassId; + } + + return data; } private async getMounts(realm: string, charGuid: number): Promise { diff --git a/src/armory/data/DbcReader.ts b/src/armory/data/DbcReader.ts index 2fa6582..212e502 100644 --- a/src/armory/data/DbcReader.ts +++ b/src/armory/data/DbcReader.ts @@ -28,6 +28,7 @@ export interface IAchievementCategory { export interface IItemDbc { id: number; classId: number; + subclassId: number; displayInfoId: number; inventoryType: number; } @@ -316,7 +317,7 @@ const dbcFields = { achievement: ["id", "faction", "titleLang0", "descriptionLang0", "category", "points", "flags", "iconId"], achievementCategory: ["id", "parent", "nameLang0"], glyphProperties: ["id", "spellId"], - item: ["id", "classId", "displayInfoId", "inventoryType"], + item: ["id", "classId", "subclassId", "displayInfoId", "inventoryType"], itemRetail: ["id", "inventoryType"], itemAppearance: ["id", "itemDisplayInfoId"], itemModifiedAppearance: ["id", "itemId", "itemAppearanceId"], diff --git a/static/character.hbs b/static/character.hbs index 0a93c09..7b82c02 100644 --- a/static/character.hbs +++ b/static/character.hbs @@ -66,7 +66,7 @@ debug: () => { }, }; - const charData = {{{JSONstringify data}}}; + const charData = {{{ JSONstringify data }}}; const races = { 1: "human", 2: "orc", @@ -199,10 +199,37 @@ onResize(true); function setMount(mountId) { - if (characterModel.mount.id !== mountId) { - characterModel.mount.id = mountId; - createViewer(); + if (characterModel.mount.id === mountId) { + return; } + + if (mountId !== 0) { + const mainHand = charData.equipment.find(e => e.slot === 15); + const offHand = charData.equipment.find(e => e.slot === 16); + const ranged = charData.equipment.find(e => e.slot === 17); + + characterModel.charCustomization.sheathMain = sheathTypes[mainHand.classId][mainHand.subclassId]; + if (offHand !== undefined) { + characterModel.charCustomization.sheathOff = sheathTypes[offHand.classId][offHand.subclassId]; + } else if (ranged !== undefined) { + characterModel.charCustomization.sheathOff = sheathTypes[ranged.classId][ranged.subclassId]; + } else { + characterModel.charCustomization.sheathOff = -1; + } + + if (characterModel.charCustomization.sheathMain === undefined) { + characterModel.charCustomization.sheathMain = 0; + } + if (characterModel.charCustomization.sheathOff === undefined) { + characterModel.charCustomization.sheathOff = 0; + } + } else { + characterModel.charCustomization.sheathMain = -1; + characterModel.charCustomization.sheathOff = -1; + } + + characterModel.mount.id = mountId; + createViewer(); } if (charData.mounts.length === 0) { @@ -233,6 +260,38 @@ $("#mounts").slideToggle("fast"); }); + const sheathTypes = { + 2: { + // One handed weapons + 0: 3, + 4: 3, + 7: 3, + 14: 3, + 15: 3, + 17: 3, + 20: 3, + + // Two handed weapons + 1: 1, + 5: 1, + 8: 1, + + // Others + 10: 2, // Staff + 6: 1, // Polearm + 2: 4, // Bow + 3: 4, // Gun + 18: 4, // Crossbow + 13: 0, // Fist weapons + 19: 3, // Wand + 20: 1, // Fishing pole + }, + 4: { + 0: 3, // Held in off-hand + 6: 9, // Shield + }, + }; + const characterModel = { type: ZamModelViewer.WOW, contentPath: "{{websiteRoot}}/data/", From 05f5cabf5c92128700bea79ee38476c77d12c8a6 Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Mon, 21 Mar 2022 00:29:12 +0100 Subject: [PATCH 04/52] docs(README): add demo link to top --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 22b8f20..fb292ca 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Suggest a Feature · Ask a Question + · + Demo

From 5b0ab5b11834afa6410993a31059d0c916d14d35 Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Mon, 21 Mar 2022 00:30:53 +0100 Subject: [PATCH 05/52] v1.0.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8500b72..510b931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "azerothcore-armory", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "azerothcore-armory", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@supercharge/promise-pool": "^2.1.0", diff --git a/package.json b/package.json index 7fb7492..25e6073 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azerothcore-armory", - "version": "1.0.0", + "version": "1.0.1", "description": "", "scripts": { "build": "tsc -p tsconfig.json", From 6933e8ee86cf1ec65a370f579b0a6716c7718a1b Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Mon, 21 Mar 2022 00:33:27 +0100 Subject: [PATCH 06/52] feat: add FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1eb62d1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: roboto From 4062263e8df40845f0a19c2116f9a75aa49668fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 Apr 2022 21:22:01 +0200 Subject: [PATCH 07/52] build(deps): bump minimist from 1.2.5 to 1.2.6 (#14) Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6. - [Release notes](https://github.com/substack/minimist/releases) - [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6) --- updated-dependencies: - dependency-name: minimist dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 510b931..9cdc5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3091,9 +3091,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "node_modules/mixin-deep": { "version": "1.3.2", @@ -7563,9 +7563,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "mixin-deep": { "version": "1.3.2", From 43ccb613418d85c4047d65b9df05f2486b977239 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 Apr 2022 18:47:41 +0200 Subject: [PATCH 08/52] build(deps): bump moment from 2.29.1 to 2.29.2 (#15) Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.2. - [Release notes](https://github.com/moment/moment/releases) - [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md) - [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.2) --- updated-dependencies: - dependency-name: moment dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cdc5bf..e1df4cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3130,9 +3130,9 @@ } }, "node_modules/moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "version": "2.29.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", + "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==", "engines": { "node": "*" } @@ -7592,9 +7592,9 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, "moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + "version": "2.29.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", + "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==" }, "morgan": { "version": "1.10.0", From 8906047a060b610bad599aebff2c9a52fd3a9f72 Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Thu, 14 Apr 2022 11:44:47 +0200 Subject: [PATCH 09/52] docs(README): fix typo --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fb292ca..574df96 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,14 @@ I also noticed that such a tool was frequently requested in the AzerothCore Disc ```sh git clone git@github.com:r-o-b-o-t-o/azerothcore-armory.git ``` -3. Install the dependencies: +2. Install the dependencies: ```sh cd azerothcore-armory/ npm install ``` -4. Configure the application: copy `config.default.json` to `config.json` or `.env.example` to `.env` and edit the resulting file. +3. Configure the application: copy `config.default.json` to `config.json` or `.env.example` to `.env` and edit the resulting file. See the [Configuration Reference](#configuration-reference) below for a description of all values. -5. Download the model viewer's data: +4. Download the model viewer's data: * Download from script: ```sh npm run build From fe21209817c6d4f227059af1213070ee0379f025 Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Fri, 6 May 2022 14:25:51 +0200 Subject: [PATCH 10/52] fix(layout): add missing closing html tag --- static/layout.hbs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/layout.hbs b/static/layout.hbs index b46cc1a..90c822f 100644 --- a/static/layout.hbs +++ b/static/layout.hbs @@ -36,3 +36,5 @@ {{{ body }}} + + From 8331e4dd735b0067e120e26961edb2e94dea19c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Borz=C3=AC?= Date: Fri, 6 May 2022 19:35:26 +0200 Subject: [PATCH 11/52] chore: apply prettier on all files (#8) * chore: apply prettier on all files * chore: add prettier npm script * Update .prettierrc Co-authored-by: Axel Cocat * Update .prettierignore Co-authored-by: Axel Cocat * Update .prettierignore Co-authored-by: Axel Cocat * Update .prettierignore Co-authored-by: Axel Cocat * chore: apply prettier with new conf * Revert "chore: apply prettier with new conf" This reverts commit db2a04c03fd1db2794fba5cf6a624d9fd74f0859. * chore: apply prettier correctly * chore: update .prettierignore * chore: restore viewer.min.js minification * chore: apply again prettier Co-authored-by: Stefano Borzi Co-authored-by: Axel Cocat --- .prettierignore | 21 ++ .prettierrc | 9 + package-lock.json | 22 ++ package.json | 2 + src/armory/Armory.ts | 35 +- src/armory/Config.ts | 16 +- src/armory/DataTablesSsp.ts | 64 ++-- src/armory/controllers/CharacterController.ts | 343 ++++++++++++++---- src/armory/controllers/GuildController.ts | 26 +- src/armory/controllers/IndexController.ts | 27 +- src/armory/data/DbcReader.ts | 55 ++- src/tools/fetchdata.ts | 69 ++-- static/js/emblems.js | 30 +- static/js/sync-url.js | 9 +- tsconfig.json | 4 +- 15 files changed, 504 insertions(+), 228 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0e6b8cc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +.tmp +.idea +.vscode +.npmrc +.nvmrc +node_modules/ +.env* +/data/ +build/ +build-tools/ +logs/ +reports/ +coverage/ +dist/ +**/*.md +**/*.yml +package-lock.json +static/**/*.min.js +static/**/*.min.css +static/**/*.hbs +config.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e1157be --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "printWidth": 140, + "useTabs": true, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/package-lock.json b/package-lock.json index e1df4cc..67e746b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@types/uuid": "^8.3.4", "concurrently": "^7.0.0", "nodemon": "^2.0.15", + "prettier": "^2.6.0", "rimraf": "^3.0.2", "typescript": "^4.5.5" } @@ -3644,6 +3645,21 @@ "node": ">=4" } }, + "node_modules/prettier": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.0.tgz", + "integrity": "sha512-m2FgJibYrBGGgQXNzfd0PuDGShJgRavjUoRCw1mZERIWVSXF0iLzLm+aOqTAbLnC3n6JzUhAA8uZnFVghHJ86A==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", @@ -7983,6 +7999,12 @@ "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", "dev": true }, + "prettier": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.0.tgz", + "integrity": "sha512-m2FgJibYrBGGgQXNzfd0PuDGShJgRavjUoRCw1mZERIWVSXF0iLzLm+aOqTAbLnC3n6JzUhAA8uZnFVghHJ86A==", + "dev": true + }, "pretty-ms": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", diff --git a/package.json b/package.json index 25e6073..c1ab37a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "node --expose-gc build/armory/main.js", "watch": "concurrently \"tsc -w --project .\" \"npm run nodemon\"", "nodemon": "nodemon -q -w build -w config.json build/armory/main.js", + "prettier": "prettier . -w", "fetchdata": "node build/tools/fetchdata.js", "cleardata": "rimraf data/bone data/meta data/mo3 data/textures" }, @@ -26,6 +27,7 @@ "@types/uuid": "^8.3.4", "concurrently": "^7.0.0", "nodemon": "^2.0.15", + "prettier": "^2.6.0", "rimraf": "^3.0.2", "typescript": "^4.5.5" }, diff --git a/src/armory/Armory.ts b/src/armory/Armory.ts index dc94a85..7f457da 100644 --- a/src/armory/Armory.ts +++ b/src/armory/Armory.ts @@ -92,15 +92,18 @@ export class Armory { } app.locals.locals = locals; - app.engine(".hbs", handlebarsEngine({ - extname: "hbs", - partialsDir: path.join(process.cwd(), "static", "partials"), - layoutsDir: path.join(process.cwd(), "static"), - defaultLayout: "layout.hbs", - helpers: { - ...require("handlebars-helpers")(), - }, - })); + app.engine( + ".hbs", + handlebarsEngine({ + extname: "hbs", + partialsDir: path.join(process.cwd(), "static", "partials"), + layoutsDir: path.join(process.cwd(), "static"), + defaultLayout: "layout.hbs", + helpers: { + ...require("handlebars-helpers")(), + }, + }), + ); app.set("view engine", "handlebars"); app.set("views", path.join(process.cwd(), "static")); @@ -122,11 +125,13 @@ export class Armory { } return req.socket.remoteAddress; }); - app.use(morgan(":method :url :status - ID :id - IP :ip - :response-time ms", { - stream: { - write: (msg) => this.logger.http(msg.trim()), - }, - })); + app.use( + morgan(":method :url :status - ID :id - IP :ip - :response-time ms", { + stream: { + write: (msg) => this.logger.http(msg.trim()), + }, + }), + ); app.use("/js", express.static(`static/js`)); app.use("/css", express.static(`static/css`)); @@ -198,7 +203,7 @@ export class Armory { } public getRealm(realm: string): IRealmConfig { - return this.config.realms.find(r => r.name.toLowerCase() === realm.toLowerCase()); + return this.config.realms.find((r) => r.name.toLowerCase() === realm.toLowerCase()); } public async getDatabaseCharset(realm: string): Promise { diff --git a/src/armory/Config.ts b/src/armory/Config.ts index 3a9427f..c3af755 100644 --- a/src/armory/Config.ts +++ b/src/armory/Config.ts @@ -105,7 +105,7 @@ export class Config { let i = 0; while (true) { const key = Config.getEnvKey(parentName + i); - const found = Object.keys(process.env).some(k => k.startsWith(key)); + const found = Object.keys(process.env).some((k) => k.startsWith(key)); if (!found) { break; } @@ -131,10 +131,14 @@ export class Config { } private static getEnvKey(key: string): string { - return Config.envPrefix + "_" + key - .replace(/\./g, "__") - .replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) - .toUpperCase(); + return ( + Config.envPrefix + + "_" + + key + .replace(/\./g, "__") + .replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) + .toUpperCase() + ); } private static parseEnvValue(value: string, model: any): any { @@ -173,4 +177,4 @@ export class Config { } return missing; } -}; +} diff --git a/src/armory/DataTablesSsp.ts b/src/armory/DataTablesSsp.ts index 36324f9..ee8b8e9 100644 --- a/src/armory/DataTablesSsp.ts +++ b/src/armory/DataTablesSsp.ts @@ -38,22 +38,22 @@ export class DataTablesSsp { private start: number; private length: number; private _order: { - column: number, - dir: string, + column: number; + dir: string; }[]; private columns: { - data: number, - name: string, - searchable: boolean, - orderable: boolean, + data: number; + name: string; + searchable: boolean; + orderable: boolean; search: { - value: string, - regex: boolean, - } + value: string; + regex: boolean; + }; }[]; private search: { - value: string, - regex: boolean, + value: string; + regex: boolean; }; private wheres: string[] = []; @@ -69,18 +69,18 @@ export class DataTablesSsp { this.start = parseInt(query.start as string, 10); this.length = parseInt(query.length as string, 10); this.draw = parseInt(query.draw as string, 10); - this._order = (query.order as { column: string, dir: string }[]) - .map(order => { return { column: parseInt(order.column, 10), dir: order.dir, }; }); - this.columns = (query.columns as { data: string, name: string, searchable: string, orderable: string, search: any }[]) - .map(column => { - return { - data: parseInt(column.data, 10), - name: column.name, - searchable: column.searchable === "true", - orderable: column.orderable === "true", - search: { value: column.search.value, regex: column.search.regex === "true" }, - }; - }); + this._order = (query.order as { column: string; dir: string }[]).map((order) => { + return { column: parseInt(order.column, 10), dir: order.dir }; + }); + this.columns = (query.columns as { data: string; name: string; searchable: string; orderable: string; search: any }[]).map((column) => { + return { + data: parseInt(column.data, 10), + name: column.name, + searchable: column.searchable === "true", + orderable: column.orderable === "true", + search: { value: column.search.value, regex: column.search.regex === "true" }, + }; + }); this.search = { value: (query.search as any).value as string, regex: (query.search as any).regex === "true", @@ -93,7 +93,7 @@ export class DataTablesSsp { } private colSettingsToStr(colSettings: IColumnSettings) { - const db = colSettings.database ? ("`" + colSettings.database + "`.") : ""; + const db = colSettings.database ? "`" + colSettings.database + "`." : ""; return `${db}\`${colSettings.table || this.table}\`.\`${colSettings.name}\``; } @@ -153,19 +153,16 @@ export class DataTablesSsp { this.filterBindings.push(`%${this.search.value}%`); } if (filterWheres.length > 0) { - this.filterWhereSql = "(" + filterWheres.map(w => `(${w})`).join(" OR ") + ")"; + this.filterWhereSql = "(" + filterWheres.map((w) => `(${w})`).join(" OR ") + ")"; } } - this.customWhereSql = this.wheres.map(w => `(${w})`).join(" AND "); + this.customWhereSql = this.wheres.map((w) => `(${w})`).join(" AND "); return this; } public sql(): string { - const columns = [ - ...this.columnSettings.map(c => this.colSettingsToStr(c)), - ...this.extraDataColumns, - ]; + const columns = [...this.columnSettings.map((c) => this.colSettingsToStr(c)), ...this.extraDataColumns]; return ` SELECT ${columns.join(", ")} FROM ${this.table} @@ -199,10 +196,7 @@ export class DataTablesSsp { } public async run(queryTimeout: number = 10_000): Promise { - this.limit() - .order() - .join() - .filter(); + this.limit().order().join().filter(); const bindings = [...this.filterBindings, ...this.customBindings]; @@ -226,7 +220,7 @@ export class DataTablesSsp { values: bindings, timeout: queryTimeout, }); - rows = (rows as any[][]).map(row => { + rows = (rows as any[][]).map((row) => { for (let i = 0; i < this.columnSettings.length; ++i) { const col = this.columnSettings[i]; if (col.formatter !== undefined) { diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index fcdbf19..243c0ac 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -90,7 +90,7 @@ export class CharacterController { this.itemInventoryTypes = {}; const itemsRetail = await this.armory.dbc.itemRetail().toArray(); for await (const item of this.armory.dbc.item()) { - const retailItem = itemsRetail.find(row => row.id === item.id); + const retailItem = itemsRetail.find((row) => row.id === item.id); if (retailItem !== undefined) { this.itemInventoryTypes[item.id] = retailItem.inventoryType; } @@ -109,7 +109,7 @@ export class CharacterController { } this.gemItems = {}; - for await (const row of this.armory.dbc.item().filter(item => item.classId === ItemClassGem)) { + for await (const row of this.armory.dbc.item().filter((item) => item.classId === ItemClassGem)) { this.gemItems[row.id] = true; } @@ -127,16 +127,17 @@ export class CharacterController { this.itemSocketBonuses[row.entry] = row.socketBonus; } - const mountSpells = await this.armory.dbc.spell() - .filter(m => m.mechanic === SpellMechanicMounted) + const mountSpells = await this.armory.dbc + .spell() + .filter((m) => m.mechanic === SpellMechanicMounted) .toArray(); - this.mountSpells = mountSpells.map(spell => spell.id); + this.mountSpells = mountSpells.map((spell) => spell.id); this.mountBySpellId = {}; for (const spell of mountSpells) { - const mount = await this.armory.dbc.mount().find(m => m.sourceSpellId === spell.id); - const icon = await this.armory.dbc.spellIcon().find(icon => icon.id === spell.spellIconId); + const mount = await this.armory.dbc.mount().find((m) => m.sourceSpellId === spell.id); + const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === spell.spellIconId); if (mount !== undefined) { - const display = await this.armory.dbc.mountDisplay().find(d => d.mountId === mount.id); + const display = await this.armory.dbc.mountDisplay().find((d) => d.mountId === mount.id); if (display !== undefined) { this.mountBySpellId[spell.id] = { creatureDisplayId: display.creatureDisplayInfoId, @@ -171,7 +172,7 @@ export class CharacterController { const equipmentData = await this.getEquipmentData(realmName, charData.guid); const customization = this.getCustomizationOptions(charData); - const equipment = equipmentData.map(row => { + const equipment = equipmentData.map((row) => { (row as any).icon = this.itemIcons[row.itemEntry]; (row as any).gems = this.getGemsFromEnchantments(row.enchantments); (row as any).enchantments = this.filterEnchantments(row.itemEntry, row.enchantments); @@ -264,7 +265,7 @@ export class CharacterController { res.json({ categories: await this.armory.dbc.achievementCategory().toArray(), - ...await this.getAchievements(realm.name, charData), + ...(await this.getAchievements(realm.name, charData)), }); } @@ -344,7 +345,7 @@ export class CharacterController { const data = rows as RowDataPacket[] as IEquipmentData[]; for (const row of data) { - const item = await this.armory.dbc.item().find(item => item.id === row.itemEntry); + const item = await this.armory.dbc.item().find((item) => item.id === row.itemEntry); row.classId = item.classId; row.subclassId = item.subclassId; } @@ -363,29 +364,27 @@ export class CharacterController { timeout: this.armory.config.dbQueryTimeout, }); - return (rows as RowDataPacket[]) - .map(row => this.mountBySpellId[row.spell]) - .filter(m => m !== undefined); + return (rows as RowDataPacket[]).map((row) => this.mountBySpellId[row.spell]).filter((m) => m !== undefined); } private async getModelViewerItems(equipmentData: IEquipmentData[], charClass: number): Promise { if (charClass !== 3) { // Keep ranged weapon only if the character is a hunter - equipmentData = equipmentData.filter(row => row.slot !== 17); + equipmentData = equipmentData.filter((row) => row.slot !== 17); } - const visibleEquipment = equipmentData. - filter(item => + const visibleEquipment = equipmentData.filter( + (item) => [0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18].includes(item.slot) && // visible slots - item.itemEntry !== 5976 // filter out Guild Tabard (displays blank otherwise) - ); + item.itemEntry !== 5976, // filter out Guild Tabard (displays blank otherwise) + ); const items: number[][] = []; for (const equipment of visibleEquipment) { - const modifiedAppearance = await this.armory.dbc.itemModifiedAppearance().find(row => row.itemId === equipment.itemEntry); + const modifiedAppearance = await this.armory.dbc.itemModifiedAppearance().find((row) => row.itemId === equipment.itemEntry); if (modifiedAppearance === undefined) { continue; } - const appearance = await this.armory.dbc.itemAppearance().find(row => row.id === modifiedAppearance.itemAppearanceId); + const appearance = await this.armory.dbc.itemAppearance().find((row) => row.id === modifiedAppearance.itemAppearanceId); if (appearance === undefined) { continue; } @@ -400,52 +399,53 @@ export class CharacterController { return enchantments .trim() .split(" ") - .map(enchant => parseInt(enchant)) - .filter(enchant => enchant !== 0); + .map((enchant) => parseInt(enchant)) + .filter((enchant) => enchant !== 0); } private getGemsFromEnchantments(enchantments: string): number[] { return this.parseEnchantmentsString(enchantments) - .filter(enchant => enchant in this.enchantSrcItems && this.enchantSrcItems[enchant] in this.gemItems) - .map(enchant => this.enchantSrcItems[enchant]); + .filter((enchant) => enchant in this.enchantSrcItems && this.enchantSrcItems[enchant] in this.gemItems) + .map((enchant) => this.enchantSrcItems[enchant]); } private filterEnchantments(item: number, enchantments: string): number[] { const socketBonus = this.itemSocketBonuses[item]; - return this.parseEnchantmentsString(enchantments) - .filter(enchant => enchant in this.enchantSrcItems && !(this.enchantSrcItems[enchant] in this.gemItems) && enchant !== socketBonus); + return this.parseEnchantmentsString(enchantments).filter( + (enchant) => enchant in this.enchantSrcItems && !(this.enchantSrcItems[enchant] in this.gemItems) && enchant !== socketBonus, + ); } private getCustomizationOptions(charData: ICharacterData): ICustomizationOption[] { const data = this.armory.characterCustomization.getCharacterCustomizationData(charData.race, charData.gender); const options = []; const setOptionByChoiceIndex = (optionName: string, choiceIndex: number) => { - const option = data.Options.find(opt => opt.Name === optionName); + const option = data.Options.find((opt) => opt.Name === optionName); if (option !== undefined) { - const choice = option.Choices.find(choice => choice.OrderIndex === choiceIndex); + const choice = option.Choices.find((choice) => choice.OrderIndex === choiceIndex); if (choice !== undefined) { options.push({ optionId: option.Id, choiceId: choice.Id }); } } }; const setOptionByChoiceName = (optionName: string, choiceName: string) => { - const option = data.Options.find(opt => opt.Name === optionName); + const option = data.Options.find((opt) => opt.Name === optionName); if (option !== undefined) { - const choice = option.Choices.find(ch => ch.Name === choiceName); + const choice = option.Choices.find((ch) => ch.Name === choiceName); if (choice !== undefined) { options.push({ optionId: option.Id, choiceId: choice.Id }); } } }; const setOptionByChoiceId = (optionName: string, choiceId: number) => { - const option = data.Options.find(opt => opt.Name === optionName); + const option = data.Options.find((opt) => opt.Name === optionName); if (option !== undefined) { options.push({ optionId: option.Id, choiceId: choiceId }); } }; const optionMapping = { - "Face": charData.face, + Face: charData.face, "Skin Color": charData.skin, "Hair Style": charData.hairStyle, "Hair Color": charData.hairColor, @@ -458,19 +458,54 @@ export class CharacterController { switch (charData.race) { case 1: // Human if (charData.gender === 0) { - setOptionByChoiceName("Mustache", { 0: "Horseshoe", 1: "Brush", 2: "Horseshoe", 3: "None", 4: "Brush", 5: "Brush", 6: "Horseshoe", 7: "Brush", 8: "None" }[charData.facialStyle]); - setOptionByChoiceName("Beard", { 0: "Short", 1: "Chin Puff", 2: "Soul Patch", 3: "Goatee", 4: "Goatee", 5: "None", 6: "Goatee", 7: "None", 8: "None" }[charData.facialStyle]); - setOptionByChoiceName("Sideburns", { 0: "Medium", 1: "None", 2: "None", 3: "Medium", 4: "Long", 5: "Long", 6: "None", 8: "None", 7: "None" }[charData.facialStyle]); + setOptionByChoiceName( + "Mustache", + { 0: "Horseshoe", 1: "Brush", 2: "Horseshoe", 3: "None", 4: "Brush", 5: "Brush", 6: "Horseshoe", 7: "Brush", 8: "None" }[ + charData.facialStyle + ], + ); + setOptionByChoiceName( + "Beard", + { 0: "Short", 1: "Chin Puff", 2: "Soul Patch", 3: "Goatee", 4: "Goatee", 5: "None", 6: "Goatee", 7: "None", 8: "None" }[ + charData.facialStyle + ], + ); + setOptionByChoiceName( + "Sideburns", + { 0: "Medium", 1: "None", 2: "None", 3: "Medium", 4: "Long", 5: "Long", 6: "None", 8: "None", 7: "None" }[charData.facialStyle], + ); setOptionByChoiceName("Eyebrows", "Natural"); setOptionByChoiceName("Face Shape", "Narrow"); - setOptionByChoiceId("Eye Color", { 0: 4138, 1: 4140, 2: 4130, 3: 4136, 4: 4141, 5: 4134, 6: 4130, 7: 4138, 8: 4144, 9: 4135, 10: 4126, 11: 4136 }[charData.face]); + setOptionByChoiceId( + "Eye Color", + { 0: 4138, 1: 4140, 2: 4130, 3: 4136, 4: 4141, 5: 4134, 6: 4130, 7: 4138, 8: 4144, 9: 4135, 10: 4126, 11: 4136 }[charData.face], + ); } else { setOptionByChoiceIndex("Piercings", charData.facialStyle); setOptionByChoiceName("Eyebrows", "Natural"); setOptionByChoiceName("Face Shape", "Narrow"); setOptionByChoiceName("Makeup", "None"); setOptionByChoiceName("Necklace", "None"); - setOptionByChoiceId("Eye Color", { 0: 4162, 1: 4153, 2: 4161, 3: 4164, 4: 4154, 5: 4160, 6: 4160, 7: 4157, 8: 4152, 9: 4154, 10: 4155, 11: 4165, 12: 4163, 13: 4155, 14: 4151 }[charData.face]); + setOptionByChoiceId( + "Eye Color", + { + 0: 4162, + 1: 4153, + 2: 4161, + 3: 4164, + 4: 4154, + 5: 4160, + 6: 4160, + 7: 4157, + 8: 4152, + 9: 4154, + 10: 4155, + 11: 4165, + 12: 4163, + 13: 4155, + 14: 4151, + }[charData.face], + ); } if (charData.class === 6) { // Death Knight @@ -482,14 +517,32 @@ export class CharacterController { setOptionByChoiceIndex("Tattoo Color", 0); setOptionByChoiceIndex("Eyebrows", 0); if (charData.gender === 0) { - setOptionByChoiceName("Mustache", { 0: "Trimmed", 1: "Bushy", 2: "Grand", 3: "Thin Braids", 4: "Wise", 5: "Thick Braids", 6: "Fancy", 7: "Bold", 8: "Tied", 9: "None", 10: "None", }[charData.facialStyle]); + setOptionByChoiceName( + "Mustache", + { + 0: "Trimmed", + 1: "Bushy", + 2: "Grand", + 3: "Thin Braids", + 4: "Wise", + 5: "Thick Braids", + 6: "Fancy", + 7: "Bold", + 8: "Tied", + 9: "None", + 10: "None", + }[charData.facialStyle], + ); setOptionByChoiceIndex("Beard", charData.facialStyle); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Earrings", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 0, 5: 4 }[charData.facialStyle]); - setOptionByChoiceName("Piercings", { 0: "None", 1: "None", 2: "None", 3: "None", 4: "Right Nostril", 5: "None" }[charData.facialStyle]); + setOptionByChoiceName( + "Piercings", + { 0: "None", 1: "None", 2: "None", 3: "None", 4: "Right Nostril", 5: "None" }[charData.facialStyle], + ); setOptionByChoiceIndex("Eye Color", 0); // TODO } if (charData.class === 6) { @@ -519,7 +572,10 @@ export class CharacterController { setOptionByChoiceName("Ears", "Thin"); setOptionByChoiceName("Scars", "None"); if (charData.gender === 0) { - setOptionByChoiceName("Sideburns", { 0: "None", 1: "Groomed", 2: "None", 3: "Short", 4: "Medium", 5: "Groomed" }[charData.facialStyle]); + setOptionByChoiceName( + "Sideburns", + { 0: "None", 1: "Groomed", 2: "None", 3: "Short", 4: "Medium", 5: "Groomed" }[charData.facialStyle], + ); setOptionByChoiceName("Mustache", { 0: "None", 1: "Groomed", 2: "None", 3: "Thin", 4: "None", 5: "None" }[charData.facialStyle]); setOptionByChoiceName("Beard", { 0: "None", 1: "Trimmed", 2: "Full", 3: "None", 4: "Short", 5: "Long" }[charData.facialStyle]); setOptionByChoiceName("Eyebrows", { 0: "Shaved", 1: "Short", 2: "Long", 3: "Flat", 4: "Short", 5: "Owl" }[charData.facialStyle]); @@ -549,10 +605,21 @@ export class CharacterController { setOptionByChoiceName("Horn Decoration", "None"); setOptionByChoiceName("Tail", charData.gender === 0 ? "Long" : "Short"); if (charData.gender === 0) { - setOptionByChoiceName("Facial Hair", { 0: "Bare", 1: "Bare", 2: "Burns", 3: "Chops", 4: "Mustache", 5: "Soul Patch", 6: "Handlebar", 7: "Bare" }[charData.facialStyle]); - setOptionByChoiceName("Tendrils", { 0: "None", 1: "Splayed", 2: "Double", 3: "Fanned", 4: "Single", 5: "Paired", 6: "Uniform", 7: "Twin" }[charData.facialStyle]); + setOptionByChoiceName( + "Facial Hair", + { 0: "Bare", 1: "Bare", 2: "Burns", 3: "Chops", 4: "Mustache", 5: "Soul Patch", 6: "Handlebar", 7: "Bare" }[ + charData.facialStyle + ], + ); + setOptionByChoiceName( + "Tendrils", + { 0: "None", 1: "Splayed", 2: "Double", 3: "Fanned", 4: "Single", 5: "Paired", 6: "Uniform", 7: "Twin" }[charData.facialStyle], + ); } else { - setOptionByChoiceName("Horns", { 0: "Sweeping", 1: "Curled", 2: "Curved", 3: "Thick", 4: "Wide", 5: "Grand", 6: "Short" }[charData.facialStyle]); + setOptionByChoiceName( + "Horns", + { 0: "Sweeping", 1: "Curled", 2: "Curved", 3: "Thick", 4: "Wide", 5: "Grand", 6: "Short" }[charData.facialStyle], + ); } if (charData.class === 6) { // Death Knight @@ -568,8 +635,28 @@ export class CharacterController { setOptionByChoiceName("War Paint", "None"); setOptionByChoiceName("War Paint Color", "None"); if (charData.gender === 0) { - setOptionByChoiceName("Beard", { 0: "None", 1: "Stubble", 2: "Thick", 3: "Full", 4: "Tied", 5: "Braid", 6: "Twin Braids", 7: "None", 8: "Ringed", 9: "Split", 10: "Goatee" }[charData.facialStyle]); - setOptionByChoiceName("Sideburns", { 0: "None", 1: "None", 2: "Full", 3: "Low", 4: "Full", 5: "None", 6: "None", 7: "Braids", 8: "None", 9: "Full", 10: "Thick" }[charData.facialStyle]); + setOptionByChoiceName( + "Beard", + { + 0: "None", + 1: "Stubble", + 2: "Thick", + 3: "Full", + 4: "Tied", + 5: "Braid", + 6: "Twin Braids", + 7: "None", + 8: "Ringed", + 9: "Split", + 10: "Goatee", + }[charData.facialStyle], + ); + setOptionByChoiceName( + "Sideburns", + { 0: "None", 1: "None", 2: "Full", 3: "Low", 4: "Full", 5: "None", 6: "None", 7: "Braids", 8: "None", 9: "Full", 10: "Thick" }[ + charData.facialStyle + ], + ); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceName("Tusks", "Natural"); @@ -589,13 +676,71 @@ export class CharacterController { case 5: // Undead setOptionByChoiceName("Skin Type", "Bony"); if (charData.gender === 0) { - setOptionByChoiceName("Jaw Features", { 0: "Intact", 1: "Rot-Kissed", 2: "Intact", 3: "Slackjawed", 4: "Drooler", 5: "Intact", 6: "Slackjawed", 7: "Drooler", 8: "Bonejawed", 9: "Jawsome", 10: "Toothy", 11: "Unhinged", 12: "Cheeky", 13: "Loose", 14: "Intact", 15: "Slackjawed", 16: "Slobber" }[charData.facialStyle]); - setOptionByChoiceIndex("Face Features", { 0: 0, 1: 0, 2: 1, 3: 1, 4: 1, 5: 2, 6: 3, 7: 3, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, 13: 0, 14: 4, 15: 4, 16: 0 }[charData.facialStyle]); - setOptionByChoiceId("Eye Color", { 0: 5330, 1: 5330, 2: 6304, 3: 6304, 4: 6304, 5: 5330, 6: 5330, 7: 5330, 8: 5330, 9: 5330, 10: 6304, 11: 6304, 12: 5330, 13: 5330, 14: 5330, 15: 5330, 16: 5330 }[charData.facialStyle]); + setOptionByChoiceName( + "Jaw Features", + { + 0: "Intact", + 1: "Rot-Kissed", + 2: "Intact", + 3: "Slackjawed", + 4: "Drooler", + 5: "Intact", + 6: "Slackjawed", + 7: "Drooler", + 8: "Bonejawed", + 9: "Jawsome", + 10: "Toothy", + 11: "Unhinged", + 12: "Cheeky", + 13: "Loose", + 14: "Intact", + 15: "Slackjawed", + 16: "Slobber", + }[charData.facialStyle], + ); + setOptionByChoiceIndex( + "Face Features", + { 0: 0, 1: 0, 2: 1, 3: 1, 4: 1, 5: 2, 6: 3, 7: 3, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, 13: 0, 14: 4, 15: 4, 16: 0 }[ + charData.facialStyle + ], + ); + setOptionByChoiceId( + "Eye Color", + { + 0: 5330, + 1: 5330, + 2: 6304, + 3: 6304, + 4: 6304, + 5: 5330, + 6: 5330, + 7: 5330, + 8: 5330, + 9: 5330, + 10: 6304, + 11: 6304, + 12: 5330, + 13: 5330, + 14: 5330, + 15: 5330, + 16: 5330, + }[charData.facialStyle], + ); } else { - setOptionByChoiceName("Face Features", { 0: "None", 1: "None", 2: "Strapped", 3: "Rotting", 4: "None", 5: "None", 6: "None", 7: "Putrid" }[charData.facialStyle]); - setOptionByChoiceName("Jaw Features", { 0: "Intact", 1: "Stitched", 2: "Intact", 3: "Intact", 4: "Bonejawed", 5: "Toothy", 6: "Cheeky", 7: "Intact" }[charData.facialStyle]); - setOptionByChoiceId("Eye Color", { 0: 5337, 1: 5337, 2: 6305, 3: 5337, 4: 5337, 5: 6305, 6: 5337, 7: 5337 }[charData.facialStyle]); + setOptionByChoiceName( + "Face Features", + { 0: "None", 1: "None", 2: "Strapped", 3: "Rotting", 4: "None", 5: "None", 6: "None", 7: "Putrid" }[charData.facialStyle], + ); + setOptionByChoiceName( + "Jaw Features", + { 0: "Intact", 1: "Stitched", 2: "Intact", 3: "Intact", 4: "Bonejawed", 5: "Toothy", 6: "Cheeky", 7: "Intact" }[ + charData.facialStyle + ], + ); + setOptionByChoiceId( + "Eye Color", + { 0: 5337, 1: 5337, 2: 6305, 3: 5337, 4: 5337, 5: 6305, 6: 5337, 7: 5337 }[charData.facialStyle], + ); } if (charData.class === 6) { // Death Knight @@ -614,9 +759,18 @@ export class CharacterController { setOptionByChoiceName("Body Paint", "None"); setOptionByChoiceIndex("Paint Color", 0); if (charData.gender === 0) { - setOptionByChoiceName("Hair", { 0: "Mane", 1: "Braids", 2: "Chops", 3: "Sideburns", 4: "Mane", 5: "Wrapped", 6: "Braids" }[charData.facialStyle]); - setOptionByChoiceName("Facial Hair", { 0: "Clean", 1: "Braid", 2: "Beard", 3: "Wrapped", 4: "Curtain", 5: "Clean", 6: "Split" }[charData.facialStyle]); - setOptionByChoiceName("Nose Ring", { 0: "None", 1: "Small", 2: "Open", 3: "None", 4: "None", 5: "Bead", 6: "Open" }[charData.facialStyle]); + setOptionByChoiceName( + "Hair", + { 0: "Mane", 1: "Braids", 2: "Chops", 3: "Sideburns", 4: "Mane", 5: "Wrapped", 6: "Braids" }[charData.facialStyle], + ); + setOptionByChoiceName( + "Facial Hair", + { 0: "Clean", 1: "Braid", 2: "Beard", 3: "Wrapped", 4: "Curtain", 5: "Clean", 6: "Split" }[charData.facialStyle], + ); + setOptionByChoiceName( + "Nose Ring", + { 0: "None", 1: "Small", 2: "Open", 3: "None", 4: "None", 5: "Bead", 6: "Open" }[charData.facialStyle], + ); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Hair", charData.facialStyle); @@ -634,8 +788,38 @@ export class CharacterController { setOptionByChoiceName("Body Paint Color", "None"); setOptionByChoiceName("Piercing", "None"); if (charData.gender === 0) { - setOptionByChoiceName("Tusks", { 0: "Tusked", 1: "Gougers", 2: "Mammoth", 3: "Spears", 4: "Bridle", 5: "Tusked", 6: "Gougers", 7: "Mammoth", 8: "Spears", 9: "Bridle", 10: "Gougers" }[charData.facialStyle]); - setOptionByChoiceName("Face Paint", { 0: "None", 1: "None", 2: "None", 3: "None", 4: "None", 5: "Berserker", 6: "Fangs", 7: "Mask", 8: "Oni", 9: "Prophet", 10: "War" }[charData.facialStyle]); + setOptionByChoiceName( + "Tusks", + { + 0: "Tusked", + 1: "Gougers", + 2: "Mammoth", + 3: "Spears", + 4: "Bridle", + 5: "Tusked", + 6: "Gougers", + 7: "Mammoth", + 8: "Spears", + 9: "Bridle", + 10: "Gougers", + }[charData.facialStyle], + ); + setOptionByChoiceName( + "Face Paint", + { + 0: "None", + 1: "None", + 2: "None", + 3: "None", + 4: "None", + 5: "Berserker", + 6: "Fangs", + 7: "Mask", + 8: "Oni", + 9: "Prophet", + 10: "War", + }[charData.facialStyle], + ); setOptionByChoiceIndex("Face Paint Color", charData.hairColor + 1); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceIndex("Eye Color", 0); // TODO @@ -712,16 +896,18 @@ export class CharacterController { } private async getTalentTrees(classId: number) { - const items = await this.armory.dbc.talentTab() - .filter(tab => tab.classMask === Math.pow(2, classId - 1)) - .map(async tab => { - const icon = await this.armory.dbc.spellIcon().find(icon => icon.id === tab.spellIconId); - const spells = await this.armory.dbc.talent() - .filter(row => row.tabId === tab.id) - .map(async row => { - const spell = await this.armory.dbc.spell().find(spell => spell.id === row.spellRank0); - const icon = await this.armory.dbc.spellIcon().find(icon => icon.id === spell?.spellIconId); - return { ...row, icon: this.processSpellIconTexture(icon?.textureFilename ?? ""), }; + const items = await this.armory.dbc + .talentTab() + .filter((tab) => tab.classMask === Math.pow(2, classId - 1)) + .map(async (tab) => { + const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === tab.spellIconId); + const spells = await this.armory.dbc + .talent() + .filter((row) => row.tabId === tab.id) + .map(async (row) => { + const spell = await this.armory.dbc.spell().find((spell) => spell.id === row.spellRank0); + const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === spell?.spellIconId); + return { ...row, icon: this.processSpellIconTexture(icon?.textureFilename ?? "") }; }) .toArray(); return { @@ -735,11 +921,7 @@ export class CharacterController { } private processSpellIconTexture(texturePath: string): string { - return texturePath - .toLowerCase() - .replace("interface\\icons\\", "") - .replace("interface\\spellbook\\", "") - .replace(/\.$/, ""); + return texturePath.toLowerCase().replace("interface\\icons\\", "").replace("interface\\spellbook\\", "").replace(/\.$/, ""); } private async getGlyphs(realm: string, character: number): Promise { @@ -755,9 +937,9 @@ export class CharacterController { const glyphs = [[], []]; for (const row of rows as RowDataPacket[]) { - const glyphIds = [row.glyph1, row.glyph2, row.glyph3, row.glyph4, row.glyph5, row.glyph6].filter(id => id !== 0); + const glyphIds = [row.glyph1, row.glyph2, row.glyph3, row.glyph4, row.glyph5, row.glyph6].filter((id) => id !== 0); for (const glyphId of glyphIds) { - const glyph = await this.armory.dbc.glyphProperties().find(g => g.id === glyphId); + const glyph = await this.armory.dbc.glyphProperties().find((g) => g.id === glyphId); if (glyph === undefined) { continue; } @@ -768,11 +950,12 @@ export class CharacterController { return glyphs; } - private async getAchievements(realm: string, charData: ICharacterData): Promise<{ achievements: any[]; earned: { [key: number]: any }; }> { - const promises = await this.armory.dbc.achievement() - .filter(ach => ach.faction === -1 || ach.faction === Utils.getFactionFromRaceId(charData.race)) + private async getAchievements(realm: string, charData: ICharacterData): Promise<{ achievements: any[]; earned: { [key: number]: any } }> { + const promises = await this.armory.dbc + .achievement() + .filter((ach) => ach.faction === -1 || ach.faction === Utils.getFactionFromRaceId(charData.race)) .map(async (ach) => { - const icon = await this.armory.dbc.spellIcon().find(icon => icon.id === ach.iconId); + const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === ach.iconId); return { id: ach.id, category: ach.category, @@ -807,7 +990,7 @@ export class CharacterController { }; } - private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number, today: number, yesterday: number }> { + private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number; today: number; yesterday: number }> { const [rows, fields] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT totalKills, todayKills, yesterdayKills @@ -841,7 +1024,7 @@ export class CharacterController { timeout: this.armory.config.dbQueryTimeout, }); - return (rows as RowDataPacket[]).map(row => { + return (rows as RowDataPacket[]).map((row) => { row.emblem = Utils.makeEmblemObject(row, false); return row; }); diff --git a/src/armory/controllers/GuildController.ts b/src/armory/controllers/GuildController.ts index df92261..6296ab5 100644 --- a/src/armory/controllers/GuildController.ts +++ b/src/armory/controllers/GuildController.ts @@ -64,24 +64,28 @@ export class GuildController { { name: "name", table: "characters", collation: `${charSet}_general_ci` }, { name: "rank" }, { name: "level", table: "characters" }, - { name: "class", table: "characters", formatter: cls => Utils.classNames[cls] }, + { name: "class", table: "characters", formatter: (cls) => Utils.classNames[cls] }, { name: "race", table: "characters", formatter: (race, row) => `${Utils.raceNames[race]}_${row[6] === 0 ? "male" : "female"}` }, - { name: "online", table: "characters", formatter: online => online === 1 }, + { name: "online", table: "characters", formatter: (online) => online === 1 }, ]); - ssp.joins = [ - { table1: "guild_member", column1: "guid", table2: "characters", column2: "guid", kind: "LEFT" }, - ]; + ssp.joins = [{ table1: "guild_member", column1: "guid", table2: "characters", column2: "guid", kind: "LEFT" }]; ssp.extraDataColumns = ["`characters`.`gender`"]; if (this.armory.config.hideGameMasters) { - ssp.joins.push({ table1: "characters", column1: "account", table2: "account_access", column2: "id", database2: realm.authDatabase, kind: "LEFT" }); - ssp = ssp.where(`\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`); + ssp.joins.push({ + table1: "characters", + column1: "account", + table2: "account_access", + column2: "id", + database2: realm.authDatabase, + kind: "LEFT", + }); + ssp = ssp.where( + `\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`, + ); } - const result = await ssp - .where("`guildid` = ?", guildId) - .where("`deleteInfos_Account` IS NULL") - .run(this.armory.config.dbQueryTimeout); + const result = await ssp.where("`guildid` = ?", guildId).where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); const ranks = await this.getGuildRanks(realm, guildId); (result as any).ranks = {}; diff --git a/src/armory/controllers/IndexController.ts b/src/armory/controllers/IndexController.ts index 0322a6f..954ad01 100644 --- a/src/armory/controllers/IndexController.ts +++ b/src/armory/controllers/IndexController.ts @@ -14,15 +14,13 @@ export class IndexController { public async index(req: express.Request, res: express.Response): Promise { res.render("index.hbs", { title: "Armory", - realms: this.armory.config.realms.map(r => r.name), + realms: this.armory.config.realms.map((r) => r.name), }); } public async search(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.query.realm as string; - const realm = realmName === undefined ? - this.armory.config.realms[0] : - this.armory.config.realms.find(r => r.name === realmName); + const realm = realmName === undefined ? this.armory.config.realms[0] : this.armory.config.realms.find((r) => r.name === realmName); if (realm === undefined) { return next(400); } @@ -34,9 +32,9 @@ export class IndexController { { name: "name", collation: `${charSet}_general_ci` }, { table: "guild", name: "name" }, { name: "level" }, - { name: "class", formatter: cls => Utils.classNames[cls] }, + { name: "class", formatter: (cls) => Utils.classNames[cls] }, { name: "race", formatter: (race, row) => `${Utils.raceNames[race]}_${row[6] === 0 ? "male" : "female"}` }, - { name: "online", formatter: online => online === 1 }, + { name: "online", formatter: (online) => online === 1 }, ]); ssp.joins = [ { table1: "characters", column1: "guid", table2: "guild_member", column2: "guid", kind: "LEFT" }, @@ -45,13 +43,20 @@ export class IndexController { ssp.extraDataColumns = ["`characters`.`gender`"]; if (this.armory.config.hideGameMasters) { - ssp.joins.push({ table1: "characters", column1: "account", table2: "account_access", column2: "id", database2: realm.authDatabase, kind: "LEFT" }); - ssp = ssp.where(`\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`); + ssp.joins.push({ + table1: "characters", + column1: "account", + table2: "account_access", + column2: "id", + database2: realm.authDatabase, + kind: "LEFT", + }); + ssp = ssp.where( + `\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`, + ); } - const result = await ssp - .where("`deleteInfos_Account` IS NULL") - .run(this.armory.config.dbQueryTimeout); + const result = await ssp.where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); (result as any).realm = realm.name; res.json(result); diff --git a/src/armory/data/DbcReader.ts b/src/armory/data/DbcReader.ts index 212e502..f931488 100644 --- a/src/armory/data/DbcReader.ts +++ b/src/armory/data/DbcReader.ts @@ -148,11 +148,13 @@ class AsyncGenWrapper implements IAsyncGeneratorWithArrayMethods { } public static from(array: T[]): AsyncGenWrapper { - return new AsyncGenWrapper(async function* () { - for (const x of array) { - yield x; - } - }()); + return new AsyncGenWrapper( + (async function* () { + for (const x of array) { + yield x; + } + })(), + ); } public async *[Symbol.asyncIterator](): AsyncGenerator { @@ -217,11 +219,10 @@ class DbcReader { return; } - const headerCols = headerLine.value - .map(header => camelCase(header).replace(/[\[\]]/g, "")); + const headerCols = headerLine.value.map((header) => camelCase(header).replace(/[\[\]]/g, "")); for await (const arr of itr) { - const cols = arr.map(value => isNaN(value as any) ? value : parseInt(value, 10)); + const cols = arr.map((value) => (isNaN(value as any) ? value : parseInt(value, 10))); const row = {}; headerCols.forEach((header, headerIdx) => { if (this.fields.length === 0 || this.fields.includes(header)) { @@ -242,7 +243,8 @@ class DbcReader { const str = chunk.toString(); // Iterate over each character, keep track of current column (of the returned array) for (let c = 0; c < str.length; ++c) { - let ch = str[c], nch = str[c + 1]; // Current character, next character + let ch = str[c], + nch = str[c + 1]; // Current character, next character if (!(col in arr)) { arr[col] = ""; // Create a new column (start with empty string) if necessary } @@ -263,14 +265,14 @@ class DbcReader { } // If it's a comma and we're not in a quoted field, move on to the next column - if (ch == ',' && !quote) { + if (ch == "," && !quote) { ++col; continue; } // If it's a newline (CRLF) and we're not in a quoted field, skip the next character // and move on to the next row and move to column 0 of that new row - if (ch == '\r' && nch == '\n' && !quote) { + if (ch == "\r" && nch == "\n" && !quote) { yield arr; arr.length = 0; // Clear the row col = 0; @@ -280,7 +282,7 @@ class DbcReader { // If it's a newline (LF or CR) and we're not in a quoted field, // move on to the next row and move to column 0 of that new row - if (!quote && (ch == '\r' || ch == '\n')) { + if (!quote && (ch == "\r" || ch == "\n")) { yield arr; arr.length = 0; // Clear the row col = 0; @@ -327,7 +329,19 @@ const dbcFields = { spell: ["id", "mechanic", "spellIconId"], spellItemEnchantment: ["id", "srcItemId"], spellIcon: ["id", "textureFilename"], - talent: ["id", "tabId", "tierId", "columnIndex", "spellRank0", "spellRank1", "spellRank2", "spellRank3", "spellRank4", "prereqTalent0", "prereqRank0"], + talent: [ + "id", + "tabId", + "tierId", + "columnIndex", + "spellRank0", + "spellRank1", + "spellRank2", + "spellRank3", + "spellRank4", + "prereqTalent0", + "prereqRank0", + ], talentTab: ["id", "nameLang0", "spellIconId", "classMask"], }; @@ -350,17 +364,26 @@ export class DbcManager { public async loadAllFiles(): Promise { this._achievement = await this.read(DbcFiles.achievement, dbcFields.achievement).toArray(); - this._achievementCategory = await this.read(DbcFiles.achievementCategory, dbcFields.achievementCategory).toArray(); + this._achievementCategory = await this.read( + DbcFiles.achievementCategory, + dbcFields.achievementCategory, + ).toArray(); this._glyphProperties = await this.read(DbcFiles.glyphProperties, dbcFields.glyphProperties).toArray(); this._item = await this.read(DbcFiles.item, dbcFields.item).toArray(); this._itemRetail = await this.read(DbcFiles.itemRetail, dbcFields.itemRetail).toArray(); this._itemAppearance = await this.read(DbcFiles.itemAppearance, dbcFields.itemAppearance).toArray(); - this._itemModifiedAppearance = await this.read(DbcFiles.itemModifiedAppearance, dbcFields.itemModifiedAppearance).toArray(); + this._itemModifiedAppearance = await this.read( + DbcFiles.itemModifiedAppearance, + dbcFields.itemModifiedAppearance, + ).toArray(); this._itemDisplayInfo = await this.read(DbcFiles.itemDisplayInfo, dbcFields.itemDisplayInfo).toArray(); this._mount = await this.read(DbcFiles.mount, dbcFields.mount).toArray(); this._mountDisplay = await this.read(DbcFiles.mountDisplay, dbcFields.mountDisplay).toArray(); this._spell = await this.read(DbcFiles.spell, dbcFields.spell).toArray(); - this._spellItemEnchantment = await this.read(DbcFiles.spellItemEnchantment, dbcFields.spellItemEnchantment).toArray(); + this._spellItemEnchantment = await this.read( + DbcFiles.spellItemEnchantment, + dbcFields.spellItemEnchantment, + ).toArray(); this._spellIcon = await this.read(DbcFiles.spellIcon, dbcFields.spellIcon).toArray(); this._talent = await this.read(DbcFiles.talent, dbcFields.talent).toArray(); this._talentTab = await this.read(DbcFiles.talentTab, dbcFields.talentTab).toArray(); diff --git a/src/tools/fetchdata.ts b/src/tools/fetchdata.ts index 9e6b412..10335c5 100644 --- a/src/tools/fetchdata.ts +++ b/src/tools/fetchdata.ts @@ -54,9 +54,12 @@ class Progress { } private createProgressBar(text: string, total: number): cliProgress.SingleBar { - const progress = new cliProgress.SingleBar({ - format: `${text} {bar} {percentage}% ({value} / {total})`, - }, cliProgress.Presets.shades_classic); + const progress = new cliProgress.SingleBar( + { + format: `${text} {bar} {percentage}% ({value} / {total})`, + }, + cliProgress.Presets.shades_classic, + ); progress.start(total, 0); return progress; } @@ -163,25 +166,13 @@ function queueTexturesAndModels(item: any): void { } async function downloadRaces(): Promise { - const races = [ - "human", - "nightelf", - "dwarf", - "gnome", - "draenei", - "orc", - "troll", - "tauren", - "bloodelf", - "scourge", - ]; + const races = ["human", "nightelf", "dwarf", "gnome", "draenei", "orc", "troll", "tauren", "bloodelf", "scourge"]; const genders = ["male", "female"]; const raceGenderCombo = races.map((race) => [race + genders[0], race + genders[1]]).flat(); const progress = new Progress("Downloading races data...", raceGenderCombo.length); - await promisepool.PromisePool - .for(raceGenderCombo) + await promisepool.PromisePool.for(raceGenderCombo) .withConcurrency(4) .process(async (race) => { const characterJson = await download("meta/character", `${race}.json`); @@ -191,7 +182,11 @@ async function downloadRaces(): Promise { for (const option of customizationJson.Options) { for (const choice of option.Choices) { for (const element of choice.Elements) { - if (element.SkinnedModel !== null && typeof element.SkinnedModel.CollectionFileDataID === "number" && element.SkinnedModel.CollectionFileDataID !== 0) { + if ( + element.SkinnedModel !== null && + typeof element.SkinnedModel.CollectionFileDataID === "number" && + element.SkinnedModel.CollectionFileDataID !== 0 + ) { modelsDownloadQueue.add(element.SkinnedModel.CollectionFileDataID); } if (element.BoneSet !== null && typeof element.BoneSet.BoneFileDataID === "number" && element.BoneSet.BoneFileDataID !== 0) { @@ -202,7 +197,7 @@ async function downloadRaces(): Promise { } const textureFiles = Object.keys(customizationJson.TextureFiles) - .map(key => customizationJson.TextureFiles[key]) + .map((key) => customizationJson.TextureFiles[key]) .flat(); for (const file of textureFiles) { texturesDownloadQueue.add(file.FileDataId); @@ -215,12 +210,14 @@ async function downloadRaces(): Promise { } async function downloadArmors(): Promise { - const rows = await dbc.item().filter((row) => row.classId === classIdArmor).toArray(); + const rows = await dbc + .item() + .filter((row) => row.classId === classIdArmor) + .toArray(); const progress = new Progress("Downloading armor data...", rows.length); - await promisepool.PromisePool - .for(rows) + await promisepool.PromisePool.for(rows) .withConcurrency(50) .process(async (row) => { const modifiedAppearance = dbcItemModifiedAppearanceByItemId[row.id]; @@ -247,12 +244,14 @@ async function downloadArmors(): Promise { } async function downloadWeapons(): Promise { - const rows = await dbc.item().filter((row) => row.classId === classIdWeapon).toArray(); + const rows = await dbc + .item() + .filter((row) => row.classId === classIdWeapon) + .toArray(); const progress = new Progress("Downloading weapon data...", rows.length); - await promisepool.PromisePool - .for(rows) + await promisepool.PromisePool.for(rows) .withConcurrency(50) .process(async (row) => { const modifiedAppearance = dbcItemModifiedAppearanceByItemId[row.id]; @@ -307,11 +306,13 @@ async function readDbcData(): Promise { } async function downloadMounts(): Promise { - const mountSpells = await dbc.spell().filter(spell => spell.mechanic === spellMechanicMounted).toArray(); + const mountSpells = await dbc + .spell() + .filter((spell) => spell.mechanic === spellMechanicMounted) + .toArray(); const progress = new Progress("Downloading mount data...", mountSpells.length); - await promisepool.PromisePool - .for(mountSpells) + await promisepool.PromisePool.for(mountSpells) .withConcurrency(50) .process(async (spell) => { const mount = dbcMountBySourceSpellId[spell.id]; @@ -333,8 +334,7 @@ async function downloadMounts(): Promise { async function downloadTextures(): Promise { const progress = new Progress("Downloading textures...", texturesDownloadQueue.size); - await promisepool.PromisePool - .for(Array.from(texturesDownloadQueue)) + await promisepool.PromisePool.for(Array.from(texturesDownloadQueue)) .withConcurrency(25) .process(async (fileDataId) => { await download("textures", `${fileDataId}.png`); @@ -347,8 +347,7 @@ async function downloadTextures(): Promise { async function downloadModels(): Promise { const progress = new Progress("Downloading models...", modelsDownloadQueue.size); - await promisepool.PromisePool - .for(Array.from(modelsDownloadQueue)) + await promisepool.PromisePool.for(Array.from(modelsDownloadQueue)) .withConcurrency(25) .process(async (fileDataId) => { await download("mo3", `${fileDataId}.mo3`); @@ -361,8 +360,7 @@ async function downloadModels(): Promise { async function downloadBones(): Promise { const progress = new Progress("Downloading bones...", bonesDownloadQueue.size); - await promisepool.PromisePool - .for(Array.from(bonesDownloadQueue)) + await promisepool.PromisePool.for(Array.from(bonesDownloadQueue)) .withConcurrency(25) .process(async (fileDataId) => { try { @@ -384,8 +382,7 @@ async function parseModels(): Promise { const files = await glob("data/mo3/*.mo3"); const progress = new Progress("Reading model files for texture references...", files.length); - await promisepool.PromisePool - .for(files) + await promisepool.PromisePool.for(files) .withConcurrency(20) .process(async (file) => { const buffer = await fsp.readFile(file); diff --git a/static/js/emblems.js b/static/js/emblems.js index ed1d9d3..cfce3aa 100644 --- a/static/js/emblems.js +++ b/static/js/emblems.js @@ -1,14 +1,16 @@ function waitForEmblemImages($emblem) { - return Promise.all($emblem.find(".images img").map((idx, img) => { - return new Promise((res, rej) => { - if (img.complete) { - res(); - } else { - img.addEventListener("load", res); - img.addEventListener("error", rej); - } - }); - })); + return Promise.all( + $emblem.find(".images img").map((idx, img) => { + return new Promise((res, rej) => { + if (img.complete) { + res(); + } else { + img.addEventListener("load", res); + img.addEventListener("error", rej); + } + }); + }), + ); } function createGuildEmblem(emblem, el) { @@ -16,7 +18,8 @@ function createGuildEmblem(emblem, el) { const canvas = $emblem.find("canvas")[0]; const ctx = canvas.getContext("2d"); - const imgUrl = (type, section, value, value2) => `${handlebarsData.websiteRoot}/img/guild-emblems/${type}_${value}${value2 ? ("_" + value2) : ""}_T${section}_U.PNG`; + const imgUrl = (type, section, value, value2) => + `${handlebarsData.websiteRoot}/img/guild-emblems/${type}_${value}${value2 ? "_" + value2 : ""}_T${section}_U.PNG`; const $images = $("
").addClass("images").appendTo($emblem); const bgUpper = $("").attr("src", imgUrl("Background", "U", emblem.background)).appendTo($images)[0]; @@ -64,7 +67,10 @@ function createArenaEmblem(teamSize, emblem, el) { const canvas = $emblem.find("canvas")[0]; const ctx = canvas.getContext("2d"); - const imgUrl = (teamSize, type, value) => `${handlebarsData.websiteRoot}/img/arena-banners/PVP-Banner${teamSize ? ("-" + teamSize) : ""}${type ? ("-" + type) : ""}${value ? ("-" + value) : ""}.PNG`; + const imgUrl = (teamSize, type, value) => + `${handlebarsData.websiteRoot}/img/arena-banners/PVP-Banner${teamSize ? "-" + teamSize : ""}${type ? "-" + type : ""}${ + value ? "-" + value : "" + }.PNG`; const $images = $("
").addClass("images").appendTo($emblem); const banner = $("").attr("src", imgUrl(teamSize)).appendTo($images)[0]; diff --git a/static/js/sync-url.js b/static/js/sync-url.js index cb2401a..a32d3dc 100644 --- a/static/js/sync-url.js +++ b/static/js/sync-url.js @@ -1,3 +1,6 @@ -window.parent.postMessage({ - url: window.location.pathname.replace(handlebarsData.websiteRoot, ""), -}, "*"); +window.parent.postMessage( + { + url: window.location.pathname.replace(handlebarsData.websiteRoot, ""), + }, + "*", +); diff --git a/tsconfig.json b/tsconfig.json index e6ec662..1af51b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,6 @@ "outDir": "build", "moduleResolution": "node" }, - "exclude": [ - "node_modules" - ], + "exclude": ["node_modules"], "include": ["src/**/*.ts"] } From 216904fd6ecc8467d5d6647eff2a5bc6cc3a5faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefano=20Borz=C3=AC?= Date: Fri, 6 May 2022 20:24:22 +0200 Subject: [PATCH 12/52] chore: minor improvements in for cycle (#18) --- src/armory/Armory.ts | 6 ++---- src/armory/Config.ts | 14 +++++--------- src/tools/fetchdata.ts | 26 ++++++++++---------------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/armory/Armory.ts b/src/armory/Armory.ts index 7f457da..7020b8c 100644 --- a/src/armory/Armory.ts +++ b/src/armory/Armory.ts @@ -85,10 +85,8 @@ export class Armory { websiteRoot: this.config.websiteRoot, iframeMode: this.config.iframeMode, }; - for (const key in locals) { - if (locals.hasOwnProperty(key)) { - app.locals[key] = locals[key]; - } + for (const key of Object.keys(locals)) { + app.locals[key] = locals[key]; } app.locals.locals = locals; diff --git a/src/armory/Config.ts b/src/armory/Config.ts index c3af755..170c638 100644 --- a/src/armory/Config.ts +++ b/src/armory/Config.ts @@ -115,7 +115,7 @@ export class Config { } else if (typeof model === "object") { const obj = {}; Config.loadObjFromEnv(logger, obj, model, parentName + i); - if (Object.keys(obj).length > 0) { + if (Object.keys(obj).length) { arr.push(obj); } } else if (process.env.hasOwnProperty(key)) { @@ -161,7 +161,7 @@ export class Config { for (const field of missing) { logger.warn(`Field ${parentName}${field} is missing from config.json!`); } - for (const key in model) { + for (const key of Object.keys(model)) { if (typeof model[key] === "object" && obj.hasOwnProperty(key)) { Config.checkAllMissingFields(logger, obj[key], model[key], parentName + key); } @@ -169,12 +169,8 @@ export class Config { } private static hasMissingFields(obj: object, model: object): string[] { - const missing = []; - for (const key in model) { - if (!obj.hasOwnProperty(key)) { - missing.push(key); - } - } - return missing; + const objProp = Object.keys(obj); + const missingProps = Object.keys(model).filter((key) => !objProp.includes(key)); + return missingProps; } } diff --git a/src/tools/fetchdata.ts b/src/tools/fetchdata.ts index 10335c5..e32fa40 100644 --- a/src/tools/fetchdata.ts +++ b/src/tools/fetchdata.ts @@ -125,21 +125,17 @@ async function download(dir: string, file: string): Promise { function queueTexturesAndModels(item: any): void { if (item.TextureFiles !== null) { - for (const key in item.TextureFiles) { - for (const file of item.TextureFiles[key]) { - if (file.FileDataId !== 0) { - texturesDownloadQueue.add(file.FileDataId); - } + for (const file in Object.values(item.TextureFiles)) { + if (file["FileDataId"] !== 0) { + texturesDownloadQueue.add(file["FileDataId"]); } } } if (item.ModelFiles !== null) { - for (const key in item.ModelFiles) { - for (const file of item.ModelFiles[key]) { - if (file.FileDataId !== 0) { - modelsDownloadQueue.add(file.FileDataId); - } + for (const file of Object.values(item.ModelFiles)) { + if (file["FileDataId"] !== 0) { + modelsDownloadQueue.add(file["FileDataId"]); } } } @@ -149,7 +145,7 @@ function queueTexturesAndModels(item: any): void { } if (item.Textures !== null) { - for (const key in item.Textures) { + for (const key of Object.keys(item.Textures)) { if (item.Textures[key] !== 0) { texturesDownloadQueue.add(item.Textures[key]); } @@ -157,7 +153,7 @@ function queueTexturesAndModels(item: any): void { } if (item.Textures2 !== null) { - for (const key in item.Textures2) { + for (const key of Object.keys(item.Textures2)) { if (item.Textures2[key] !== 0) { texturesDownloadQueue.add(item.Textures2[key]); } @@ -196,11 +192,9 @@ async function downloadRaces(): Promise { } } - const textureFiles = Object.keys(customizationJson.TextureFiles) - .map((key) => customizationJson.TextureFiles[key]) - .flat(); + const textureFiles = Object.values(customizationJson.TextureFiles).flat(); for (const file of textureFiles) { - texturesDownloadQueue.add(file.FileDataId); + texturesDownloadQueue.add(file["FileDataId"]); } progress.increment(); From 08009a575c0ec18d42ec99a838826b37badaa150 Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Fri, 6 May 2022 22:01:04 +0200 Subject: [PATCH 13/52] fix: character search duplicates because of account_access (#19) --- src/armory/DataTablesSsp.ts | 6 ++++-- src/armory/controllers/CharacterController.ts | 4 ++-- src/armory/controllers/GuildController.ts | 5 ++--- src/armory/controllers/IndexController.ts | 5 ++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/armory/DataTablesSsp.ts b/src/armory/DataTablesSsp.ts index ee8b8e9..6e4732f 100644 --- a/src/armory/DataTablesSsp.ts +++ b/src/armory/DataTablesSsp.ts @@ -23,6 +23,7 @@ export interface IColumnJoin { column2: string; database2?: string; kind: "INNER" | "FULL OUTER" | "LEFT" | "RIGHT"; + where?: string; } export class DataTablesSsp { @@ -130,8 +131,9 @@ export class DataTablesSsp { private join() { for (const join of this.joins) { - const db2 = join.database2 ? "`" + join.database2 + "`." : ""; - this.joinSql += `${join.kind} JOIN ${db2}\`${join.table2}\` ON ${db2}\`${join.table2}\`.\`${join.column2}\` = \`${join.table1}\`.\`${join.column1}\`\n`; + const db2 = join.database2 ? `\`${join.database2}\`.` : ""; + const where = join.where ? ` ${join.where}` : ""; + this.joinSql += `${join.kind} JOIN ${db2}\`${join.table2}\` ON ${db2}\`${join.table2}\`.\`${join.column2}\` = \`${join.table1}\`.\`${join.column1}\`${where}\n`; } return this; diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index 243c0ac..60a5aeb 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -315,10 +315,10 @@ export class CharacterController { FROM \`characters\` LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\` LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\` - LEFT JOIN \`${realm.authDatabase}\`.\`account_access\` ON \`account_access\`.\`id\` = \`characters\`.\`account\` + LEFT JOIN \`${realm.authDatabase}\`.\`account_access\` ON \`account_access\`.\`id\` = \`characters\`.\`account\` AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0 WHERE ${where} - AND (\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0 OR ? = 0) + AND (\`account_access\`.\`id\` IS NULL OR ? = 0) `, values: [character, this.armory.config.hideGameMasters ? 1 : 0], timeout: this.armory.config.dbQueryTimeout, diff --git a/src/armory/controllers/GuildController.ts b/src/armory/controllers/GuildController.ts index 6296ab5..d5a8c4e 100644 --- a/src/armory/controllers/GuildController.ts +++ b/src/armory/controllers/GuildController.ts @@ -79,10 +79,9 @@ export class GuildController { column2: "id", database2: realm.authDatabase, kind: "LEFT", + where: `AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0`, }); - ssp = ssp.where( - `\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`, - ); + ssp = ssp.where("`account_access`.`id` IS NULL"); } const result = await ssp.where("`guildid` = ?", guildId).where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); diff --git a/src/armory/controllers/IndexController.ts b/src/armory/controllers/IndexController.ts index 954ad01..43c825c 100644 --- a/src/armory/controllers/IndexController.ts +++ b/src/armory/controllers/IndexController.ts @@ -50,10 +50,9 @@ export class IndexController { column2: "id", database2: realm.authDatabase, kind: "LEFT", + where: `AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0`, }); - ssp = ssp.where( - `\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`, - ); + ssp = ssp.where("`account_access`.`id` IS NULL"); } const result = await ssp.where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); From 1156ceb17f2a8b487f56b5413c0fea914bb9538c Mon Sep 17 00:00:00 2001 From: Axel Cocat Date: Sat, 7 May 2022 17:33:42 +0200 Subject: [PATCH 14/52] feat(character): colored item borders for quality (#20) --- src/armory/controllers/CharacterController.ts | 13 ++++++++++++- static/character.hbs | 9 +++++++-- static/img/icon-border/large/default.png | Bin 0 -> 1946 bytes static/img/icon-border/large/q0.png | Bin 0 -> 1598 bytes static/img/icon-border/large/q1.png | Bin 0 -> 2138 bytes static/img/icon-border/large/q2.png | Bin 0 -> 1817 bytes static/img/icon-border/large/q3.png | Bin 0 -> 1802 bytes static/img/icon-border/large/q4.png | Bin 0 -> 1922 bytes static/img/icon-border/large/q5.png | Bin 0 -> 1906 bytes static/img/icon-border/large/q6.png | Bin 0 -> 1961 bytes static/img/icon-border/large/q7.png | Bin 0 -> 1961 bytes static/img/icon-border/large/q8.png | Bin 0 -> 1961 bytes static/img/icon-border/large/q9.png | Bin 0 -> 2006 bytes static/img/icon-border/medium/default.png | Bin 0 -> 784 bytes static/img/icon-border/medium/q0.png | Bin 0 -> 901 bytes static/img/icon-border/medium/q1.png | Bin 0 -> 946 bytes static/img/icon-border/medium/q2.png | Bin 0 -> 858 bytes static/img/icon-border/medium/q3.png | Bin 0 -> 851 bytes static/img/icon-border/medium/q4.png | Bin 0 -> 883 bytes static/img/icon-border/medium/q5.png | Bin 0 -> 876 bytes static/img/icon-border/medium/q6.png | Bin 0 -> 933 bytes static/img/icon-border/medium/q7.png | Bin 0 -> 933 bytes static/img/icon-border/medium/q8.png | Bin 0 -> 933 bytes static/img/icon-border/medium/q9.png | Bin 0 -> 884 bytes static/partials/icons.hbs | 4 ++-- 25 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 static/img/icon-border/large/default.png create mode 100644 static/img/icon-border/large/q0.png create mode 100644 static/img/icon-border/large/q1.png create mode 100644 static/img/icon-border/large/q2.png create mode 100644 static/img/icon-border/large/q3.png create mode 100644 static/img/icon-border/large/q4.png create mode 100644 static/img/icon-border/large/q5.png create mode 100644 static/img/icon-border/large/q6.png create mode 100644 static/img/icon-border/large/q7.png create mode 100644 static/img/icon-border/large/q8.png create mode 100644 static/img/icon-border/large/q9.png create mode 100644 static/img/icon-border/medium/default.png create mode 100644 static/img/icon-border/medium/q0.png create mode 100644 static/img/icon-border/medium/q1.png create mode 100644 static/img/icon-border/medium/q2.png create mode 100644 static/img/icon-border/medium/q3.png create mode 100644 static/img/icon-border/medium/q4.png create mode 100644 static/img/icon-border/medium/q5.png create mode 100644 static/img/icon-border/medium/q6.png create mode 100644 static/img/icon-border/medium/q7.png create mode 100644 static/img/icon-border/medium/q8.png create mode 100644 static/img/icon-border/medium/q9.png diff --git a/src/armory/controllers/CharacterController.ts b/src/armory/controllers/CharacterController.ts index 60a5aeb..ac01118 100644 --- a/src/armory/controllers/CharacterController.ts +++ b/src/armory/controllers/CharacterController.ts @@ -31,6 +31,7 @@ interface IEquipmentData { randomPropertyId: number; classId: number; subclassId: number; + quality: number; } interface ICustomizationOption { @@ -331,7 +332,7 @@ export class CharacterController { } private async getEquipmentData(realm: string, charGuid: number): Promise { - const [rows, fields] = await this.armory.getCharactersDb(realm).query({ + let [rows, fields] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT character_inventory.slot, item_instance.itemEntry, item_instance.flags, item_instance.enchantments, item_instance.randomPropertyId FROM character_inventory @@ -350,6 +351,16 @@ export class CharacterController { row.subclassId = item.subclassId; } + [rows, fields] = await this.armory.worldDb.query({ + sql: "SELECT entry, quality FROM item_template WHERE entry IN (?)", + values: [data.map((row) => row.itemEntry)], + timeout: this.armory.config.dbQueryTimeout, + }); + for (const row of rows as RowDataPacket[]) { + const item = data.find((item) => item.itemEntry === row.entry); + item.quality = row.quality; + } + return data; } diff --git a/static/character.hbs b/static/character.hbs index 7b82c02..333a132 100644 --- a/static/character.hbs +++ b/static/character.hbs @@ -134,10 +134,11 @@ characterModel.items.push(...items); } - function createItemSlot(item, invSlot, icon, rel, container) { + function createItemSlot(item, invSlot, icon, quality, rel, container) { const $item = $itemSlotTemplate.clone(); $item .data("icon", icon) + .data("quality", quality) .find(".inventory-slot") .css("background-image", `url("{{websiteRoot}}/img/inventory-slot/${invSlot}.png")`); if (item !== undefined) { @@ -168,7 +169,7 @@ rel.push("rand=" + item.randomPropertyId); } } - createItemSlot(item?.itemEntry, slot, item?.icon?.toLowerCase(), rel.join("&"), side.element); + createItemSlot(item?.itemEntry, slot, item?.icon?.toLowerCase(), item?.quality, rel.join("&"), side.element); } } const nbItemRows = Math.max($("#equipment-col-left").children().length, $("#equipment-col-right").children().length); @@ -186,9 +187,13 @@ const $item = $(el); const item = $item.data("item"); const icon = $item.data("icon"); + const quality = $item.data("quality"); if (item !== undefined) { $item.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/${iconSize}/${icon}.jpg")`); } + if (quality !== undefined) { + $item.find(".border").css("background-image", `url("{{websiteRoot}}/img/icon-border/${iconSize}/q${quality}.png")`); + } }); if (!init) { diff --git a/static/img/icon-border/large/default.png b/static/img/icon-border/large/default.png new file mode 100644 index 0000000000000000000000000000000000000000..5a2221aa6c0febb942eac5766e24beddc53f6b35 GIT binary patch literal 1946 zcmV;L2W9w)P)I7ENF##XIoTaDK&_S^`Uw2SxCj^wPGmtt@N!v^u?D51t}J> zP+tQ2CW0VRFKyEnQze(CwN-4J>u&eHvorqR*)z#xlU*j@OXuuKerEQZnKS2nbN<^o z=O4}UJnE#?q<0`#n@s;i)3jjTR;HayPcuErl!!=wOH6l}rkHLrEigG70@4J=Zl*WK z#>S4t;|VfNlXP99VzEeuVbGQ>TST7gx|Gl7DVbCf-J@IaiS9PF4%rD$<+k$QT1 z#QlEi>gu9Gp+NC?oMN#UEi5dEYpd0&$V0uT2lwHK7lt~s*(^DZ!|l<hm$jHc9E*cAh5(H!~AKpEA@{?mv?%YY$nn#C+ z4^zEb{Fd{lnQ{-g{ODoYWyQJ&zdnDCM{b#>rl#ol@#CjC|2)$brsW_YhnP*ic=oyH z8GVC>4h;c}&$;M3DS(O(IUrq9QM_&0UBhS3o)IHx{PJZwb?Vf|oG)H{P^dnB@#00w z=JGW9*=c`d0E{0ulEx3IM!kcRTD7=+Xz0-3$VVU2_3PJ>^$gRmOt*q|ND=}mpDzd= zm27e|B4Cj0!-DM7G~`WzwGaBt3G%a#DHa68fONKFwl6$$l1-ujMi3PADF?M|Tei?h zlASB-t@U zpWBWQ2vx0>qZKihflzHbLI4I_YE^g!eQnmN>x8Pb+#;V0Ho;OosrEv(x3^c!I1xpE z;#o6vVT7X3Edha}gCXQ;(U&2PQ>S8S#kt!U5Z}4ebu}jAV|Hwvh*&37;oRw(rUW1X z=dQKdRmHh;-FkTE&YvBRwp7)^J9nA_EQ1;Q(9WH0nb990a>lIV+^u*rT6pKKu7C`I zPz~+eDPX9I6~@rcouPnX1|UK^cUb9H0g%wnoeH~(uyh~Vxl_O_Ys|Wbb?!U`w2Z|T zYG~&UdnqEXc6o$^cJ6So3Wix|=T3$B5NzWrg^kg8LYLb3{6f> zieP7GVQ^71ATtva6JM3f<@a{%*g^aD?WGGBei>#^5RVp_Fjast@Y;Sc>pnX>OP*6e zS)QrV49E?ppJrxe9L#;**D;ABl}?FDxw98O@y<<%7g0i)s`KLiQ$Lu z0u%~)E?-sOcsLr41+d`#6+R*#V796k^D_unhl+#%^et&@48&3b>Q3^@!@a)yftfI9 z5L+0To_G;r*I_&+>YksUr}=xgY4)$X5Cy+eBNX=sa7hplT@EY6u)YP#WHPV&stMPj z0ca>14Z?3!E*Mmr!TyH~aE?afLkUZ4>}_;^tNeEL`e5M03CL#_7wz*uw1Ah0a{LDQ zA8qjaAU?v7o5RSsAw>b=b$@0;0PkE75MS%~pzLGnXL^DuNzwV%2!tmD$3GH;OH6+< zL4g#T)u}6m=Oj~E^43$-2=cWe0*EU$CIG>AL}q+2nq^3{eF8O=mbyC`AwYaJ3;si- g_p%QL3jPyd0L@m~8Gf`|M*si-07*qoM6N<$f-GjGUH||9 literal 0 HcmV?d00001 diff --git a/static/img/icon-border/large/q0.png b/static/img/icon-border/large/q0.png new file mode 100644 index 0000000000000000000000000000000000000000..36047a359241a835360ee09d05d231a0c6f8f2c5 GIT binary patch literal 1598 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 QsQ>@~07*qoM6N<$f`J_B{r~^~ literal 0 HcmV?d00001 diff --git a/static/img/icon-border/large/q2.png b/static/img/icon-border/large/q2.png new file mode 100644 index 0000000000000000000000000000000000000000..3c33d2cfc695e08730785eb1351d17702fd0eff6 GIT binary patch literal 1817 zcmV+!2j=*RP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2Chj&K~#8N?OaQX z990-Sx4Zf=Gnq$p%!o#vNWw%7grG5kD1<18pzajiMB}(|<-(Q!LPe1%f~X;h4@AU8 zbYT#p5HxX-n1lrLQjB?+$8=}9r@PB@s`^e%P8I-+g@DbKCg7 zj~o#S-D{36<}Jh+6I^i@sxVlMA|9e_q^xA>VEAy!qmDMtQub4RhL7Voh1u@207*hD z86KWO1<#E9h)vZh;CVD9+ETp^6pO$x>G2pk3^R=m#wz6?USzn|ZlKc%%c$zn!XPfq z)iF{kqwY6@wuC`bMy=CCwNPYJ6F;%JMb8fr<(1YB3ZTu61THREXMDe>|4^V;v5lfv=hEE0S^~PD(_`q>0F=;yJv9 z^=meJtky$`;X2Dm;RZvQ8CsaboZrGgPz*u3&(LJs;&!l9Vs4M>ksyW&%jha7Uzv3Z zZ3Zc;G_}K8nM?7I@EVm8$_0<99n6uX(ZVv!FtppiP;r2LG;BpgR&cdlBZ))oUz;@) zakW!JsbJ9R_?Rf%g!BF-oc@&+zJy~`=?gZun-$NK5)rb>teYoeC)Q8iXK?0EAMI_y zV`XH`jq5pOUun#yMSf`m_5ey zS85+`vgB4oe$A=`NZ#G_XunR^B z+c1O|Fpdel#`Hs!Px#{Nc$-%8akLLHSca>-&QQcrI80<2OP>ruI!G7kB;7Q>aMcWS zE(X?f2h)b*gcLBsYbCr5<_ry!ERbOll5#oFO`2<=lg@@Q6T}81LX>=+f3Ji$PC&X! zXBmU&F(}3f(F5&r+<>Nq0g7F!yGRnEHC>Fk01=`&L^{WiB*e1j3gn@Mi|jm-gb3yC z)dhs!LjwJC$iHDU^N?jo*G1^`o22SuOb(9=79v>ZE`}r_BEl8tPM@)+cJ9It$FmI)k=nU)VaQ0w)Xv=kPEY0_ z&2#6j2dSMq7v>P@nA*AP8CRKvbWQEtB~!Br5s})tbA^!9&YcTGMo8`4xd6R%>6*&9 zb77Uy4mGuN=K>+JkkrndD|1L{=PsF=O-RT5o4XjKd1~j*l~3MNJ9jQjs(R`9H+M1U zp9=GD?qZOx`8RhlXmv^L+_?aclG?fJF{4aEI;M8+T-d2b7&LdJa_*E-nRG@?$Fg)1 zDJuw1Rq3$Ma9|mWI(L5epwL(l*Mzf@PdBiF)tMm09x;;LQJ-BtAtF*rc$&m$MqbVi zY9Oe}EFkzz1-^hQ{Ck43m+>E%dYE!CNr+%cDR8uTc$DO+1a3J&m{tUoTQw#WB|Z_DgRuEf<}Tl00000NkvXX Hu0mjfV`ye+ literal 0 HcmV?d00001 diff --git a/static/img/icon-border/large/q3.png b/static/img/icon-border/large/q3.png new file mode 100644 index 0000000000000000000000000000000000000000..61ddea12d2b1e8ee9f7a7ff0f1b5c785c1b790f9 GIT binary patch literal 1802 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 IM6N<$f=&BsRR910 literal 0 HcmV?d00001 diff --git a/static/img/icon-border/large/q5.png b/static/img/icon-border/large/q5.png new file mode 100644 index 0000000000000000000000000000000000000000..67d39c9cdaac5c3b27c5f6b1137a49c13d395142 GIT binary patch literal 1906 zcmV-&2aWiNP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2M0++K~#8N?Ofk$ z990-Sv)SEj_E$Dde~3nDHMFU;O3<1@p+#CngzA%(zF46Z!9M7NPd@ku_#g-hYDMUS zVxmYD^`R&yU8Zm^_;silbPK#%!4mGGY2kvf6aIAcjmj_ zz2DBxT8%~nF_w_9KV#T%EyS`c^SK_#FgHl1wkLsY{2X98YZ^No)U;9ut}Fm2`S}ye z7cK#_cC&$x5QhvBu{RAo`TdI+$z*^eD-vX;QUSWUfSv*`P-VT2+Dskk5_>bR#`^S} z$*NhRt==pau2+yOvQMRE#xlsJ(J0qoWhk(_8}kztq#y%Q!X4c3_6q#2>H%vmO z%2=RAnooj7=ssq;f$|*XG-Wv*8q>3|a!ccy`S6;pui`X&f6iXVSoOP?6nDvZ$)L!m z-`s_f(z+xv{TU?t3rJ*A$gjzuC&ymt9$=C4tdR^&SYm3dsLWx3ABN&|ij!f#@+^`m zmh0@3=H!%8tl-S7JPl7XT&N>e;HX_}OY)O)k;@{H&tQ>CEs{7Rf2N8>3QzXsk)ZC2 z)Ck=i11yNF6#Gx|t|cg+-K|2dd2N@4y2x0&%G4j-i(y{1pI7XoHbc@~Qx;qcx!ba~ zIAn}Ih5PTl&%$4q8+i7JX*M^a*|QyLTP_K@#z{_{gImc90#at~rjq2S>>X23*%?>u z%71tmShE^9a|Sr@87@|VG4eXbHe)F+A%m86h%_BK@+d~uZ{Q7?2M)duTnDZ)dxZk0 zO~(!ei7U`<2FS&_v6rYH*Ub6zzzg4EoTR)*!};_N;7^y3Ajdd!dgLIKj(rpiQ{Bj!_(s?VF%OG%=eTrIw2F&Dd z(jU#tG|O5hwq>+!O`QnoAYG)Bbdz5xQ(x&!-&vymce1YF5<(=3boy!9lopYEhL%Yf z$Z%kjGAZ4pI5$E_XA+@(u*kNomk_H;?{ag>E=2&7)Kxl@kc3EK>hcm|oQ%#yQGixK z4-X^u0o{d<5S>EBSQH>aoRhZ~K0>qui?ygAOaEb?I$Zb&5mG!oM+I`Kt!VQhlQ--_ z$RWeHPF+{@Aqo<6IlB-6#^D8zTV>2Yx9 zfd~m4+(qH^EhicH^E((z1frGo2(`+aq#r502R`>|f zdWuSLC)|ji+$}-BT+PM7ogeX&I}egf@sqn2#82*85I?!|K%OCRa2FK>4(_6Y_{p6I z3Sj~VcTs@xA#rfGgn6d;XPGSs{N%1>S%nhfBK}#X2RgdPPwqT$oa%}eqCiJZMt+73 z1@U8IwAlZBM1Yb)HB z5I0-a=pJC-?c8N1YQV3oyBgA$5LW}69$?)n>iRcz{edda03UNJe#0eXqh-CqZTY!v zBy)geSrR*9B4n?r(kRyLWLduB3RRc(r^pN4$td-F*Cpg0%i2xC4w96|wsYlZv(b9` znMq4Xso8URIBhKlKi_XnB;)^t=I4y3nAE#r=!U9IeH74YewxdO{aI$B_2V~wa38rq zfAIkc`NSn;gJrFyfQL!S0g|#~Hv_@d%*?Y_l}W-ih?LUzky%#g?Q&<^Qj|Q+kqf*= zZ#!l^C*CqgT<-cg&P?^1!L8)#`x%AkdCJoc=$xd@{{m23$yS^GGCQ|C0NNz8a>a`PSewwF1|6tlxR?>MxWk(0e zlXaZsy(Sq`OlNt&k8}L5*?!I?#Qt{(mlTru1d}{+AImz}NoE+(AVKpaV2W08kz|~p zF?~lC#+fF3ga{r|9-{T_B*R0@_p?r9cRZ-A-sG>61oNUc(~k}rmuRSMga|e%{mkA) sGS=JqFnv>^7n&z%DdYf`9WnsmKf)!ccjd1y5C8xG07*qoM6N<$g6dR@2mk;8 literal 0 HcmV?d00001 diff --git a/static/img/icon-border/large/q6.png b/static/img/icon-border/large/q6.png new file mode 100644 index 0000000000000000000000000000000000000000..83afe883bacb36b969eadacd144c8355f42cc148 GIT binary patch literal 1961 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 literal 0 HcmV?d00001 diff --git a/static/img/icon-border/medium/default.png b/static/img/icon-border/medium/default.png new file mode 100644 index 0000000000000000000000000000000000000000..9d91ac90a0be61f7a9806b24b634250d6f6652d5 GIT binary patch literal 784 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 literal 0 HcmV?d00001 diff --git a/static/img/icon-border/medium/q4.png b/static/img/icon-border/medium/q4.png new file mode 100644 index 0000000000000000000000000000000000000000..ea74f5b947ff5dce48130d3e02ae71d558470f78 GIT binary patch literal 883 zcmV-(1C0EMP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0|-e(K~z{r?U~JM z6G0fppGo@VW=U#5#Cq`1Rx2o!9{j@3g9kM|dhw(lidGL| zFZG~;V3Dd-C{1WedX7k(G=bdb3L>Pu(39)AY-2t>70tzMB)(QQt0+nHcp3=|++(#puJqWbWyD->`e(%b_5GFrhTQ|2&1A0QW(xS{GK#evA))~PPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0|7}yK~z{r?U_AK z6hR!upIy=D9swmGM$lX#vC@dmfku33MOsPt2w2%_EPd(pE7*`=L1$rMEYMgO6B4aR zEC>NS3YCk8#31YQpPiAJWnC~i_vXw!$=u$|&g}kX_Ln!`%kFh}tZVe;qEd#fwx1s6LcMkBAyC^-M-mV!dx}YymsMy%$13>rv zaYPa)bbS?gw8^#<;A;fFBES=;P%^OJfLUhv$R=jxiW5hv9$NGy-7+H3WQxd?3tXqg z-m`UI5`2nA|Ddl`h&W=MCg=oP`6_LG#xNOR4i4FpmMt1qB=^jFst&ODz;kUGV2&6Z zA~+!XE(Od-poXuGL-Oml97}9|*MB_LA3L2dO7|-V*hforTd!}C_br^ZsrSI?r{3;# zjaIwU6|L3=_Hpu)JFKPs;-eg3ABVNvakxEO`v-D>>v?NEO;Tp3agD60(XY3v)VJnB<;vZymKgtgP#-5Bs>> zdgBD5r;BR!@-cZ{?EscS2D;e`+5#)_H1HUKM+CSMqBJl7RI9*?wdQUa$U?T*fv{%F z-~p#PZ_TF6c|uoY2T3yOLQ3Kd@wPddG?AHm$k3C{XS`?5WzAE}IU~CdiOiE0*x#En zdfV#~dtaZ19@Y7qF2dHn01w%+%K|$W$4mCcqJm~Ps`ERdH}*RD+|5R6Em})PDT$|# zqL3H*WW!2g*4FJ2m5^vJ9<~7*D&AbSZWj$K0Q>@@EjatV^G3k{0000Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D13F1WK~z{r?U~JM z)Ib=Ae@VLiNY-sD+C_`>(2AFC@lvbB`f=3Si{L>|J%|Urc=1#OL5kk=Pv}W0O7W!V zL6N$65J9LH1<@k4t`%ulcl%+p@qK4!mLzRcgqqGuA4ro)W|Ak#JG1G$hUa-;GHIjL z0NP-nN$dzg$4YWDHppz2^d={A-n85WAz&^LeCFDX~`g%e>9_%l&{G?wRU1{{!m^iw_#>Lh>a4udLM0Wf+9zOY4q_%(k8aD{z^7%e{XIndl zu8vfsT@SAV!Xu;CoX4XNDtET%Vp5(UR_ZLY)mmsAzCLJknWPIdMci^r$Qqe6P9{to zW8t%>_d80q@EhY);R6N!4{?^5ppL(tLbTmUppT`=$tI^r;lj@)Oe`+r8HwMaKz}RG z6$>pfgHX z3|}=34Wghkfsk09pO;(uvSz2#g>b(L2tVXaPQ@gE)k~z(D_8}1_AnXqbo<-z|Ia!Piz9wH=7ArBVLN_o!tmAa+nG^f$ zdyn1~z87(Oxr7%hgt?KD-s}l&mgRC-cz}faSh%~(aXLEoBA1)N)9loyrVMx@o5O)* zRm$Lo>*1A3${b2qIKUYst|%J`xFd3GtF_w4sBaG*dcyTo?5Sg`eW$2n3V$DB)SG76 zN>WCqy^e5xPD=O7`kM~Pa@y-AiGE?(iJUi8Nh~U9TGFz9n5aafpY_7XpMd01ip$|O zW0K@ozbx-uk-DW`+AMYubY$HarZ#?o4TUbwsoT#=76ASL2pFcOuk@^r00000NkvXX Hu0mjf%$mAI literal 0 HcmV?d00001 diff --git a/static/img/icon-border/medium/q7.png b/static/img/icon-border/medium/q7.png new file mode 100644 index 0000000000000000000000000000000000000000..cedeb6e751f7c052660734fb45ac216763691316 GIT binary patch literal 933 zcmV;W16urvP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D13F1WK~z{r?U~JM z)Ib=Ae@VLiNY-sD+C_`>(2AFC@lvbB`f=3Si{L>|J%|Urc=1#OL5kk=Pv}W0O7W!V zL6N$65J9LH1<@k4t`%ulcl%+p@qK4!mLzRcgqqGuA4ro)W|Ak#JG1G$hUa-;GHIjL z0NP-nN$dzg$4YWDHppz2^d={A-n85WAz&^LeCFDX~`g%e>9_%l&{G?wRU1{{!m^iw_#>Lh>a4udLM0Wf+9zOY4q_%(k8aD{z^7%e{XIndl zu8vfsT@SAV!Xu;CoX4XNDtET%Vp5(UR_ZLY)mmsAzCLJknWPIdMci^r$Qqe6P9{to zW8t%>_d80q@EhY);R6N!4{?^5ppL(tLbTmUppT`=$tI^r;lj@)Oe`+r8HwMaKz}RG z6$>pfgHX z3|}=34Wghkfsk09pO;(uvSz2#g>b(L2tVXaPQ@gE)k~z(D_8}1_AnXqbo<-z|Ia!Piz9wH=7ArBVLN_o!tmAa+nG^f$ zdyn1~z87(Oxr7%hgt?KD-s}l&mgRC-cz}faSh%~(aXLEoBA1)N)9loyrVMx@o5O)* zRm$Lo>*1A3${b2qIKUYst|%J`xFd3GtF_w4sBaG*dcyTo?5Sg`eW$2n3V$DB)SG76 zN>WCqy^e5xPD=O7`kM~Pa@y-AiGE?(iJUi8Nh~U9TGFz9n5aafpY_7XpMd01ip$|O zW0K@ozbx-uk-DW`+AMYubY$HarZ#?o4TUbwsoT#=76ASL2pFcOuk@^r00000NkvXX Hu0mjf%$mAI literal 0 HcmV?d00001 diff --git a/static/img/icon-border/medium/q8.png b/static/img/icon-border/medium/q8.png new file mode 100644 index 0000000000000000000000000000000000000000..cedeb6e751f7c052660734fb45ac216763691316 GIT binary patch literal 933 zcmV;W16urvP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D13F1WK~z{r?U~JM z)Ib=Ae@VLiNY-sD+C_`>(2AFC@lvbB`f=3Si{L>|J%|Urc=1#OL5kk=Pv}W0O7W!V zL6N$65J9LH1<@k4t`%ulcl%+p@qK4!mLzRcgqqGuA4ro)W|Ak#JG1G$hUa-;GHIjL z0NP-nN$dzg$4YWDHppz2^d={A-n85WAz&^LeCFDX~`g%e>9_%l&{G?wRU1{{!m^iw_#>Lh>a4udLM0Wf+9zOY4q_%(k8aD{z^7%e{XIndl zu8vfsT@SAV!Xu;CoX4XNDtET%Vp5(UR_ZLY)mmsAzCLJknWPIdMci^r$Qqe6P9{to zW8t%>_d80q@EhY);R6N!4{?^5ppL(tLbTmUppT`=$tI^r;lj@)Oe`+r8HwMaKz}RG z6$>pfgHX z3|}=34Wghkfsk09pO;(uvSz2#g>b(L2tVXaPQ@gE)k~z(D_8}1_AnXqbo<-z|Ia!Piz9wH=7ArBVLN_o!tmAa+nG^f$ zdyn1~z87(Oxr7%hgt?KD-s}l&mgRC-cz}faSh%~(aXLEoBA1)N)9loyrVMx@o5O)* zRm$Lo>*1A3${b2qIKUYst|%J`xFd3GtF_w4sBaG*dcyTo?5Sg`eW$2n3V$DB)SG76 zN>WCqy^e5xPD=O7`kM~Pa@y-AiGE?(iJUi8Nh~U9TGFz9n5aafpY_7XpMd01ip$|O zW0K@ozbx-uk-DW`+AMYubY$HarZ#?o4TUbwsoT#=76ASL2pFcOuk@^r00000NkvXX Hu0mjf%$mAI literal 0 HcmV?d00001 diff --git a/static/img/icon-border/medium/q9.png b/static/img/icon-border/medium/q9.png new file mode 100644 index 0000000000000000000000000000000000000000..8db67b149eebedba3561dcf8a9dd1cd50f1e3c91 GIT binary patch literal 884 zcmV-)1B?8LP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0|`k)K~z{r?U~JM z6G0e%8;P?rzg9LfB3u4Q1p?BaSMg>>wI*9x7>Bd^m0#pJaZqHiEWdyq zXioPE>4Cu{cM26=#7gNCR?Dxr_(Qk&@w57*NMO0nBV6FCO-UxPHTa9F%iG8iPKu>JluF%t37^Udqq_Y zVv|7^lkW!p#oiexw*M3h6+C5;gQ~Zohhzt^S^g;uXVq2Gb8=;>)XzB^})3K-bReMg+ z`!v2W#92?ua9N#{k!i0peEhX3=|0tc`;lp{5ia|aWZmNA4{u^oN;7M(>K-I4(dcEX zF!ClKX_lgPup}%l`pYXz8>^&l*)Fj`pmv7i6{t0oy8W(Y0pKsw0&28B&!Fr80000< KMNUMnLSTX$znmEW literal 0 HcmV?d00001 diff --git a/static/partials/icons.hbs b/static/partials/icons.hbs index aab509e..62a5c39 100644 --- a/static/partials/icons.hbs +++ b/static/partials/icons.hbs @@ -1,7 +1,7 @@