Compare commits
58 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92fda8086e | |||
|
|
28b9fd1aee | ||
| 24c7c403ba | |||
|
|
0e7cf0f988 | ||
| 424d65233b | |||
|
|
6d7398d055 | ||
|
|
cc7ca06182 | ||
| ffdeacc0c4 | |||
|
|
788afd6e8e | ||
|
|
52a3af6642 | ||
|
|
6cb84817ac | ||
|
|
a6001ee47a | ||
|
|
f37189e431 | ||
|
|
2df1fad904 | ||
|
|
13ae2e9b2d | ||
|
|
f739a92e1e | ||
|
|
5e868f9dad | ||
|
|
9801c03013 | ||
|
|
a1e5bb6712 | ||
|
|
401e92eb70 | ||
|
|
4f0f579028 | ||
|
|
f7be06074e | ||
|
|
ac6ca4c84e | ||
|
|
dc1af0802f | ||
|
|
58ca06d1e0 | ||
|
|
0e4fa65d3c | ||
|
|
e497ecb725 | ||
|
|
6a847c8ccb | ||
|
|
dda4aae6f6 | ||
|
|
6116fea58e | ||
|
|
a4c6586bd0 | ||
|
|
980467f49f | ||
|
|
0dd11a77f5 | ||
|
|
c95ad80e3f | ||
|
|
11f13544c9 | ||
|
|
f34edb0ea0 | ||
|
|
843d5f856c | ||
|
|
905f1549fd | ||
|
|
a98f9b4658 | ||
|
|
72a1d3e402 | ||
|
|
588b30c0ce | ||
|
|
bb6428955f | ||
|
|
7cd2748d33 | ||
|
|
8b1d172690 | ||
|
|
1156ceb17f | ||
|
|
08009a575c | ||
|
|
216904fd6e | ||
|
|
8331e4dd73 | ||
|
|
fe21209817 | ||
|
|
8906047a06 | ||
|
|
43ccb61341 | ||
|
|
4062263e8d | ||
|
|
6933e8ee86 | ||
|
|
5b0ab5b118 | ||
|
|
05f5cabf5c | ||
|
|
91f3ec424a | ||
|
|
af497b7ea6 | ||
|
|
2d672fd5ed |
|
|
@ -14,7 +14,7 @@ config.json
|
|||
.env
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Git folder
|
||||
.git/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
ACORE_ARMORY_AOWOW_URL="https://wotlkdb.com"
|
||||
ACORE_ARMORY_AOWOW_URL="https://wowgaming.altervista.org/aowow"
|
||||
ACORE_ARMORY_WEBSITE_URL="https://mywebsite.com"
|
||||
ACORE_ARMORY_WEBSITE_NAME="My Website"
|
||||
ACORE_ARMORY_WEBSITE_ROOT=""
|
||||
|
|
@ -6,6 +6,8 @@ ACORE_ARMORY_IFRAME_MODE__ENABLED=0
|
|||
ACORE_ARMORY_IFRAME_MODE__URL="https://mywebsite.com/armory"
|
||||
ACORE_ARMORY_LOAD_DBCS=1
|
||||
ACORE_ARMORY_HIDE_GAME_MASTERS=1
|
||||
ACORE_ARMORY_TRANSMOG_MODULE=0
|
||||
ACORE_ARMORY_USE_ZAM_CDN=0
|
||||
ACORE_ARMORY_REALMS__0__NAME="AzerothCore"
|
||||
ACORE_ARMORY_REALMS__0__REALM_ID=1
|
||||
ACORE_ARMORY_REALMS__0__AUTH_DATABASE="acore_auth"
|
||||
|
|
|
|||
33
.eslintrc.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"env": {
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"semi": ["error", "always"],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"args": "none"
|
||||
}
|
||||
],
|
||||
"no-duplicate-imports": "warn",
|
||||
"no-promise-executor-return": "warn",
|
||||
"quote-props": ["warn", "as-needed"],
|
||||
"indent": "off",
|
||||
"@typescript-eslint/indent": [
|
||||
"error",
|
||||
"tab",
|
||||
{
|
||||
"ignoredNodes": [
|
||||
"FunctionExpression > .params[decorators.length > 0]",
|
||||
"FunctionExpression > .params > :matches(Decorator, :not(:first-child))",
|
||||
"ClassBody.body > PropertyDefinition[decorators.length > 0] > .key"
|
||||
],
|
||||
"SwitchCase": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
ko_fi: roboto
|
||||
13
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: Build
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
13
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: Lint
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- run: npm ci
|
||||
- run: npm run lint -- --max-warnings=0
|
||||
3
.gitignore
vendored
|
|
@ -15,3 +15,6 @@ config.json
|
|||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
|
|
|
|||
21
.prettierignore
Normal file
|
|
@ -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
|
||||
9
.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"printWidth": 140,
|
||||
"useTabs": true,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
71
README.md
|
|
@ -1,4 +1,5 @@
|
|||
<!-- SHIELDS -->
|
||||
[](https://github.com/r-o-b-o-t-o/azerothcore-armory/actions/workflows/build.yml)
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
|
|
@ -11,11 +12,13 @@
|
|||
<p align="center">
|
||||
A website to view your <a href="https://github.com/azerothcore/azerothcore-wotlk">AzerothCore</a> server's characters
|
||||
<br />
|
||||
<a href="https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?template=bug_report.yml">Report a Bug</a>
|
||||
<a href="https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?assignees=&labels=bug&template=bug_report.yml">Report a Bug</a>
|
||||
·
|
||||
<a href="https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?template=feature_request.yml">Request a Feature</a>
|
||||
<a href="https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?assignees=&labels=enhancement&template=feature_request.yml">Suggest a Feature</a>
|
||||
·
|
||||
<a href="https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?template=question.yml">Ask a Question</a>
|
||||
<a href="https://github.com/r-o-b-o-t-o/azerothcore-armory/issues/new?labels=question&template=question.yml">Ask a Question</a>
|
||||
·
|
||||
<a href="https://www.chromiecraft.com/armory">Demo</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -108,45 +111,40 @@ 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:
|
||||
* Download from script:
|
||||
```sh
|
||||
npm run build
|
||||
npm run fetchdata
|
||||
```
|
||||
**OR**
|
||||
* Download the latest archive (`data.zip`) from the [Releases](https://github.com/r-o-b-o-t-o/azerothcore-armory/releases) page and extract it to the `data/` directory.
|
||||
4. Download the model viewer's data from the [Releases](https://github.com/r-o-b-o-t-o/azerothcore-armory/releases) page and extract it to the `data/` directory.
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
<details>
|
||||
<summary>Main configuration</summary>
|
||||
|
||||
| config.json | .env | Type | Default value | Description |
|
||||
|--------------------------------|----------------------------------------------------|-------------------------------|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `aowowUrl` | `ACORE_ARMORY_AOWOW_URL` | String | `"https://wotlkdb.com"` | The URL of the AoWoW database to use for tooltips and links |
|
||||
| `websiteUrl` | `ACORE_ARMORY_WEBSITE_URL` | String | `"https://mywebsite.com"` | Your website's URL. Used to redirect to the homepage on error pages |
|
||||
| `websiteName` | `ACORE_ARMORY_WEBSITE_NAME` | String | `"My Website"` | Your website's name. Displayed in the redirect button on error pages |
|
||||
| `websiteRoot` | `ACORE_ARMORY_WEBSITE_ROOT` | String | `""` | The root of your armory's URL. If your armory is hosted on, for example, `http://mywebsite.com/azerothcore-armory`, the `websiteRoot` value should be `"/azerothcore-armory"` |
|
||||
| `iframeMode` | `ACORE_ARMORY_IFRAME_MODE__`... | Object | | Settings for the iframe mode |
|
||||
| `iframeMode.enabled` | `ACORE_ARMORY_IFRAME_MODE__ENABLED` | Boolean | `false` | Set to `true` if you want to embed the armory in an iframe |
|
||||
| `iframeMode.url` | `ACORE_ARMORY_IFRAME_MODE__URL` | String | `"https://mywebsite.com/armory"` | Set this to the URL of the page that hosts the `iframe` |
|
||||
| `loadDbcs` | `ACORE_ARMORY_LOAD_DBCS` | Boolean | `true` | Loads the DBC data from the `data` directory into memory when starting up. It is highly recommended to set this to `true`. Only use `false` to keep memory usage low, on a test server for example |
|
||||
| `hideGameMasters` | `ACORE_ARMORY_HIDE_GAME_MASTERS` | Boolean | `true` | Hides Game Master characters if set to `true`. They will not be found in the search page, and their character pages will show a 404 error |
|
||||
| `realms` | `ACORE_ARMORY_REALMS__`... | Array of objects | | An array of realm configurations |
|
||||
| `realms[0].name` | `ACORE_ARMORY_REALMS__0__NAME` | String | `"AzerothCore"` | The name of the realm. Will be used in the URLs, shown on the character pages and in the search page if you have multiple realms |
|
||||
| `realms[0].realmId` | `ACORE_ARMORY_REALMS__0__REALM_ID` | Number | `1` | The realm's ID, this must match the `id` column of the `realmlist` table in the auth database |
|
||||
| `realms[0].authDatabase` | `ACORE_ARMORY_REALMS__0__AUTH_DATABASE` | String | `"acore_auth"` | The name of the auth database |
|
||||
| `realms[0].charactersDatabase` | `ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__`... | Database configuration object | | Configuration for the characters database. See "Database configuration" below |
|
||||
| `worldDatabase` | `ACORE_ARMORY_WORLD_DATABASE__`... | Database configuration object | | Configuration for the world database. This is shared between all realms at the moment. See "Database configuration" below |
|
||||
| `dbQueryTimeout` | `ACORE_ARMORY_DB_QUERY_TIMEOUT` | Number | `10000` | The maximum duration in milliseconds of a database query before it times out |
|
||||
| config.json | .env | Type | Default value | Description |
|
||||
|--------------------------------|----------------------------------------------------|-------------------------------|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `aowowUrl` | `ACORE_ARMORY_AOWOW_URL` | String | `"https://wowgaming.altervista.org/aowow"` | The URL of the AoWoW database to use for tooltips and links |
|
||||
| `websiteUrl` | `ACORE_ARMORY_WEBSITE_URL` | String | `"https://mywebsite.com"` | Your website's URL. Used to redirect to the homepage on error pages |
|
||||
| `websiteName` | `ACORE_ARMORY_WEBSITE_NAME` | String | `"My Website"` | Your website's name. Displayed in the redirect button on error pages |
|
||||
| `websiteRoot` | `ACORE_ARMORY_WEBSITE_ROOT` | String | `""` | The root of your armory's URL. If your armory is hosted on, for example, `http://mywebsite.com/azerothcore-armory`, the `websiteRoot` value should be `"/azerothcore-armory"` |
|
||||
| `iframeMode` | `ACORE_ARMORY_IFRAME_MODE__`... | Object | | Settings for the iframe mode |
|
||||
| `iframeMode.enabled` | `ACORE_ARMORY_IFRAME_MODE__ENABLED` | Boolean | `false` | Set to `true` if you want to embed the armory in an iframe |
|
||||
| `iframeMode.url` | `ACORE_ARMORY_IFRAME_MODE__URL` | String | `"https://mywebsite.com/armory"` | Set this to the URL of the page that hosts the `iframe` |
|
||||
| `loadDbcs` | `ACORE_ARMORY_LOAD_DBCS` | Boolean | `true` | Loads the DBC data from the `data` directory into memory when starting up. It is highly recommended to set this to `true`. Only use `false` to keep memory usage low, on a test server for example |
|
||||
| `hideGameMasters` | `ACORE_ARMORY_HIDE_GAME_MASTERS` | Boolean | `true` | Hides Game Master characters if set to `true`. They will not be found in the search page, and their character pages will show a 404 error |
|
||||
| `transmogModule` | `ACORE_ARMORY_TRANSMOG_MODULE` | Boolean | `false` | Set this to `true` if your server uses the [transmogrification module](https://github.com/azerothcore/mod-transmog) and you want to display the transmogrified items on the 3D model |
|
||||
| `useZamCdn` | `ACORE_ARMORY_USE_ZAM_CDN` | Boolean | `false` | Set this to `true` to use the ZAM network CDN for the 3D model viewer instead of the assets in your local `data` folder |
|
||||
| `realms` | `ACORE_ARMORY_REALMS__`... | Array of objects | | An array of realm configurations |
|
||||
| `realms[0].name` | `ACORE_ARMORY_REALMS__0__NAME` | String | `"AzerothCore"` | The name of the realm. Will be used in the URLs, shown on the character pages and in the search page if you have multiple realms |
|
||||
| `realms[0].realmId` | `ACORE_ARMORY_REALMS__0__REALM_ID` | Number | `1` | The realm's ID, this must match the `id` column of the `realmlist` table in the auth database |
|
||||
| `realms[0].authDatabase` | `ACORE_ARMORY_REALMS__0__AUTH_DATABASE` | String | `"acore_auth"` | The name of the auth database |
|
||||
| `realms[0].charactersDatabase` | `ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__`... | Database configuration object | | Configuration for the characters database. See "Database configuration" below |
|
||||
| `worldDatabase` | `ACORE_ARMORY_WORLD_DATABASE__`... | Database configuration object | | Configuration for the world database. This is shared between all realms at the moment. See "Database configuration" below |
|
||||
| `dbQueryTimeout` | `ACORE_ARMORY_DB_QUERY_TIMEOUT` | Number | `10000` | The maximum duration in milliseconds of a database query before it times out |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -180,8 +178,7 @@ Open a web browser and navigate to http://localhost:48733
|
|||
Other useful npm scripts:
|
||||
* `npm run clean`: cleans the build directory
|
||||
* `npm run watch`: watches for changes and rebuilds automatically, useful for development
|
||||
* `npm run fetchdata`: downloads the data needed by the 3d model viewer
|
||||
* `npm run cleardata`: clears the data downloaded for the 3d model viewer
|
||||
* `npm run cleardata`: clears the data downloaded for the 3D model viewer
|
||||
|
||||
### With Docker
|
||||
|
||||
|
|
@ -257,7 +254,7 @@ This repository is used in production over at [ChromieCraft](https://www.chromie
|
|||
- [X] Character page
|
||||
- [X] Online/offline status
|
||||
- [X] Equipment with tooltips
|
||||
- [X] 3d model, including mounts
|
||||
- [X] 3D model, including mounts and [transmogrifications](https://github.com/azerothcore/mod-transmog)
|
||||
- [X] Talent trees, including glyphs and dual spec support
|
||||
- [X] Achievements
|
||||
- [X] PvP statistics, including arena teams
|
||||
|
|
@ -271,6 +268,8 @@ This repository is used in production over at [ChromieCraft](https://www.chromie
|
|||
- [X] Multiple realms support
|
||||
- [ ] PvE ladder
|
||||
- [ ] PvP ladder
|
||||
- [X] Arena ladder
|
||||
- [ ] Achievements ladder
|
||||
|
||||
See the [open issues](https://github.com/r-o-b-o-t-o/azerothcore-armory/issues) for a list of suggested features and known issues.
|
||||
|
||||
|
|
@ -308,7 +307,7 @@ Distributed under the MIT License. See the [`LICENSE`][license-url] file for mor
|
|||
<!-- CONTACT -->
|
||||
## Contact
|
||||
|
||||
<img src="https://img.shields.io/badge/Discord-5865F2?style=flat&logo=discord&logoColor=white"/> Feel free to get in touch with me on Discord: `Roboto#9185`
|
||||
<img src="https://img.shields.io/badge/Discord-5865F2?style=flat&logo=discord&logoColor=white"/> Feel free to get in touch with me on Discord: `roboto_`
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
|
@ -324,4 +323,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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"aowowUrl": "https://wotlkdb.com",
|
||||
"aowowUrl": "https://wowgaming.altervista.org/aowow",
|
||||
"websiteUrl": "https://mywebsite.com",
|
||||
"websiteName": "My Website",
|
||||
"websiteRoot": "",
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
},
|
||||
"loadDbcs": true,
|
||||
"hideGameMasters": true,
|
||||
"hideBotAccounts": true,
|
||||
"botAccountPatterns": "RNDBOT%",
|
||||
"transmogModule": false,
|
||||
"useZamCdn": false,
|
||||
"realms": [
|
||||
{
|
||||
"name": "AzerothCore",
|
||||
|
|
|
|||
92
docker-compose.mythica.yml
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Portainer stack for the Mythica armory.
|
||||
#
|
||||
# Differs from the upstream docker-compose.yml in three ways, all of them
|
||||
# because Portainer deploys from a git checkout it manages itself:
|
||||
#
|
||||
# 1. Configuration comes from environment variables set in the Portainer
|
||||
# stack UI, not from a .env file in the repo (upstream uses env_file,
|
||||
# which Portainer git stacks do not provide).
|
||||
# 2. Only the four large model-data directories are bind-mounted from the
|
||||
# host. The DBC .csv files stay in the image so they stay versioned with
|
||||
# the code, and Portainer re-cloning the repo cannot wipe the 2 GB of
|
||||
# model data.
|
||||
# 3. The unused ac-network block is dropped.
|
||||
#
|
||||
# See docs/mythica-deploy.md for the full procedure.
|
||||
|
||||
services:
|
||||
armory:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mythica-armory
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
# --- site identity -------------------------------------------------
|
||||
ACORE_ARMORY_WEBSITE_NAME: ${ARMORY_WEBSITE_NAME:-Mythica}
|
||||
ACORE_ARMORY_WEBSITE_URL: ${ARMORY_WEBSITE_URL:-http://192.168.1.77:48733}
|
||||
ACORE_ARMORY_WEBSITE_ROOT: ${ARMORY_WEBSITE_ROOT:-}
|
||||
|
||||
# Item tooltips and item icons are loaded from this AoWoW instance.
|
||||
# The default is a public third-party site: point this at your own
|
||||
# AoWoW if you do not want that dependency.
|
||||
ACORE_ARMORY_AOWOW_URL: ${ARMORY_AOWOW_URL:-https://wowgaming.altervista.org/aowow}
|
||||
|
||||
# --- embedding -----------------------------------------------------
|
||||
# Set both when embedding the armory in a page on the main site.
|
||||
ACORE_ARMORY_IFRAME_MODE__ENABLED: ${ARMORY_IFRAME_ENABLED:-0}
|
||||
ACORE_ARMORY_IFRAME_MODE__URL: ${ARMORY_IFRAME_URL:-}
|
||||
|
||||
# --- behaviour -----------------------------------------------------
|
||||
ACORE_ARMORY_LOAD_DBCS: ${ARMORY_LOAD_DBCS:-1}
|
||||
# Off by default here: GM accounts on this realm belong to real
|
||||
# players who should appear in the armory like anyone else. Set to
|
||||
# 1 to hide them from search and 404 their character pages.
|
||||
ACORE_ARMORY_HIDE_GAME_MASTERS: ${ARMORY_HIDE_GMS:-0}
|
||||
|
||||
# Playerbots are ordinary accounts, not game masters, so the GM
|
||||
# filter does not touch them. Without this the armory lists ~1350
|
||||
# bots against ~29 real characters.
|
||||
# Comma-separated LIKE patterns. RNDBOT% covers the Playerbots
|
||||
# accounts; ahouse is the auction-house bot, which owns 9 mule
|
||||
# characters. Add further service accounts here as they appear.
|
||||
ACORE_ARMORY_HIDE_BOT_ACCOUNTS: ${ARMORY_HIDE_BOTS:-1}
|
||||
ACORE_ARMORY_BOT_ACCOUNT_PATTERNS: ${ARMORY_BOT_PATTERNS:-RNDBOT%,ahouse}
|
||||
ACORE_ARMORY_TRANSMOG_MODULE: ${ARMORY_TRANSMOG:-0}
|
||||
|
||||
# 0 = serve 3D model assets from the local data directory.
|
||||
# Leave it at 0: the upstream fetch tool can no longer rebuild this
|
||||
# data from the Zam CDN, so the local copy is the only source.
|
||||
ACORE_ARMORY_USE_ZAM_CDN: ${ARMORY_USE_ZAM_CDN:-0}
|
||||
|
||||
# --- realm ---------------------------------------------------------
|
||||
ACORE_ARMORY_REALMS__0__NAME: ${ARMORY_REALM_NAME:-Mythica}
|
||||
ACORE_ARMORY_REALMS__0__REALM_ID: ${ARMORY_REALM_ID:-1}
|
||||
ACORE_ARMORY_REALMS__0__AUTH_DATABASE: ${ARMORY_AUTH_DB:-acore_auth}
|
||||
ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__HOST: ${ARMORY_DB_HOST:-192.168.1.60}
|
||||
ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__PORT: ${ARMORY_DB_PORT:-3306}
|
||||
ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__USER: ${ARMORY_DB_USER:?set ARMORY_DB_USER}
|
||||
ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__PASSWORD: ${ARMORY_DB_PASS:?set ARMORY_DB_PASS}
|
||||
ACORE_ARMORY_REALMS__0__CHARACTERS_DATABASE__DATABASE: ${ARMORY_CHARACTERS_DB:-acore_characters}
|
||||
|
||||
# --- world database -------------------------------------------------
|
||||
ACORE_ARMORY_WORLD_DATABASE__HOST: ${ARMORY_DB_HOST:-192.168.1.60}
|
||||
ACORE_ARMORY_WORLD_DATABASE__PORT: ${ARMORY_DB_PORT:-3306}
|
||||
ACORE_ARMORY_WORLD_DATABASE__USER: ${ARMORY_DB_USER:?set ARMORY_DB_USER}
|
||||
ACORE_ARMORY_WORLD_DATABASE__PASSWORD: ${ARMORY_DB_PASS:?set ARMORY_DB_PASS}
|
||||
ACORE_ARMORY_WORLD_DATABASE__DATABASE: ${ARMORY_WORLD_DB:-acore_world}
|
||||
|
||||
ACORE_ARMORY_DB_QUERY_TIMEOUT: ${ARMORY_DB_TIMEOUT:-10000}
|
||||
|
||||
volumes:
|
||||
# The 3D model payload, extracted from the release archive. These
|
||||
# four directories are exactly the top level of data.tar.gz.
|
||||
- ${ARMORY_DATA_DIR:-/opt/mythica-armory/data}/meta:/data/meta
|
||||
- ${ARMORY_DATA_DIR:-/opt/mythica-armory/data}/bone:/data/bone
|
||||
- ${ARMORY_DATA_DIR:-/opt/mythica-armory/data}/mo3:/data/mo3
|
||||
- ${ARMORY_DATA_DIR:-/opt/mythica-armory/data}/textures:/data/textures
|
||||
- ${ARMORY_LOG_DIR:-/opt/mythica-armory/logs}:/logs
|
||||
|
||||
ports:
|
||||
- "${ARMORY_PORT:-48733}:48733"
|
||||
|
|
@ -17,3 +17,6 @@ services:
|
|||
networks:
|
||||
local-private-net:
|
||||
driver: bridge
|
||||
ac-network:
|
||||
name: ${DOCKER_AC_NETWORK_NAME:-azerothcore-wotlk_ac-network} # default network name if you use the docker setup of ac
|
||||
external: ${DOCKER_AC_NETWORK_EXTERNAL:-false}
|
||||
|
|
|
|||
140
docs/mythica-deploy.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Deploying the armory on Nox
|
||||
|
||||
This fork of [r-o-b-o-t-o/azerothcore-armory](https://github.com/r-o-b-o-t-o/azerothcore-armory)
|
||||
(MIT) is set up to run as a Portainer stack on Nox, alongside the Mythica web
|
||||
page, reading the AzerothCore databases on the game server over the LAN.
|
||||
|
||||
**It must not run on the AzerothCore VM (192.168.1.60).** That box is tuned for
|
||||
the playerbot load; anything extra there means redoing that balancing. Nothing
|
||||
in this procedure touches the game server — the armory only reads its databases.
|
||||
|
||||
---
|
||||
|
||||
## Why the model data is a manual step
|
||||
|
||||
The armory needs about 2 GB of processed 3D model data — bone sets, mo3 meshes,
|
||||
textures, and the per-race character descriptors.
|
||||
|
||||
Upstream ships a `fetchdata` tool that downloads this from Zam's CDN. **It no
|
||||
longer works.** Zam reorganised that part of the CDN, and every character path
|
||||
now 404s (`meta/character/*`, `meta/charactercustomization2/*` — checked against
|
||||
the `live`, `wrath` and `classic` prefixes and several filename variants). The
|
||||
armor and item paths still resolve, so a run appears to succeed while silently
|
||||
producing no character data, and the app then fails at startup on
|
||||
`data/meta/charactercustomization2/1_0.json`.
|
||||
|
||||
The only remaining source is the snapshot in upstream's v1.0.0 release. That
|
||||
snapshot is mirrored into this fork's own release so the fork does not depend on
|
||||
GitHub staying up for the one piece that cannot be regenerated.
|
||||
|
||||
The mirrored copy is **split into five parts** because Nginx Proxy Manager in
|
||||
front of the forge rejects request bodies at that size (500 MB uploads fine,
|
||||
2 GB returns 413). Raising `client_max_body_size` on the forge's proxy host
|
||||
would let it be stored as a single file, if that is ever worth doing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Put the model data on Nox
|
||||
|
||||
Once. Redeploying the stack does not disturb it.
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/mythica-armory/data /opt/mythica-armory/logs
|
||||
sudo chown -R "$USER:$USER" /opt/mythica-armory # else the downloads below fail
|
||||
cd /opt/mythica-armory/data
|
||||
|
||||
BASE=https://gitlab.hallsworth.ca/yrtria/azerothcore-armory/releases/download/v1.0.0
|
||||
for i in 0 1 2 3 4; do curl -LO $BASE/data.tar.gz.part-$i; done
|
||||
curl -LO $BASE/SHA256SUMS.txt
|
||||
|
||||
cat data.tar.gz.part-* > data.tar.gz
|
||||
sha256sum -c --ignore-missing SHA256SUMS.txt # data.tar.gz must say OK
|
||||
|
||||
tar xzf data.tar.gz
|
||||
rm -f data.tar.gz data.tar.gz.part-*
|
||||
```
|
||||
|
||||
Afterwards `/opt/mythica-armory/data` holds four directories — `meta`, `bone`,
|
||||
`mo3`, `textures` — totalling roughly 2.5 GB unpacked.
|
||||
|
||||
Ownership by your own user is fine — the container reads these as root either
|
||||
way, and it keeps the download and extract steps out of `sudo`.
|
||||
|
||||
The DBC `.csv` files are **not** part of this. They are committed to the repo and
|
||||
travel in the image, so they stay in step with the code.
|
||||
|
||||
> `data.tar.gz.UPSTREAM-GITHUB-LINK.txt` on the same release is not the archive.
|
||||
> It is the original external reference Forgejo recorded during migration — a
|
||||
> short-lived signed GitHub URL, kept only for provenance.
|
||||
|
||||
## 2. Create the Portainer stack
|
||||
|
||||
Repository stack, same pattern as `wow-web-page`:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Repository URL | `https://gitlab.hallsworth.ca/yrtria/azerothcore-armory.git` |
|
||||
| Reference | `refs/heads/master` |
|
||||
| Compose path | `docker-compose.mythica.yml` |
|
||||
|
||||
Environment variables — `ARMORY_DB_USER` and `ARMORY_DB_PASS` are required and
|
||||
the stack refuses to start without them:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ARMORY_DB_USER` | database user (see §4) |
|
||||
| `ARMORY_DB_PASS` | its password |
|
||||
| `ARMORY_DB_HOST` | `192.168.1.60` |
|
||||
| `ARMORY_WEBSITE_URL` | `https://armory.dionysismedia.ca` once proxied, otherwise `http://192.168.1.77:48733` |
|
||||
| `ARMORY_WEBSITE_NAME` | `Mythica` |
|
||||
| `ARMORY_REALM_ID` | `1` |
|
||||
| `ARMORY_DATA_DIR` | `/opt/mythica-armory/data` |
|
||||
| `ARMORY_LOG_DIR` | `/opt/mythica-armory/logs` |
|
||||
|
||||
The first build compiles TypeScript, so it takes a few minutes. It listens on
|
||||
48733.
|
||||
|
||||
## 3. Reverse proxy
|
||||
|
||||
Add a proxy host in NPM pointing at `192.168.1.77:48733`.
|
||||
|
||||
To embed it in the main site instead of giving it its own subdomain, set
|
||||
`ARMORY_IFRAME_ENABLED=1` and `ARMORY_IFRAME_URL` to the page that hosts the
|
||||
iframe, then use the snippet in the upstream README. That path keeps the site's
|
||||
own header and navigation around it.
|
||||
|
||||
## 4. Database access
|
||||
|
||||
`acore` is already granted from `192.168.1.%` and MySQL on the game server binds
|
||||
`0.0.0.0`, so this works with no changes. But the armory only ever reads, and it
|
||||
is public-facing, so a dedicated read-only user is worth the two minutes:
|
||||
|
||||
```sql
|
||||
CREATE USER 'armory'@'192.168.1.%' IDENTIFIED BY '<password>';
|
||||
GRANT SELECT ON acore_characters.* TO 'armory'@'192.168.1.%';
|
||||
GRANT SELECT ON acore_world.* TO 'armory'@'192.168.1.%';
|
||||
GRANT SELECT ON acore_auth.* TO 'armory'@'192.168.1.%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
## 5. Things to know
|
||||
|
||||
- **Item icons and tooltips come from a third-party AoWoW instance**
|
||||
(`wowgaming.altervista.org` by default) — they are hotlinked, so they break if
|
||||
that site moves or goes down. Point `ARMORY_AOWOW_URL` at your own AoWoW to
|
||||
remove that dependency. This is the one remaining external asset dependency;
|
||||
the model data is now self-hosted.
|
||||
- **The armory has its own talent view** with dual-spec support, which overlaps
|
||||
the talent page already in `WoW-Web-Page`. Decide which one owns the character
|
||||
page rather than shipping both.
|
||||
- **The Dockerfile builds from `node:16`**, which is end-of-life. It builds and
|
||||
runs fine; bumping it is a separate change from getting this deployed.
|
||||
- **`hideGameMasters` is off** in this stack, unlike upstream. GM accounts on
|
||||
this realm belong to real players — `Spinnaker` among them — so hiding them
|
||||
would hide people who should be listed. Set `ARMORY_HIDE_GMS=1` to restore
|
||||
upstream behaviour.
|
||||
- **Service accounts are hidden** via `hideBotAccounts` / `botAccountPatterns`,
|
||||
a comma-separated list of LIKE patterns defaulting to `RNDBOT%,ahouse` — the
|
||||
Playerbots accounts and the auction-house bot, which owns 9 mule characters.
|
||||
None of them are game masters, so the GM filter never touched them. Add more
|
||||
with `ARMORY_BOT_PATTERNS`, or set `ARMORY_HIDE_BOTS=0` to show them all.
|
||||
3498
package-lock.json
generated
38
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "azerothcore-armory",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
"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",
|
||||
"lint": "eslint src/",
|
||||
"prettier": "prettier . -w",
|
||||
"fetchdata": "node build/tools/fetchdata.js",
|
||||
"cleardata": "rimraf data/bone data/meta data/mo3 data/textures"
|
||||
},
|
||||
|
|
@ -15,31 +17,33 @@
|
|||
"homepage": "https://github.com/r-o-b-o-t-o/azerothcore-armory",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/cli-progress": "^3.9.2",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/glob": "^7.2.0",
|
||||
"@types/mkdirp": "^1.0.2",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/node-fetch": "^2.6.1",
|
||||
"@types/pako": "^1.0.3",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"concurrently": "^7.0.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"concurrently": "^7.2.2",
|
||||
"eslint": "^8.18.0",
|
||||
"nodemon": "^2.0.18",
|
||||
"prettier": "^2.7.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"typescript": "^4.5.5"
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supercharge/promise-pool": "^2.1.0",
|
||||
"@supercharge/promise-pool": "^2.3.0",
|
||||
"camelcase": "^6.3.0",
|
||||
"cli-progress": "^3.10.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.3",
|
||||
"express-handlebars": "^6.0.2",
|
||||
"glob": "^7.2.0",
|
||||
"glob-promise": "^4.2.2",
|
||||
"cli-progress": "^3.11.2",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.18.1",
|
||||
"express-handlebars": "^6.0.6",
|
||||
"glob": "^8.0.3",
|
||||
"handlebars-helpers": "^0.9.8",
|
||||
"html-entities": "^2.3.2",
|
||||
"html-entities": "^2.3.3",
|
||||
"mkdirp": "^1.0.4",
|
||||
"morgan": "^1.10.0",
|
||||
"mysql2": "^2.3.3",
|
||||
|
|
@ -48,6 +52,6 @@
|
|||
"pretty-ms": "^7.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.6.0"
|
||||
"winston": "^3.8.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { CharacterCustomization } from "./data/CharacterCustomization";
|
|||
import { IndexController } from "./controllers/IndexController";
|
||||
import { CharacterController } from "./controllers/CharacterController";
|
||||
import { GuildController } from "./controllers/GuildController";
|
||||
import { ArenaController } from "./controllers/ArenaController";
|
||||
|
||||
export class Armory {
|
||||
public characterCustomization: CharacterCustomization;
|
||||
|
|
@ -85,22 +86,24 @@ 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;
|
||||
|
||||
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: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
...require("handlebars-helpers")(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.set("view engine", "handlebars");
|
||||
app.set("views", path.join(process.cwd(), "static"));
|
||||
|
||||
|
|
@ -122,20 +125,22 @@ 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`));
|
||||
app.use("/img", express.static(`static/img`));
|
||||
app.use("/data/mo3", express.static(`data/mo3`));
|
||||
app.use("/data/meta", express.static(`data/meta`));
|
||||
app.use("/data/bone", express.static(`data/bone`));
|
||||
app.use("/data/textures", express.static(`data/textures`));
|
||||
app.use("/data/background.png", express.static(`data/modelviewer-background.png`));
|
||||
app.use("/js", express.static("static/js"));
|
||||
app.use("/css", express.static("static/css"));
|
||||
app.use("/img", express.static("static/img"));
|
||||
app.use("/data/mo3", express.static("data/mo3"));
|
||||
app.use("/data/meta", express.static("data/meta"));
|
||||
app.use("/data/bone", express.static("data/bone"));
|
||||
app.use("/data/textures", express.static("data/textures"));
|
||||
app.use("/data/background.png", express.static("data/modelviewer-background.png"));
|
||||
|
||||
const indexController = new IndexController(this);
|
||||
app.get("/", this.wrapRoute(indexController.index.bind(indexController)));
|
||||
|
|
@ -153,6 +158,11 @@ export class Armory {
|
|||
app.get("/guild/:realm/:name", this.wrapRoute(guildsController.guild.bind(guildsController)));
|
||||
app.get("/guild/:realm/:guild/members", this.wrapRoute(guildsController.members.bind(guildsController)));
|
||||
|
||||
const arenaController = new ArenaController(this);
|
||||
app.get("/arena", this.wrapRoute(arenaController.index.bind(arenaController)));
|
||||
app.get("/arena/ladder", this.wrapRoute(arenaController.ladder.bind(arenaController)));
|
||||
app.get("/arena/team/:realm/:name", this.wrapRoute(arenaController.team.bind(arenaController)));
|
||||
|
||||
app.use((err, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
// Error handler
|
||||
|
||||
|
|
@ -198,14 +208,14 @@ 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<string> {
|
||||
const db = this.getCharactersDb(realm);
|
||||
|
||||
if (!(realm in this.charsetCache)) {
|
||||
const [rows, fields] = await db.query({
|
||||
const [rows] = await db.query({
|
||||
sql: `
|
||||
SELECT CCSA.character_set_name AS charset FROM information_schema.\`TABLES\` T,
|
||||
information_schema.\`COLLATION_CHARACTER_SET_APPLICABILITY\` CCSA
|
||||
|
|
@ -232,7 +242,7 @@ export class Armory {
|
|||
}, 500);
|
||||
}
|
||||
|
||||
private wrapRoute(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<any>) {
|
||||
private wrapRoute(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<void>) {
|
||||
// Adds error handling for promise-based controller methods
|
||||
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -31,12 +31,16 @@ export class Config {
|
|||
public iframeMode: IIframeModeConfig;
|
||||
public loadDbcs: boolean;
|
||||
public hideGameMasters: boolean;
|
||||
public hideBotAccounts: boolean;
|
||||
public botAccountPatterns: string;
|
||||
public transmogModule: boolean;
|
||||
public useZamCdn: boolean;
|
||||
public realms: IRealmConfig[];
|
||||
public worldDatabase: IDatabaseConfig;
|
||||
public dbQueryTimeout: number;
|
||||
|
||||
private static envPrefix: string = "ACORE_ARMORY";
|
||||
private static checkedMissingField: boolean = false;
|
||||
private static envPrefix = "ACORE_ARMORY";
|
||||
private static checkedMissingField = false;
|
||||
|
||||
public static async load(logger: winston.Logger): Promise<Config> {
|
||||
try {
|
||||
|
|
@ -70,13 +74,13 @@ export class Config {
|
|||
return config as Config;
|
||||
}
|
||||
|
||||
private static loadObjFromEnv(logger: winston.Logger, obj: object, model: object, parentName: string = "") {
|
||||
private static loadObjFromEnv(logger: winston.Logger, obj: object, model: object, parentName = "") {
|
||||
if (parentName !== "") {
|
||||
parentName += ".";
|
||||
}
|
||||
|
||||
for (const field in model) {
|
||||
if (!model.hasOwnProperty(field)) {
|
||||
if (!Object.hasOwnProperty.call(model, field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -85,9 +89,9 @@ export class Config {
|
|||
} else if (typeof model[field] === "object") {
|
||||
obj[field] = {};
|
||||
Config.loadObjFromEnv(logger, obj[field], model[field], parentName + field);
|
||||
} else if (!obj.hasOwnProperty(field)) {
|
||||
} else if (!Object.hasOwnProperty.call(obj, field)) {
|
||||
const key = Config.getEnvKey(parentName + field);
|
||||
if (process.env.hasOwnProperty(key)) {
|
||||
if (Object.hasOwnProperty.call(process.env, key)) {
|
||||
obj[field] = Config.parseEnvValue(process.env[key], model[field]);
|
||||
} else if (!Config.checkedMissingField) {
|
||||
logger.warn(`Config field ${key} is missing from .env!`);
|
||||
|
|
@ -96,16 +100,16 @@ export class Config {
|
|||
}
|
||||
}
|
||||
|
||||
private static loadArrayFromEnv(logger: winston.Logger, model: any, parentName: string = ""): any[] {
|
||||
private static loadArrayFromEnv(logger: winston.Logger, model, parentName = ""): unknown[] {
|
||||
if (parentName !== "") {
|
||||
parentName += ".";
|
||||
}
|
||||
|
||||
const arr = [];
|
||||
let i = 0;
|
||||
while (true) {
|
||||
for (;;) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -115,10 +119,10 @@ 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)) {
|
||||
} else if (Object.hasOwnProperty.call(process.env, key)) {
|
||||
arr.push(Config.parseEnvValue(process.env[key], model));
|
||||
} else {
|
||||
break;
|
||||
|
|
@ -131,13 +135,17 @@ 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 {
|
||||
private static parseEnvValue(value: string, model: boolean | number | string): boolean | number | string {
|
||||
const type = typeof model;
|
||||
const lower = value.toLowerCase();
|
||||
if (type === "boolean") {
|
||||
|
|
@ -149,7 +157,7 @@ export class Config {
|
|||
return value;
|
||||
}
|
||||
|
||||
private static checkAllMissingFields(logger: winston.Logger, obj: object, model: object, parentName: string = "") {
|
||||
private static checkAllMissingFields(logger: winston.Logger, obj: object, model: object, parentName = "") {
|
||||
const missing = Config.hasMissingFields(obj, model);
|
||||
if (parentName !== "") {
|
||||
parentName += ".";
|
||||
|
|
@ -157,20 +165,16 @@ export class Config {
|
|||
for (const field of missing) {
|
||||
logger.warn(`Field ${parentName}${field} is missing from config.json!`);
|
||||
}
|
||||
for (const key in model) {
|
||||
if (typeof model[key] === "object" && obj.hasOwnProperty(key)) {
|
||||
for (const key of Object.keys(model)) {
|
||||
if (typeof model[key] === "object" && Object.hasOwnProperty.call(obj, key)) {
|
||||
Config.checkAllMissingFields(logger, obj[key], model[key], parentName + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { Pool } from "mysql2/promise";
|
||||
import { Pool, RowDataPacket } from "mysql2/promise";
|
||||
import { Query } from "express-serve-static-core";
|
||||
|
||||
export interface IResult {
|
||||
recordsTotal: number;
|
||||
recordsFiltered: number;
|
||||
draw: number;
|
||||
data: any[][];
|
||||
data: unknown[][];
|
||||
}
|
||||
|
||||
export interface IColumnSettings {
|
||||
name: string;
|
||||
collation?: string;
|
||||
formatter?: (data: string | number | null, row: any) => string;
|
||||
formatter?: (data: string | number | null, row: unknown) => string;
|
||||
table?: string;
|
||||
database?: string;
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ export interface IColumnJoin {
|
|||
column2: string;
|
||||
database2?: string;
|
||||
kind: "INNER" | "FULL OUTER" | "LEFT" | "RIGHT";
|
||||
where?: string;
|
||||
}
|
||||
|
||||
export class DataTablesSsp {
|
||||
|
|
@ -38,52 +39,55 @@ 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[] = [];
|
||||
private filterBindings: (string | number)[] = [];
|
||||
private customBindings: (string | number)[] = [];
|
||||
private filterWhereSql: string = "1";
|
||||
private customWhereSql: string = "1";
|
||||
private limitSql: string = "";
|
||||
private orderSql: string = "";
|
||||
private joinSql: string = "";
|
||||
private filterWhereSql = "1";
|
||||
private customWhereSql = "1";
|
||||
private limitSql = "";
|
||||
private orderSql = "";
|
||||
private joinSql = "";
|
||||
|
||||
public constructor(query: Query, db: Pool, table: string, primaryKey: string, columnSettings: IColumnSettings[]) {
|
||||
this.start = parseInt(query.start as string, 10);
|
||||
this.length = parseInt(query.length as string, 10);
|
||||
this.start = Math.max(0, parseInt(query.start as string, 10));
|
||||
this.length = Math.min(100, Math.max(0, 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) => {
|
||||
const dir = order.dir.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||
return { column: parseInt(order.column, 10), dir };
|
||||
});
|
||||
this.columns = (
|
||||
query.columns as { data: string; name: string; searchable: string; orderable: string; search: { value: string; regex: string } }[]
|
||||
).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",
|
||||
value: (query.search as unknown)["value"] as string,
|
||||
regex: (query.search as unknown)["regex"] === "true",
|
||||
};
|
||||
|
||||
this.db = db;
|
||||
|
|
@ -93,7 +97,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}\``;
|
||||
}
|
||||
|
||||
|
|
@ -130,8 +134,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;
|
||||
|
|
@ -153,19 +158,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}
|
||||
|
|
@ -198,35 +200,32 @@ export class DataTablesSsp {
|
|||
`;
|
||||
}
|
||||
|
||||
public async run(queryTimeout: number = 10_000): Promise<IResult> {
|
||||
this.limit()
|
||||
.order()
|
||||
.join()
|
||||
.filter();
|
||||
public async run(queryTimeout = 10_000): Promise<IResult> {
|
||||
this.limit().order().join().filter();
|
||||
|
||||
const bindings = [...this.filterBindings, ...this.customBindings];
|
||||
|
||||
let [rows, fields] = await this.db.query({
|
||||
let [rows] = await this.db.query({
|
||||
sql: this.buildTotalCountSql(),
|
||||
values: this.customBindings,
|
||||
timeout: queryTimeout,
|
||||
});
|
||||
const recordsTotal = rows[0].count;
|
||||
|
||||
[rows, fields] = await this.db.query({
|
||||
[rows] = await this.db.query({
|
||||
sql: this.buildFilteredCountSql(),
|
||||
values: bindings,
|
||||
timeout: queryTimeout,
|
||||
});
|
||||
const recordsFiltered = rows[0].count;
|
||||
|
||||
[rows, fields] = await this.db.query({
|
||||
[rows] = await this.db.query({
|
||||
sql: this.sql(),
|
||||
rowsAsArray: true,
|
||||
values: bindings,
|
||||
timeout: queryTimeout,
|
||||
});
|
||||
rows = (rows as any[][]).map(row => {
|
||||
rows = (rows as RowDataPacket[]).map((row) => {
|
||||
for (let i = 0; i < this.columnSettings.length; ++i) {
|
||||
const col = this.columnSettings[i];
|
||||
if (col.formatter !== undefined) {
|
||||
|
|
@ -240,7 +239,7 @@ export class DataTablesSsp {
|
|||
recordsTotal,
|
||||
recordsFiltered,
|
||||
draw: this.draw,
|
||||
data: rows,
|
||||
data: rows as unknown[][],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,16 @@ export class Utils {
|
|||
return [1, 3, 4, 7, 11].includes(race) ? EFaction.Alliance : EFaction.Horde;
|
||||
}
|
||||
|
||||
public static makeEmblemObject(obj: any, padWithZeroes: boolean = true): IEmblem {
|
||||
public static makeEmblemObject(
|
||||
obj: {
|
||||
background: number;
|
||||
emblemStyle: number;
|
||||
emblemColor: number;
|
||||
borderStyle: number;
|
||||
borderColor: number;
|
||||
},
|
||||
padWithZeroes = true,
|
||||
): IEmblem {
|
||||
const padLength = padWithZeroes ? 2 : 0;
|
||||
return {
|
||||
icon: obj.emblemStyle.toString().padStart(padLength, "0"),
|
||||
|
|
@ -51,4 +60,37 @@ export class Utils {
|
|||
background: obj.background.toString().padStart(padLength, "0"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a configuration-supplied string for inline use in SQL.
|
||||
*
|
||||
* The bot-account filter has to be spliced into join clauses that the query
|
||||
* builder emits as raw SQL, so it cannot be passed as a bound parameter the
|
||||
* way a normal value would be. The value comes from the operator's own
|
||||
* config rather than from a request, but quoting it keeps a stray apostrophe
|
||||
* from producing a broken or surprising query.
|
||||
*/
|
||||
public static quoteSqlString(value: string): string {
|
||||
return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the LIKE test that identifies non-player accounts.
|
||||
*
|
||||
* `patterns` is a comma-separated list, so a realm can name several service
|
||||
* accounts at once — Playerbots plus an auction-house bot, say. Returns an
|
||||
* empty string when nothing is configured, which callers treat as "do not
|
||||
* filter" rather than emitting an always-false clause.
|
||||
*/
|
||||
public static botAccountFilter(column: string, patterns: string): string {
|
||||
const list = String(patterns ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
|
||||
if (list.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `(${list.map((p) => `${column} LIKE ${Utils.quoteSqlString(p)}`).join(" OR ")})`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
156
src/armory/controllers/ArenaController.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import * as express from "express";
|
||||
import { RowDataPacket } from "mysql2";
|
||||
|
||||
import { Armory } from "../Armory";
|
||||
import { IRealmConfig } from "../Config";
|
||||
import { IEmblem, Utils } from "../Utils";
|
||||
import { DataTablesSsp } from "../DataTablesSsp";
|
||||
|
||||
interface ITeamMemberData {
|
||||
name: string;
|
||||
weekGames: number;
|
||||
weekWins: number;
|
||||
seasonGames: number;
|
||||
seasonWins: number;
|
||||
personalRating: number;
|
||||
race: number;
|
||||
class: number;
|
||||
gender: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
interface ITeamData {
|
||||
name: string;
|
||||
captainGuid: number;
|
||||
type: number;
|
||||
rating: number;
|
||||
seasonGames: number;
|
||||
seasonWins: number;
|
||||
weekGames: number;
|
||||
weekWins: number;
|
||||
emblem: IEmblem;
|
||||
members: ITeamMemberData[];
|
||||
}
|
||||
|
||||
export class ArenaController {
|
||||
private armory: Armory;
|
||||
|
||||
public constructor(armory: Armory) {
|
||||
this.armory = armory;
|
||||
}
|
||||
|
||||
public async index(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
|
||||
res.render("ladder-arena.hbs", {
|
||||
title: `Arena Ladder`,
|
||||
realms: this.armory.config.realms.map((r) => r.name),
|
||||
});
|
||||
}
|
||||
|
||||
public async team(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
|
||||
const realmName = req.params.realm;
|
||||
const teamName = req.params.name;
|
||||
|
||||
const realm = this.armory.getRealm(realmName);
|
||||
if (realm === undefined) {
|
||||
// Could not find realm
|
||||
return next(404);
|
||||
}
|
||||
|
||||
const teamData = await this.getTeamData(realm, teamName);
|
||||
if (teamData === null) {
|
||||
// Could not find team
|
||||
return next(404);
|
||||
}
|
||||
|
||||
res.render("arena-team.hbs", {
|
||||
title: `Armory - ${teamData.name}`,
|
||||
realm: realm.name,
|
||||
...teamData,
|
||||
});
|
||||
}
|
||||
|
||||
public async ladder(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
|
||||
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);
|
||||
if (realm === undefined) {
|
||||
return next(400);
|
||||
}
|
||||
|
||||
const teamSize = parseInt(req.query.teamsize as string);
|
||||
if (!(teamSize === 2 || teamSize === 3 || teamSize === 5)) {
|
||||
return next(400);
|
||||
}
|
||||
|
||||
const db = this.armory.getCharactersDb(realm.name);
|
||||
const charSet = await this.armory.getDatabaseCharset(realm.name);
|
||||
|
||||
const ssp = new DataTablesSsp(req.query, db, "arena_team", "arenaTeamId", [
|
||||
{ name: "name", collation: `${charSet}_general_ci` },
|
||||
{ name: "rating" },
|
||||
{ name: "seasonWins" },
|
||||
{ name: "seasonGames" },
|
||||
]);
|
||||
|
||||
const result = await ssp.where("`type` = " + teamSize).run(this.armory.config.dbQueryTimeout);
|
||||
|
||||
res.json({
|
||||
...result,
|
||||
realm: realm.name,
|
||||
teamSize,
|
||||
});
|
||||
}
|
||||
|
||||
private async getTeamData(realm: IRealmConfig, teamName: string): Promise<ITeamData> {
|
||||
const db = this.armory.getCharactersDb(realm.name);
|
||||
const [rows] = await db.query({
|
||||
sql: `
|
||||
SELECT arenaTeamId, name, captainGuid, type, rating, seasonGames, seasonWins, weekGames, weekWins, emblemStyle, emblemColor, borderStyle, borderColor, backgroundColor AS background
|
||||
FROM arena_team WHERE name = ?
|
||||
`,
|
||||
values: [teamName],
|
||||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
if ((rows as RowDataPacket[]).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const team = rows[0];
|
||||
|
||||
const [memberRows] = await db.query({
|
||||
sql: `
|
||||
SELECT name, weekGames, weekWins, seasonGames, seasonWins, personalRating, race, class, gender, online
|
||||
FROM arena_team_member
|
||||
LEFT JOIN characters ON arena_team_member.guid = characters.guid
|
||||
WHERE arenaTeamId = ?
|
||||
`,
|
||||
values: [team.arenaTeamId],
|
||||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
const members: ITeamMemberData[] = (memberRows as RowDataPacket[]).map((row) => {
|
||||
return {
|
||||
name: row.name,
|
||||
weekGames: row.weekGames,
|
||||
weekWins: row.weekWins,
|
||||
seasonGames: row.seasonGames,
|
||||
seasonWins: row.seasonWins,
|
||||
personalRating: row.personalRating,
|
||||
race: Utils.raceNames[row.race],
|
||||
class: Utils.classNames[row.class],
|
||||
gender: row.gender === 0 ? "male" : "female",
|
||||
online: row.online === 1,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: team.name,
|
||||
captainGuid: team.captainGuid,
|
||||
type: team.type,
|
||||
rating: team.rating,
|
||||
seasonGames: team.seasonGames,
|
||||
seasonWins: team.seasonWins,
|
||||
weekGames: team.weekGames,
|
||||
weekWins: team.weekWins,
|
||||
emblem: Utils.makeEmblemObject(team, false),
|
||||
members,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import * as express from "express";
|
||||
import { RowDataPacket } from "mysql2/promise";
|
||||
|
||||
import { Utils } from "../Utils";
|
||||
import { Armory } from "../Armory";
|
||||
import { IRealmConfig } from "../Config";
|
||||
import { IAchievement } from "../data/DbcReader";
|
||||
import { IEmblem, Utils } from "../Utils";
|
||||
import { IAchievement as IAchievementDbc } from "../data/DbcReader";
|
||||
|
||||
interface ICharacterData {
|
||||
guid: number;
|
||||
|
|
@ -27,8 +27,14 @@ interface IEquipmentData {
|
|||
slot: number;
|
||||
itemEntry: number;
|
||||
flags: number;
|
||||
enchantments: string;
|
||||
enchantments: string | number[];
|
||||
randomPropertyId: number;
|
||||
classId: number;
|
||||
subclassId: number;
|
||||
quality: number;
|
||||
transmog?: number;
|
||||
icon?: number;
|
||||
gems?: number[];
|
||||
}
|
||||
|
||||
interface ICustomizationOption {
|
||||
|
|
@ -42,6 +48,30 @@ interface IMount {
|
|||
icon: string;
|
||||
}
|
||||
|
||||
interface IAchievement {
|
||||
id: number;
|
||||
category: number;
|
||||
title: string;
|
||||
description: string;
|
||||
points: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface IArenaTeam {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
rating: number;
|
||||
seasonWins: number;
|
||||
seasonGames: number;
|
||||
background: number;
|
||||
emblemStyle: number;
|
||||
emblemColor: number;
|
||||
borderStyle: number;
|
||||
borderColor: number;
|
||||
emblem?: IEmblem;
|
||||
}
|
||||
|
||||
const ItemClassGem = 3;
|
||||
const SpellMechanicMounted = 21;
|
||||
const RaceDisplayName = {
|
||||
|
|
@ -78,7 +108,7 @@ export class CharacterController {
|
|||
private itemSocketBonuses: { [key: number]: number };
|
||||
private mountSpells: number[];
|
||||
private mountBySpellId: { [key: number]: IMount };
|
||||
private achievementById: { [key: number]: IAchievement };
|
||||
private achievementById: { [key: number]: IAchievementDbc };
|
||||
|
||||
public constructor(armory: Armory) {
|
||||
this.armory = armory;
|
||||
|
|
@ -88,7 +118,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;
|
||||
}
|
||||
|
|
@ -107,7 +137,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;
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +147,7 @@ export class CharacterController {
|
|||
}
|
||||
|
||||
this.itemSocketBonuses = {};
|
||||
let [rows, fields] = await this.armory.worldDb.query({
|
||||
const [rows] = await this.armory.worldDb.query({
|
||||
sql: "SELECT entry, socketBonus FROM item_template WHERE socketBonus <> 0",
|
||||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
|
|
@ -125,16 +155,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,
|
||||
|
|
@ -169,23 +200,27 @@ export class CharacterController {
|
|||
|
||||
const equipmentData = await this.getEquipmentData(realmName, charData.guid);
|
||||
const customization = this.getCustomizationOptions(charData);
|
||||
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);
|
||||
const equipment = equipmentData.map((row) => {
|
||||
row.icon = this.itemIcons[row.itemEntry];
|
||||
row.gems = this.getGemsFromEnchantments(row.enchantments as string);
|
||||
row.enchantments = this.filterEnchantments(row.itemEntry, row.enchantments as string);
|
||||
return row;
|
||||
});
|
||||
const mounts = await this.getMounts(realmName, charData.guid);
|
||||
const transmogs: number[][] = this.armory.config.transmogModule ? [] : undefined;
|
||||
const characterModelItems = await this.getModelViewerItems(equipmentData, charData.class, transmogs);
|
||||
|
||||
res.render("character.hbs", {
|
||||
title: `Armory - ${charData.name}`,
|
||||
...this.makeSharedDataObject(realm, charData),
|
||||
contentPath: this.armory.config.useZamCdn ? "https://wow.zamimg.com/modelviewer/live/" : this.armory.config.websiteRoot + "/data/",
|
||||
data: {
|
||||
race: charData.race,
|
||||
gender: charData.gender,
|
||||
class: charData.class,
|
||||
flags: charData.playerFlags,
|
||||
characterModelItems: await this.getModelViewerItems(equipmentData, charData.class),
|
||||
characterModelItems,
|
||||
characterModelTransmogs: transmogs,
|
||||
customizationOptions: customization,
|
||||
equipment,
|
||||
mounts,
|
||||
|
|
@ -262,7 +297,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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +319,7 @@ export class CharacterController {
|
|||
|
||||
res.render("character-pvp.hbs", {
|
||||
title: `Armory - ${charData.name} - PvP`,
|
||||
realm: realm.name,
|
||||
...this.makeSharedDataObject(realm, charData),
|
||||
faction: Utils.getFactionFromRaceId(charData.race),
|
||||
kills: await this.getPvpKills(realm.name, charData.guid),
|
||||
|
|
@ -306,16 +342,32 @@ export class CharacterController {
|
|||
|
||||
private async getCharacterData(realm: IRealmConfig, character: string | number): Promise<ICharacterData> {
|
||||
const where = typeof character === "string" ? "LOWER(`characters`.`name`) = LOWER(?)" : "`characters`.`guid` = ?";
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm.name).query({
|
||||
|
||||
// Bot accounts are hidden the same way game masters are: left join the
|
||||
// rows we do not want, then require the join to have missed. Keeping the
|
||||
// pattern inline rather than bound avoids disturbing the positional
|
||||
// values below.
|
||||
const botFilter = this.armory.config.hideBotAccounts
|
||||
? Utils.botAccountFilter("`bot_account`.`username`", this.armory.config.botAccountPatterns)
|
||||
: "";
|
||||
const botJoin =
|
||||
botFilter === ""
|
||||
? ""
|
||||
: `LEFT JOIN \`${realm.authDatabase}\`.\`account\` AS \`bot_account\` ON \`bot_account\`.\`id\` = \`characters\`.\`account\` AND ${botFilter}`;
|
||||
const botWhere = botFilter === "" ? "" : "AND `bot_account`.`id` IS NULL";
|
||||
|
||||
const [rows] = await this.armory.getCharactersDb(realm.name).query({
|
||||
sql: `
|
||||
SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`race\`, \`class\`, \`gender\`, \`level\`, \`skin\`, \`face\`, \`hairStyle\`, \`hairColor\`, \`facialStyle\`, \`playerFlags\`, \`online\`, \`guild\`.\`name\` AS \`guild\`
|
||||
SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`characters\`.\`race\`, \`characters\`.\`class\`, \`characters\`.\`gender\`, \`characters\`.\`level\`, \`characters\`.\`skin\`, \`characters\`.\`face\`, \`characters\`.\`hairStyle\`, \`characters\`.\`hairColor\`, \`characters\`.\`facialStyle\`, \`characters\`.\`playerFlags\`, \`characters\`.\`online\`, \`guild\`.\`name\` AS \`guild\`
|
||||
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
|
||||
${botJoin}
|
||||
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)
|
||||
${botWhere}
|
||||
`,
|
||||
values: [character, this.armory.config.hideGameMasters ? 1 : 0],
|
||||
timeout: this.armory.config.dbQueryTimeout,
|
||||
|
|
@ -328,21 +380,50 @@ export class CharacterController {
|
|||
}
|
||||
|
||||
private async getEquipmentData(realm: string, charGuid: number): Promise<IEquipmentData[]> {
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
const transmogSelect = this.armory.config.transmogModule ? ", custom_transmogrification.FakeEntry AS transmog" : "";
|
||||
const transmogJoin = this.armory.config.transmogModule
|
||||
? "LEFT JOIN custom_transmogrification ON custom_transmogrification.GUID = item_instance.guid"
|
||||
: "";
|
||||
let [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT character_inventory.slot, item_instance.itemEntry, item_instance.flags, item_instance.enchantments, item_instance.randomPropertyId
|
||||
SELECT
|
||||
character_inventory.slot, item_instance.itemEntry, item_instance.flags, item_instance.enchantments, item_instance.randomPropertyId
|
||||
${transmogSelect}
|
||||
FROM character_inventory
|
||||
JOIN item_instance ON item_instance.guid = character_inventory.item
|
||||
${transmogJoin}
|
||||
WHERE character_inventory.guid = ? AND character_inventory.bag = 0 AND character_inventory.slot IN (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)
|
||||
`,
|
||||
values: [charGuid],
|
||||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
return rows as RowDataPacket[] as IEquipmentData[];
|
||||
|
||||
const data = rows as RowDataPacket[] as IEquipmentData[];
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
[rows] = 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;
|
||||
}
|
||||
|
||||
private async getMounts(realm: string, charGuid: number): Promise<IMount[]> {
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT spell
|
||||
FROM character_spell
|
||||
|
|
@ -352,34 +433,52 @@ 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<number[][]> {
|
||||
private async getModelViewerItems(equipmentData: IEquipmentData[], charClass: number, transmogOut?: number[][]): Promise<number[][]> {
|
||||
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);
|
||||
let appearance = await this.armory.dbc.itemAppearance().find((row) => row.id === modifiedAppearance.itemAppearanceId);
|
||||
if (appearance === undefined) {
|
||||
continue;
|
||||
}
|
||||
appearance = { ...appearance };
|
||||
|
||||
items.push([this.itemInventoryTypes[equipment.itemEntry], appearance.itemDisplayInfoId]);
|
||||
let invType = this.itemInventoryTypes[equipment.itemEntry];
|
||||
items.push([invType, appearance.itemDisplayInfoId]);
|
||||
|
||||
if (transmogOut !== undefined) {
|
||||
if (equipment.transmog !== undefined) {
|
||||
if (equipment.transmog === 1) {
|
||||
appearance.itemDisplayInfoId = -1; // Hidden gear piece from transmog
|
||||
} else {
|
||||
const modifiedAppearance = await this.armory.dbc.itemModifiedAppearance().find((row) => row.itemId === equipment.transmog);
|
||||
if (modifiedAppearance !== undefined) {
|
||||
const tmogAppearance = await this.armory.dbc.itemAppearance().find((row) => row.id === modifiedAppearance.itemAppearanceId);
|
||||
if (tmogAppearance !== undefined) {
|
||||
appearance = tmogAppearance;
|
||||
invType = this.itemInventoryTypes[equipment.transmog];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
transmogOut.push([invType, appearance.itemDisplayInfoId]);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
|
|
@ -389,52 +488,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,
|
||||
|
|
@ -447,19 +547,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
|
||||
|
|
@ -471,14 +606,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) {
|
||||
|
|
@ -508,7 +661,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]);
|
||||
|
|
@ -538,10 +694,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
|
||||
|
|
@ -557,8 +724,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");
|
||||
|
|
@ -578,13 +765,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
|
||||
|
|
@ -603,9 +848,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);
|
||||
|
|
@ -623,8 +877,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
|
||||
|
|
@ -677,7 +961,7 @@ export class CharacterController {
|
|||
}
|
||||
|
||||
private async getTalents(realm: string, character: number): Promise<number[][]> {
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT spell, specMask
|
||||
FROM character_talent
|
||||
|
|
@ -701,16 +985,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 {
|
||||
|
|
@ -724,15 +1010,11 @@ 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<any[][]> {
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
private async getGlyphs(realm: string, character: number): Promise<number[][]> {
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT guid, talentGroup, glyph1, glyph2, glyph3, glyph4, glyph5, glyph6
|
||||
FROM character_glyphs
|
||||
|
|
@ -742,11 +1024,11 @@ export class CharacterController {
|
|||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
|
||||
const glyphs = [[], []];
|
||||
const glyphs: number[][] = [[], []];
|
||||
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;
|
||||
}
|
||||
|
|
@ -757,11 +1039,15 @@ 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: IAchievement[]; earned: { [key: number]: number } }> {
|
||||
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,
|
||||
|
|
@ -774,7 +1060,7 @@ export class CharacterController {
|
|||
.toArray();
|
||||
const achievements = await Promise.all(promises);
|
||||
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT achievement, date
|
||||
FROM character_achievement
|
||||
|
|
@ -783,11 +1069,9 @@ export class CharacterController {
|
|||
values: [charData.guid],
|
||||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
const earned = {};
|
||||
const earned: { [key: number]: number } = {};
|
||||
for (const row of rows as RowDataPacket[]) {
|
||||
earned[row.achievement] = {
|
||||
date: row.date,
|
||||
};
|
||||
earned[row.achievement] = row.date;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -796,8 +1080,8 @@ export class CharacterController {
|
|||
};
|
||||
}
|
||||
|
||||
private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number, today: number, yesterday: number }> {
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number; today: number; yesterday: number }> {
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT totalKills, todayKills, yesterdayKills
|
||||
FROM characters
|
||||
|
|
@ -815,8 +1099,8 @@ export class CharacterController {
|
|||
};
|
||||
}
|
||||
|
||||
private async getArenaTeams(realm: string, charGuid: number): Promise<any[]> {
|
||||
const [rows, fields] = await this.armory.getCharactersDb(realm).query({
|
||||
private async getArenaTeams(realm: string, charGuid: number): Promise<IArenaTeam[]> {
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
SELECT
|
||||
arena_team.arenaTeamId AS id, arena_team.name, arena_team.type, arena_team.rating, arena_team.seasonWins, arena_team.seasonGames,
|
||||
|
|
@ -830,7 +1114,7 @@ export class CharacterController {
|
|||
timeout: this.armory.config.dbQueryTimeout,
|
||||
});
|
||||
|
||||
return (rows as RowDataPacket[]).map(row => {
|
||||
return (rows as IArenaTeam[]).map((row) => {
|
||||
row.emblem = Utils.makeEmblemObject(row, false);
|
||||
return row;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,16 +2,25 @@ import * as express from "express";
|
|||
import { encode } from "html-entities";
|
||||
import { RowDataPacket } from "mysql2/promise";
|
||||
|
||||
import { Utils } from "../Utils";
|
||||
import { Armory } from "../Armory";
|
||||
import { IRealmConfig } from "../Config";
|
||||
import { DataTablesSsp } from "../DataTablesSsp";
|
||||
import { Utils, IEmblem, EFaction } from "../Utils";
|
||||
|
||||
interface IGuildRank {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface IGuildData {
|
||||
id: number;
|
||||
name: string;
|
||||
leader: string;
|
||||
faction: EFaction;
|
||||
emblem: IEmblem;
|
||||
membersCount: number;
|
||||
}
|
||||
|
||||
export class GuildController {
|
||||
private armory: Armory;
|
||||
|
||||
|
|
@ -64,37 +73,59 @@ 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",
|
||||
where: `AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`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);
|
||||
const botFilter = this.armory.config.hideBotAccounts
|
||||
? Utils.botAccountFilter(`\`${realm.authDatabase}\`.\`account\`.\`username\``, this.armory.config.botAccountPatterns)
|
||||
: "";
|
||||
if (botFilter !== "") {
|
||||
ssp.joins.push({
|
||||
table1: "characters",
|
||||
column1: "account",
|
||||
table2: "account",
|
||||
column2: "id",
|
||||
database2: realm.authDatabase,
|
||||
kind: "LEFT",
|
||||
where: `AND ${botFilter}`,
|
||||
});
|
||||
ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`);
|
||||
}
|
||||
|
||||
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 = {};
|
||||
const ranksObj: { [key: number]: string } = {};
|
||||
for (const rank of ranks) {
|
||||
(result as any).ranks[rank.id] = encode(rank.name);
|
||||
ranksObj[rank.id] = encode(rank.name);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
res.json({
|
||||
...result,
|
||||
ranks: ranksObj,
|
||||
});
|
||||
}
|
||||
|
||||
private async getGuildData(realm: IRealmConfig, name: string): Promise<any> {
|
||||
private async getGuildData(realm: IRealmConfig, name: string): Promise<IGuildData> {
|
||||
const db = this.armory.getCharactersDb(realm.name);
|
||||
let [rows, fields] = await db.query({
|
||||
let [rows] = await db.query({
|
||||
sql: `
|
||||
SELECT guildid, name, leaderguid, EmblemStyle AS emblemStyle, EmblemColor AS emblemColor, BorderStyle AS borderStyle, BorderColor AS borderColor, BackgroundColor AS background
|
||||
FROM guild WHERE name = ?
|
||||
|
|
@ -107,7 +138,7 @@ export class GuildController {
|
|||
}
|
||||
const guild = rows[0];
|
||||
|
||||
[rows, fields] = await db.query({
|
||||
[rows] = await db.query({
|
||||
sql: `
|
||||
SELECT name, race FROM characters
|
||||
WHERE guid = ?
|
||||
|
|
@ -117,7 +148,7 @@ export class GuildController {
|
|||
});
|
||||
const leader = rows[0];
|
||||
|
||||
[rows, fields] = await db.query({
|
||||
[rows] = await db.query({
|
||||
sql: `
|
||||
SELECT COUNT(guid) AS \`count\` FROM guild_member
|
||||
WHERE guildid = ?
|
||||
|
|
@ -139,7 +170,7 @@ export class GuildController {
|
|||
|
||||
private async getGuildId(realm: IRealmConfig, name: string): Promise<number> {
|
||||
const db = this.armory.getCharactersDb(realm.name);
|
||||
const [rows, fields] = await db.query({
|
||||
const [rows] = await db.query({
|
||||
sql: `
|
||||
SELECT guildid
|
||||
FROM guild WHERE name = ?
|
||||
|
|
@ -156,7 +187,7 @@ export class GuildController {
|
|||
|
||||
private async guildExists(realm: IRealmConfig, id: number): Promise<boolean> {
|
||||
const db = this.armory.getCharactersDb(realm.name);
|
||||
const [rows, fields] = await db.query({
|
||||
const [rows] = await db.query({
|
||||
sql: `
|
||||
SELECT guildid
|
||||
FROM guild WHERE guildid
|
||||
|
|
@ -170,7 +201,7 @@ export class GuildController {
|
|||
|
||||
private async getGuildRanks(realm: IRealmConfig, id: number): Promise<IGuildRank[]> {
|
||||
const db = this.armory.getCharactersDb(realm.name);
|
||||
const [rows, fields] = await db.query({
|
||||
const [rows] = await db.query({
|
||||
sql: `
|
||||
SELECT rid AS id, rname AS name
|
||||
FROM guild_rank WHERE guildid = ?
|
||||
|
|
|
|||
|
|
@ -14,15 +14,13 @@ export class IndexController {
|
|||
public async index(req: express.Request, res: express.Response): Promise<void> {
|
||||
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<void> {
|
||||
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,15 +43,39 @@ 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",
|
||||
where: `AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`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);
|
||||
(result as any).realm = realm.name;
|
||||
const botFilter = this.armory.config.hideBotAccounts
|
||||
? Utils.botAccountFilter(`\`${realm.authDatabase}\`.\`account\`.\`username\``, this.armory.config.botAccountPatterns)
|
||||
: "";
|
||||
if (botFilter !== "") {
|
||||
ssp.joins.push({
|
||||
table1: "characters",
|
||||
column1: "account",
|
||||
table2: "account",
|
||||
column2: "id",
|
||||
database2: realm.authDatabase,
|
||||
kind: "LEFT",
|
||||
where: `AND ${botFilter}`,
|
||||
});
|
||||
ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
const result = await ssp.where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout);
|
||||
|
||||
res.json({
|
||||
...result,
|
||||
realm: realm.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const fsp = fs.promises;
|
|||
import * as path from "path";
|
||||
|
||||
export class CharacterCustomization {
|
||||
private data: { [key: number]: { [key: number]: any } };
|
||||
private data: { [key: number]: { [key: number]: unknown } };
|
||||
|
||||
public async loadData(): Promise<void> {
|
||||
this.data = {};
|
||||
|
|
@ -21,7 +21,7 @@ export class CharacterCustomization {
|
|||
}
|
||||
}
|
||||
|
||||
public getCharacterCustomizationData(race: number, gender: number): any {
|
||||
public getCharacterCustomizationData(race: number, gender: number): unknown {
|
||||
return this.data[race][gender];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface IAchievementCategory {
|
|||
export interface IItemDbc {
|
||||
id: number;
|
||||
classId: number;
|
||||
subclassId: number;
|
||||
displayInfoId: number;
|
||||
inventoryType: number;
|
||||
}
|
||||
|
|
@ -147,11 +148,13 @@ class AsyncGenWrapper<T> implements IAsyncGeneratorWithArrayMethods<T> {
|
|||
}
|
||||
|
||||
public static from<T>(array: T[]): AsyncGenWrapper<T> {
|
||||
return new AsyncGenWrapper<T>(async function* () {
|
||||
for (const x of array) {
|
||||
yield x;
|
||||
}
|
||||
}());
|
||||
return new AsyncGenWrapper<T>(
|
||||
(async function* () {
|
||||
for (const x of array) {
|
||||
yield x;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
public async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
||||
|
|
@ -216,11 +219,15 @@ class DbcReader<T> {
|
|||
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) => {
|
||||
if (value === "") {
|
||||
return value;
|
||||
}
|
||||
return isNaN(+value) ? value : parseInt(value, 10);
|
||||
});
|
||||
const row = {};
|
||||
headerCols.forEach((header, headerIdx) => {
|
||||
if (this.fields.length === 0 || this.fields.includes(header)) {
|
||||
|
|
@ -241,7 +248,8 @@ class DbcReader<T> {
|
|||
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
|
||||
const ch = str[c];
|
||||
const nch = str[c + 1]; // Current character, next character
|
||||
if (!(col in arr)) {
|
||||
arr[col] = ""; // Create a new column (start with empty string) if necessary
|
||||
}
|
||||
|
|
@ -262,14 +270,14 @@ class DbcReader<T> {
|
|||
}
|
||||
|
||||
// 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;
|
||||
|
|
@ -279,7 +287,7 @@ class DbcReader<T> {
|
|||
|
||||
// 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;
|
||||
|
|
@ -316,7 +324,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"],
|
||||
|
|
@ -326,7 +334,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"],
|
||||
};
|
||||
|
||||
|
|
@ -349,17 +369,26 @@ export class DbcManager {
|
|||
|
||||
public async loadAllFiles(): Promise<void> {
|
||||
this._achievement = await this.read<IAchievement>(DbcFiles.achievement, dbcFields.achievement).toArray();
|
||||
this._achievementCategory = await this.read<IAchievementCategory>(DbcFiles.achievementCategory, dbcFields.achievementCategory).toArray();
|
||||
this._achievementCategory = await this.read<IAchievementCategory>(
|
||||
DbcFiles.achievementCategory,
|
||||
dbcFields.achievementCategory,
|
||||
).toArray();
|
||||
this._glyphProperties = await this.read<IGlyphProperties>(DbcFiles.glyphProperties, dbcFields.glyphProperties).toArray();
|
||||
this._item = await this.read<IItemDbc>(DbcFiles.item, dbcFields.item).toArray();
|
||||
this._itemRetail = await this.read<IItemRetailDbc>(DbcFiles.itemRetail, dbcFields.itemRetail).toArray();
|
||||
this._itemAppearance = await this.read<IItemAppearanceDbc>(DbcFiles.itemAppearance, dbcFields.itemAppearance).toArray();
|
||||
this._itemModifiedAppearance = await this.read<IItemModifiedAppearanceDbc>(DbcFiles.itemModifiedAppearance, dbcFields.itemModifiedAppearance).toArray();
|
||||
this._itemModifiedAppearance = await this.read<IItemModifiedAppearanceDbc>(
|
||||
DbcFiles.itemModifiedAppearance,
|
||||
dbcFields.itemModifiedAppearance,
|
||||
).toArray();
|
||||
this._itemDisplayInfo = await this.read<IItemDisplayInfoDbc>(DbcFiles.itemDisplayInfo, dbcFields.itemDisplayInfo).toArray();
|
||||
this._mount = await this.read<IMountDbc>(DbcFiles.mount, dbcFields.mount).toArray();
|
||||
this._mountDisplay = await this.read<IMountXDisplayDbc>(DbcFiles.mountDisplay, dbcFields.mountDisplay).toArray();
|
||||
this._spell = await this.read<ISpellDbc>(DbcFiles.spell, dbcFields.spell).toArray();
|
||||
this._spellItemEnchantment = await this.read<ISpellItemEnchantmentDbc>(DbcFiles.spellItemEnchantment, dbcFields.spellItemEnchantment).toArray();
|
||||
this._spellItemEnchantment = await this.read<ISpellItemEnchantmentDbc>(
|
||||
DbcFiles.spellItemEnchantment,
|
||||
dbcFields.spellItemEnchantment,
|
||||
).toArray();
|
||||
this._spellIcon = await this.read<ISpellIcon>(DbcFiles.spellIcon, dbcFields.spellIcon).toArray();
|
||||
this._talent = await this.read<ITalent>(DbcFiles.talent, dbcFields.talent).toArray();
|
||||
this._talentTab = await this.read<ITalentTab>(DbcFiles.talentTab, dbcFields.talentTab).toArray();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import "dotenv/config";
|
||||
import "source-map-support/register";
|
||||
|
||||
import { Armory } from "./Armory";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
require("source-map-support").install();
|
||||
|
||||
const armory = new Armory();
|
||||
await armory.start();
|
||||
}
|
||||
|
|
|
|||
2
src/index.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
declare module Express {
|
||||
declare namespace Express {
|
||||
export interface Request {
|
||||
id: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,30 @@ const fsp = fs.promises;
|
|||
import * as path from "path";
|
||||
|
||||
import * as pako from "pako";
|
||||
import fetch from "node-fetch";
|
||||
import * as mkdirp from "mkdirp";
|
||||
import * as glob from "glob-promise";
|
||||
import { Response } from "node-fetch";
|
||||
import * as glob from "glob";
|
||||
import "source-map-support/register";
|
||||
import * as prettyMs from "pretty-ms";
|
||||
import * as cliProgress from "cli-progress";
|
||||
import fetch, { Response } from "node-fetch";
|
||||
import promisepool = require("@supercharge/promise-pool");
|
||||
|
||||
import { DbcManager, IItemAppearanceDbc, IItemModifiedAppearanceDbc, IMountDbc, IMountXDisplayDbc } from "../armory/data/DbcReader";
|
||||
|
||||
require("source-map-support").install();
|
||||
|
||||
const baseUrl = "https://wow.zamimg.com/modelviewer/live";
|
||||
|
||||
const globAsync = (pattern: string, options?: glob.IOptions) => {
|
||||
return new Promise<string[]>((res, rej) => {
|
||||
glob(pattern, options, (err, matches) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
} else {
|
||||
res(matches);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
class Stopwatch {
|
||||
private startTime: number;
|
||||
|
||||
|
|
@ -54,9 +64,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;
|
||||
}
|
||||
|
|
@ -89,54 +102,46 @@ const modelsDownloadQueue = new Set<number>();
|
|||
const texturesDownloadQueue = new Set<number>();
|
||||
const bonesDownloadQueue = new Set<number>();
|
||||
|
||||
async function download(dir: string, file: string): Promise<string | any> {
|
||||
async function download(dir: string, file: string): Promise<string | unknown> {
|
||||
const dataDir = path.join(process.cwd(), "data");
|
||||
const fullPath = `${dir}/${file}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/${fullPath}`);
|
||||
if (res.status !== 200) {
|
||||
throw new HttpRequestError(res);
|
||||
}
|
||||
const res = await fetch(`${baseUrl}/${fullPath}`);
|
||||
if (res.status !== 200) {
|
||||
throw new HttpRequestError(res);
|
||||
}
|
||||
|
||||
await mkdirp(path.join(dataDir, dir));
|
||||
await mkdirp(path.join(dataDir, dir));
|
||||
|
||||
if (res.headers.get("Content-Type") === "application/json") {
|
||||
const json = await res.json();
|
||||
fsp.writeFile(path.join(dataDir, fullPath), JSON.stringify(json));
|
||||
return json;
|
||||
} else {
|
||||
const fileStream = fs.createWriteStream(path.join(dataDir, fullPath));
|
||||
await new Promise((resolve, rej) => {
|
||||
res.body.pipe(fileStream);
|
||||
res.body.on("error", rej);
|
||||
fileStream.on("finish", resolve);
|
||||
});
|
||||
if (res.headers.get("Content-Type") === "application/json") {
|
||||
const json = await res.json();
|
||||
fsp.writeFile(path.join(dataDir, fullPath), JSON.stringify(json));
|
||||
return json;
|
||||
} else {
|
||||
const fileStream = fs.createWriteStream(path.join(dataDir, fullPath));
|
||||
await new Promise((resolve, rej) => {
|
||||
res.body.pipe(fileStream);
|
||||
res.body.on("error", rej);
|
||||
fileStream.on("finish", resolve);
|
||||
});
|
||||
|
||||
return fileStream.path.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
return fileStream.path.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function queueTexturesAndModels(item: any): void {
|
||||
function queueTexturesAndModels(item): 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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -146,7 +151,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]);
|
||||
}
|
||||
|
|
@ -154,7 +159,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]);
|
||||
}
|
||||
|
|
@ -163,35 +168,27 @@ function queueTexturesAndModels(item: any): void {
|
|||
}
|
||||
|
||||
async function downloadRaces(): Promise<void> {
|
||||
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`);
|
||||
modelsDownloadQueue.add(characterJson.Model);
|
||||
modelsDownloadQueue.add(characterJson["Model"]);
|
||||
|
||||
const customizationJson = await download("meta/charactercustomization2", `${characterJson.Race}_${characterJson.Gender}.json`);
|
||||
for (const option of customizationJson.Options) {
|
||||
const customizationJson = await download("meta/charactercustomization2", `${characterJson["Race"]}_${characterJson["Gender"]}.json`);
|
||||
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) {
|
||||
|
|
@ -201,11 +198,9 @@ async function downloadRaces(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -215,12 +210,14 @@ async function downloadRaces(): Promise<void> {
|
|||
}
|
||||
|
||||
async function downloadArmors(): Promise<void> {
|
||||
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<void> {
|
|||
}
|
||||
|
||||
async function downloadWeapons(): Promise<void> {
|
||||
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];
|
||||
|
|
@ -262,7 +261,7 @@ async function downloadWeapons(): Promise<void> {
|
|||
}
|
||||
const appearance = dbcItemAppearanceById[modifiedAppearance.itemAppearanceId];
|
||||
try {
|
||||
const itemJson = await download(`meta/item`, `${appearance.itemDisplayInfoId}.json`);
|
||||
const itemJson = await download("meta/item", `${appearance.itemDisplayInfoId}.json`);
|
||||
queueTexturesAndModels(itemJson);
|
||||
progress.increment();
|
||||
} catch (err) {
|
||||
|
|
@ -307,11 +306,13 @@ async function readDbcData(): Promise<void> {
|
|||
}
|
||||
|
||||
async function downloadMounts(): Promise<void> {
|
||||
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<void> {
|
|||
async function downloadTextures(): Promise<void> {
|
||||
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<void> {
|
|||
async function downloadModels(): Promise<void> {
|
||||
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<void> {
|
|||
async function downloadBones(): Promise<void> {
|
||||
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 {
|
||||
|
|
@ -381,11 +379,10 @@ async function downloadBones(): Promise<void> {
|
|||
}
|
||||
|
||||
async function parseModels(): Promise<void> {
|
||||
const files = await glob("data/mo3/*.mo3");
|
||||
const files = await globAsync("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);
|
||||
|
|
|
|||
115
static/arena-team.hbs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/arena-team.css">
|
||||
{{> datatables}}
|
||||
{{> emblems}}
|
||||
|
||||
<a href="{{websiteRoot}}/arena">Back to Arena Ladder</a>
|
||||
<br><br>
|
||||
|
||||
<div id="arena-header">
|
||||
<div class="emblem-container">
|
||||
<div class="arena-emblem">
|
||||
<canvas width="74" height="128"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div class="team-name is-size-3">{{name}}</div>
|
||||
<div class="is-size-5">
|
||||
{{type}}v{{type}} Team, {{realm}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="is-size-5">Rating: {{rating}}</div>
|
||||
<table id="stats">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Games</th>
|
||||
<th>Wins</th>
|
||||
<th>Losses</th>
|
||||
<th>Win Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Week</td>
|
||||
<td>{{weekGames}}</td>
|
||||
<td>{{weekWins}}</td>
|
||||
<td>{{subtract weekGames weekWins}}</td>
|
||||
<td>
|
||||
{{#if (isnt weekGames 0)}}
|
||||
{{toPrecision (multiply (divide weekWins weekGames) 100) 3}} %
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Season</td>
|
||||
<td>{{seasonGames}}</td>
|
||||
<td>{{seasonWins}}</td>
|
||||
<td>{{subtract seasonGames seasonWins}}</td>
|
||||
<td>
|
||||
{{#if (isnt seasonGames 0)}}
|
||||
{{toPrecision (multiply (divide seasonWins seasonGames) 100) 3}} %
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
<div class="is-size-5">Members:</div>
|
||||
<table id="members" class="stripe hover row-border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Class</th>
|
||||
<th>Race</th>
|
||||
<th>Week Wins</th>
|
||||
<th>Week Losses</th>
|
||||
<th>Season Wins</th>
|
||||
<th>Season Losses</th>
|
||||
<th>Rating</th>
|
||||
<th>Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each members}}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{../websiteRoot}}/character/{{../realm}}/{{name}}/pvp">
|
||||
{{name}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<img src="{{../aowow}}/static/images/wow/icons/medium/class_{{class}}.jpg">
|
||||
</td>
|
||||
<td>
|
||||
<img src="{{../aowow}}/static/images/wow/icons/medium/race_{{race}}_{{gender}}.jpg">
|
||||
</td>
|
||||
<td>{{weekWins}}</td>
|
||||
<td>{{subtract weekGames weekWins}}</td>
|
||||
<td>{{seasonWins}}</td>
|
||||
<td>{{subtract seasonGames seasonWins}}</td>
|
||||
<td>{{personalRating}}</td>
|
||||
<td>{{#if online}}🟢{{else}}🔴{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script type="application/javascript">
|
||||
$(window).on("load", () => {
|
||||
createArenaEmblem({{this.type}}, {{{JSONstringify this.emblem}}}, $(".arena-emblem")[0]);
|
||||
|
||||
const dt = $("#members").DataTable({
|
||||
searching: false,
|
||||
paging: false,
|
||||
responsive: {
|
||||
details: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -69,193 +69,194 @@
|
|||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
let achievementsData = {};
|
||||
let $openCategory;
|
||||
const $achievementTemplate = $("#achievement-template").detach().removeAttr("id");
|
||||
const $progressTemplate = $("#progress-template").detach().removeAttr("id");
|
||||
$(window).on("load", () => {
|
||||
let achievementsData = {};
|
||||
let $openCategory;
|
||||
const $achievementTemplate = $("#achievement-template").detach().removeAttr("id");
|
||||
const $progressTemplate = $("#progress-template").detach().removeAttr("id");
|
||||
|
||||
function makeCategoryLink(cat) {
|
||||
const $a = $("<a>")
|
||||
.attr("href", "#")
|
||||
.text(cat.nameLang0)
|
||||
.on("click", () => {
|
||||
if ($openCategory !== undefined && $openCategory !== $a && !$openCategory.data("children")[0].contains($a[0])) {
|
||||
closeCategory();
|
||||
}
|
||||
function makeCategoryLink(cat) {
|
||||
const $a = $("<a>")
|
||||
.attr("href", "#")
|
||||
.text(cat.nameLang0)
|
||||
.on("click", () => {
|
||||
if ($openCategory !== undefined && $openCategory !== $a && !$openCategory.data("children")[0].contains($a[0])) {
|
||||
closeCategory();
|
||||
}
|
||||
|
||||
const $children = $a.data("children");
|
||||
if ($children !== undefined) {
|
||||
$openCategory = $a;
|
||||
if (!$children.is(":visible")) {
|
||||
// Open the clicked category
|
||||
$children.slideDown();
|
||||
const $children = $a.data("children");
|
||||
if ($children !== undefined) {
|
||||
$openCategory = $a;
|
||||
if (!$children.is(":visible")) {
|
||||
// Open the clicked category
|
||||
$children.slideDown();
|
||||
}
|
||||
}
|
||||
openCategory(cat);
|
||||
return false;
|
||||
});
|
||||
return $a;
|
||||
}
|
||||
|
||||
function makeAchievement(achievement) {
|
||||
const isEarned = achievement.id in achievementsData.earned;
|
||||
if (!isEarned && achievement.category === 81) {
|
||||
// Show unlocked feats of strength only
|
||||
return;
|
||||
}
|
||||
|
||||
const $achievement = $achievementTemplate
|
||||
.clone(false)
|
||||
.appendTo($("#achievements"));
|
||||
$achievement.find(".name").text(achievement.title);
|
||||
$achievement.find(".description").text(achievement.description);
|
||||
$achievement.find(".points .value").text(achievement.points);
|
||||
if (achievement.points === 0) {
|
||||
$achievement.find(".points").addClass("no-points");
|
||||
}
|
||||
$achievement.find(".iconlarge .icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/large/${achievement.icon}.jpg")`);
|
||||
$achievement.find("a").attr("href", `{{aowow}}?achievement=${achievement.id}`);
|
||||
|
||||
if (isEarned) {
|
||||
const date = new Date(achievementsData.earned[achievement.id] * 1000);
|
||||
$achievement.find(".earned-date").text(date.toLocaleDateString());
|
||||
$achievement.addClass("earned");
|
||||
}
|
||||
|
||||
return $achievement;
|
||||
}
|
||||
|
||||
function openCategory(cat) {
|
||||
const categoryAchievements = achievementsData.achievements.filter(ach => ach.category === cat.id);
|
||||
|
||||
$("#achievements").empty();
|
||||
$(".summary").hide();
|
||||
|
||||
for (const achievement of categoryAchievements) {
|
||||
makeAchievement(achievement);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCategory() {
|
||||
if ($openCategory === undefined || $openCategory.data("children") === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
$openCategory
|
||||
.data("children")
|
||||
.slideUp();
|
||||
}
|
||||
|
||||
function openSummary() {
|
||||
closeCategory();
|
||||
$("#achievements").empty();
|
||||
const achievements = Object.keys(achievementsData.earned)
|
||||
.filter(key => achievementsData.earned.hasOwnProperty(key))
|
||||
.map(key => parseInt(key, 10))
|
||||
.map(achievement => {
|
||||
return {
|
||||
achievement,
|
||||
date: achievementsData.earned[achievement],
|
||||
};
|
||||
});
|
||||
const mostRecent = achievements.sort((a, b) => b.date - a.date).slice(0, 4);
|
||||
for (const row of mostRecent) {
|
||||
const achievement = achievementsData.achievements.find(ach => ach.id === row.achievement);
|
||||
makeAchievement(achievement);
|
||||
}
|
||||
|
||||
$(".summary").show();
|
||||
}
|
||||
|
||||
function makeProgressBarRow(barsData) {
|
||||
const $row = $("<div>").addClass("columns");
|
||||
$("#progress-bars").append($row);
|
||||
|
||||
for (const bar of barsData) {
|
||||
let $bar = $progressTemplate.clone(false);
|
||||
$bar.find(".title").text(bar.name);
|
||||
$bar.find(".value").text(`${bar.earned} / ${bar.total}`);
|
||||
$bar.find(".progress").attr("value", bar.earned).attr("max", bar.total);
|
||||
$row.append($bar);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
function makeProgressBars() {
|
||||
let totalAchievements = 0;
|
||||
let totalAchievementsEarned = 0;
|
||||
|
||||
const findProgress = (catId) => {
|
||||
const cat = achievementsData.categories.find(cat => cat.id === catId);
|
||||
const subCategories = achievementsData.categories.filter(cat => cat.parent === catId);
|
||||
const categoryAchievements = achievementsData.achievements.filter(ach => ach.category === cat.id || subCategories.some(cat => cat.id === ach.category));
|
||||
let earned = 0;
|
||||
for (const ach of categoryAchievements) {
|
||||
if (ach.id in achievementsData.earned) {
|
||||
++earned;
|
||||
}
|
||||
}
|
||||
openCategory(cat);
|
||||
return false;
|
||||
});
|
||||
return $a;
|
||||
}
|
||||
totalAchievementsEarned += earned;
|
||||
totalAchievements += categoryAchievements.length;
|
||||
|
||||
function makeAchievement(achievement) {
|
||||
const isEarned = achievement.id in achievementsData.earned;
|
||||
if (!isEarned && achievement.category === 81) {
|
||||
// Show unlocked feats of strength only
|
||||
return;
|
||||
}
|
||||
|
||||
const $achievement = $achievementTemplate
|
||||
.clone(false)
|
||||
.appendTo($("#achievements"));
|
||||
$achievement.find(".name").text(achievement.title);
|
||||
$achievement.find(".description").text(achievement.description);
|
||||
$achievement.find(".points .value").text(achievement.points);
|
||||
if (achievement.points === 0) {
|
||||
$achievement.find(".points").addClass("no-points");
|
||||
}
|
||||
$achievement.find(".iconlarge .icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/large/${achievement.icon}.jpg")`);
|
||||
$achievement.find("a").attr("href", `{{aowow}}?achievement=${achievement.id}`);
|
||||
|
||||
if (isEarned) {
|
||||
const earned = achievementsData.earned[achievement.id];
|
||||
const date = new Date(earned.date * 1000);
|
||||
$achievement.find(".earned-date").text(date.toLocaleDateString());
|
||||
$achievement.addClass("earned");
|
||||
}
|
||||
|
||||
return $achievement;
|
||||
}
|
||||
|
||||
function openCategory(cat) {
|
||||
const categoryAchievements = achievementsData.achievements.filter(ach => ach.category === cat.id);
|
||||
|
||||
$("#achievements").empty();
|
||||
$(".summary").hide();
|
||||
|
||||
for (const achievement of categoryAchievements) {
|
||||
makeAchievement(achievement);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCategory() {
|
||||
if ($openCategory === undefined || $openCategory.data("children") === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
$openCategory
|
||||
.data("children")
|
||||
.slideUp();
|
||||
}
|
||||
|
||||
function openSummary() {
|
||||
closeCategory();
|
||||
$("#achievements").empty();
|
||||
const achievements = Object.keys(achievementsData.earned)
|
||||
.filter(key => achievementsData.earned.hasOwnProperty(key))
|
||||
.map(key => parseInt(key, 10))
|
||||
.map(achievement => {
|
||||
return {
|
||||
achievement,
|
||||
...achievementsData.earned[achievement],
|
||||
name: cat.nameLang0,
|
||||
earned,
|
||||
total: categoryAchievements.length,
|
||||
};
|
||||
});
|
||||
const mostRecent = achievements.sort((a, b) => b.date - a.date).slice(0, 4);
|
||||
for (const row of mostRecent) {
|
||||
const achievement = achievementsData.achievements.find(ach => ach.id === row.achievement);
|
||||
makeAchievement(achievement);
|
||||
}
|
||||
|
||||
$(".summary").show();
|
||||
}
|
||||
|
||||
function makeProgressBarRow(barsData) {
|
||||
const $row = $("<div>").addClass("columns");
|
||||
$("#progress-bars").append($row);
|
||||
|
||||
for (const bar of barsData) {
|
||||
let $bar = $progressTemplate.clone(false);
|
||||
$bar.find(".title").text(bar.name);
|
||||
$bar.find(".value").text(`${bar.earned} / ${bar.total}`);
|
||||
$bar.find(".progress").attr("value", bar.earned).attr("max", bar.total);
|
||||
$row.append($bar);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
function makeProgressBars() {
|
||||
let totalAchievements = 0;
|
||||
let totalAchievementsEarned = 0;
|
||||
|
||||
const findProgress = (catId) => {
|
||||
const cat = achievementsData.categories.find(cat => cat.id === catId);
|
||||
const subCategories = achievementsData.categories.filter(cat => cat.parent === catId);
|
||||
const categoryAchievements = achievementsData.achievements.filter(ach => ach.category === cat.id || subCategories.some(cat => cat.id === ach.category));
|
||||
let earned = 0;
|
||||
for (const ach of categoryAchievements) {
|
||||
if (ach.id in achievementsData.earned) {
|
||||
++earned;
|
||||
}
|
||||
}
|
||||
totalAchievementsEarned += earned;
|
||||
totalAchievements += categoryAchievements.length;
|
||||
|
||||
return {
|
||||
name: cat.nameLang0,
|
||||
earned,
|
||||
total: categoryAchievements.length,
|
||||
};
|
||||
};
|
||||
makeProgressBarRow([{ ...findProgress(92) }, { ...findProgress(96) }]);
|
||||
makeProgressBarRow([{ ...findProgress(97) }, { ...findProgress(95) }]);
|
||||
makeProgressBarRow([{ ...findProgress(168) }, { ...findProgress(169) }]);
|
||||
makeProgressBarRow([{ ...findProgress(201) }, { ...findProgress(155) }]);
|
||||
const $totalRow = makeProgressBarRow([{ name: "Achievements Earned", earned: totalAchievementsEarned, total: totalAchievements }]);
|
||||
$totalRow.prependTo($("#progress-bars")); // Move the total bar to the top
|
||||
}
|
||||
makeProgressBarRow([{ ...findProgress(92) }, { ...findProgress(96) }]);
|
||||
makeProgressBarRow([{ ...findProgress(97) }, { ...findProgress(95) }]);
|
||||
makeProgressBarRow([{ ...findProgress(168) }, { ...findProgress(169) }]);
|
||||
makeProgressBarRow([{ ...findProgress(201) }, { ...findProgress(155) }]);
|
||||
const $totalRow = makeProgressBarRow([{ name: "Achievements Earned", earned: totalAchievementsEarned, total: totalAchievements }]);
|
||||
$totalRow.prependTo($("#progress-bars")); // Move the total bar to the top
|
||||
}
|
||||
|
||||
$.getJSON(`{{websiteRoot}}/character/{{realm}}/{{guid}}/achievements/data`, (data) => {
|
||||
achievementsData = data;
|
||||
$.getJSON(`{{websiteRoot}}/character/{{{realm}}}/{{guid}}/achievements/data`, (data) => {
|
||||
achievementsData = data;
|
||||
|
||||
const $summaryLink = $("<a>")
|
||||
.attr("href", "#")
|
||||
.text("Summary")
|
||||
.on("click", openSummary)
|
||||
.appendTo($("#categories"));
|
||||
const $summaryLink = $("<a>")
|
||||
.attr("href", "#")
|
||||
.text("Summary")
|
||||
.on("click", openSummary)
|
||||
.appendTo($("#categories"));
|
||||
|
||||
for (const cat of achievementsData.categories.filter(c => c.parent < 0)) {
|
||||
if (cat.id === 1) {
|
||||
// Skip Statistics category
|
||||
continue;
|
||||
}
|
||||
const children = achievementsData.categories.filter(c => c.parent === cat.id);
|
||||
|
||||
const $cat = makeCategoryLink(cat);
|
||||
$("#categories").append($cat);
|
||||
|
||||
if (children.length > 0) {
|
||||
const $children = $("<div>")
|
||||
.hide()
|
||||
.addClass("subcategories");
|
||||
$cat.data("children", $children);
|
||||
for (const child of children) {
|
||||
const $child = makeCategoryLink(child);
|
||||
$child.appendTo($children);
|
||||
for (const cat of achievementsData.categories.filter(c => c.parent < 0)) {
|
||||
if (cat.id === 1) {
|
||||
// Skip Statistics category
|
||||
continue;
|
||||
}
|
||||
const children = achievementsData.categories.filter(c => c.parent === cat.id);
|
||||
|
||||
$("#categories").append($children);
|
||||
const $cat = makeCategoryLink(cat);
|
||||
$("#categories").append($cat);
|
||||
|
||||
if (children.length > 0) {
|
||||
const $children = $("<div>")
|
||||
.hide()
|
||||
.addClass("subcategories");
|
||||
$cat.data("children", $children);
|
||||
for (const child of children) {
|
||||
const $child = makeCategoryLink(child);
|
||||
$child.appendTo($children);
|
||||
}
|
||||
|
||||
$("#categories").append($children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalPoints = 0;
|
||||
for (const ach of achievementsData.achievements) {
|
||||
if (ach.id in achievementsData.earned) {
|
||||
totalPoints += ach.points;
|
||||
let totalPoints = 0;
|
||||
for (const ach of achievementsData.achievements) {
|
||||
if (ach.id in achievementsData.earned) {
|
||||
totalPoints += ach.points;
|
||||
}
|
||||
}
|
||||
}
|
||||
$("#total-points").text(totalPoints);
|
||||
$("#total-points").text(totalPoints);
|
||||
|
||||
makeProgressBars();
|
||||
openSummary();
|
||||
makeProgressBars();
|
||||
openSummary();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@
|
|||
<div class="column is-full-touch is-one-quarter-desktop">
|
||||
<div class="box arena-team" data-team-id="{{this.id}}">
|
||||
<div class="info has-text-centered">
|
||||
<div class="is-size-4">{{this.name}}</div>
|
||||
<div class="is-size-4">
|
||||
<a href="{{../websiteRoot}}/arena/team/{{../realm}}/{{this.name}}">{{this.name}}</a>
|
||||
</div>
|
||||
<div class="is-size-5">{{this.type}}v{{this.type}} Team</div>
|
||||
<div>Rating: {{this.rating}}</div>
|
||||
<div>{{this.seasonWins}} W / {{subtract this.seasonGames this.seasonWins}} L</div>
|
||||
|
|
@ -36,7 +38,9 @@
|
|||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
{{#each arenaTeams}}
|
||||
createArenaEmblem({{this.type}}, {{{JSONstringify this.emblem}}}, $(".arena-team[data-team-id={{this.id}}] .arena-emblem")[0]);
|
||||
{{/each}}
|
||||
$(window).on("load", () => {
|
||||
{{#each arenaTeams}}
|
||||
createArenaEmblem({{this.type}}, {{{JSONstringify this.emblem}}}, $(".arena-team[data-team-id={{this.id}}] .arena-emblem")[0]);
|
||||
{{/each}}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
<script type="application/javascript">
|
||||
const aowow_tooltips = { "renamelinks": true, };
|
||||
</script>
|
||||
<script type="application/javascript" src="{{aowow}}/static/widgets/power.js"></script>
|
||||
|
||||
{{> character-header }}
|
||||
|
||||
|
|
@ -47,189 +46,193 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: none;">
|
||||
<!-- Hackfix for aowow's tooltip opening when the page loads -->
|
||||
<a href="{{aowow}}/?spell=10"></a>
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
const talentsData = {{{JSONstringify data}}};
|
||||
$(window).on("load", () => {
|
||||
const talentsData = {{{JSONstringify data}}};
|
||||
|
||||
for (let spec = 0; spec < 2; ++spec) {
|
||||
const $spec = $(`#talents-spec-${spec}`);
|
||||
function createTree(data, learnedTalents, container) {
|
||||
const $columns = $(container).find(".col");
|
||||
let points = 0;
|
||||
|
||||
let nbLearned = 0;
|
||||
for (const tree of talentsData.trees) {
|
||||
const learned = {};
|
||||
for (const talent of talentsData.talents[spec]) {
|
||||
for (const s of tree.spells) {
|
||||
const ranks = [s.spellRank0, s.spellRank1, s.spellRank2, s.spellRank3, s.spellRank4];
|
||||
const idx = ranks.indexOf(talent);
|
||||
if (idx === -1) {
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const nbRows = Math.max(...data.spells.filter(s => s.columnIndex === i).map(s => s.tierId));
|
||||
const $column = $columns.eq(i);
|
||||
if (nbRows < 0) {
|
||||
// Remove empty column
|
||||
$column.remove();
|
||||
continue;
|
||||
}
|
||||
for (let j = 0; j <= nbRows; ++j) {
|
||||
$("<div>")
|
||||
.addClass("iconmedium")
|
||||
.appendTo($column);
|
||||
}
|
||||
}
|
||||
|
||||
for (const spell of data.spells) {
|
||||
const $column = $columns.eq(spell.columnIndex);
|
||||
const $placeholder = $column.find(".iconmedium").eq(spell.tierId);
|
||||
const $template = $("#talent-template");
|
||||
const $talent = $template.clone(false);
|
||||
$talent.removeAttr("id");
|
||||
$talent.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/medium/${spell.icon}.jpg")`);
|
||||
const rank = spell.id in learnedTalents ? (learnedTalents[spell.id].rank - 1) : 0;
|
||||
$talent.find("a").attr("href", `{{aowow}}/?spell=${spell["spellRank" + rank]}`);
|
||||
$talent.data("id", spell.id);
|
||||
|
||||
if (spell.id in learnedTalents) {
|
||||
points += learnedTalents[spell.id].rank;
|
||||
const ranks = [spell.spellRank0, spell.spellRank1, spell.spellRank2, spell.spellRank3, spell.spellRank4].filter(r => r !== 0).length;
|
||||
const maxRank = learnedTalents[spell.id].rank === ranks;
|
||||
$talent
|
||||
.removeClass("empty")
|
||||
.addClass(maxRank ? "full" : "notfull");
|
||||
$talent.attr("rank", `${learnedTalents[spell.id].rank}/${ranks}`);
|
||||
$talent.data("rank", learnedTalents[spell.id].rank);
|
||||
}
|
||||
|
||||
$placeholder.replaceWith($talent);
|
||||
}
|
||||
|
||||
// Add arrows
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const $column = $columns.eq(i);
|
||||
for (const icon of $column.find(".iconmedium")) {
|
||||
const $icon = $(icon);
|
||||
const spell = data.spells.find(s => s.id === $icon.data("id"));
|
||||
if (spell === undefined || spell.prereqTalent0 === 0) {
|
||||
continue;
|
||||
}
|
||||
learned[s.id] = {
|
||||
spell: s,
|
||||
rank: idx + 1,
|
||||
};
|
||||
++nbLearned;
|
||||
break;
|
||||
const req = data.spells.find(s => s.id === spell.prereqTalent0);
|
||||
if (req === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (req.columnIndex === spell.columnIndex) {
|
||||
// Down
|
||||
const height = (44 + 14) * (spell.tierId - req.tierId - 1) + 25;
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(15, height, 16, undefined, -(height - 8), "down" + arrowFile, "center bottom", $icon);
|
||||
} else if (req.tierId === spell.tierId && req.columnIndex < spell.columnIndex) {
|
||||
// Right
|
||||
const width = (44 + 14) * (spell.columnIndex - req.columnIndex - 1) + 23;
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, -(width - 8), undefined, 16, "right" + arrowFile, "right center", $icon);
|
||||
} else if (req.tierId === spell.tierId && req.columnIndex > spell.columnIndex) {
|
||||
// Left
|
||||
const width = (44 + 14) * (req.columnIndex - spell.columnIndex - 1) + 23;
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, undefined, -(width - 8), 16, "left" + arrowFile, "left center", $icon)
|
||||
} else if (req.tierId < spell.tierId && req.columnIndex < spell.columnIndex) {
|
||||
// Right Down
|
||||
const width = 44 * (spell.columnIndex - req.columnIndex) + 2;
|
||||
const left = -((44 + 14) * (spell.columnIndex - req.columnIndex - 1) + 15);
|
||||
const top = -((44 + 14) * (spell.tierId - req.tierId) - 14);
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, left, undefined, top, "rightdown" + arrowFile, "right center", $icon);
|
||||
|
||||
const height = (44 + 14) * (spell.tierId - req.tierId - 1) + 40;
|
||||
createArrow(15, height, 16, undefined, -(height - 8), "down" + arrowFile, "center bottom", $icon);
|
||||
} else {
|
||||
// Left Down
|
||||
const width = 44 * (req.columnIndex - spell.columnIndex) - 1;
|
||||
const right = -((44 + 14) * (req.columnIndex - spell.columnIndex - 1) + 15);
|
||||
const top = -((44 + 14) * (spell.tierId - req.tierId) - 14);
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, undefined, right, top, "leftdown" + arrowFile, "left center", $icon);
|
||||
|
||||
const height = (44 + 14) * (spell.tierId - req.tierId - 1) + 40;
|
||||
createArrow(15, height, 16, undefined, -(height - 8), "down" + arrowFile, "center bottom", $icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $tree = $("#talent-tree-template")
|
||||
.clone(false)
|
||||
.removeAttr("id")
|
||||
.appendTo($spec);
|
||||
$tree.find(".header .image").attr("src", `{{aowow}}/static/images/wow/icons/medium/${tree.icon}.jpg`);
|
||||
$tree.find(".header .name").text(tree.name);
|
||||
|
||||
createTree(tree, learned, $tree[0]);
|
||||
$(container).find(".header .points").text(points);
|
||||
}
|
||||
|
||||
if (nbLearned === 0 && spec !== 0) {
|
||||
$spec.addClass("hidden");
|
||||
$("#spec-links").hide();
|
||||
continue;
|
||||
}
|
||||
|
||||
const $glyphs = $spec.find(".glyphs");
|
||||
for (const spell of talentsData.glyphs[spec]) {
|
||||
const $a = $("<a>");
|
||||
$a.attr("href", `{{aowow}}?spell=${spell}`);
|
||||
$glyphs.append($a);
|
||||
$glyphs.append("<br>");
|
||||
}
|
||||
$glyphs.parent().appendTo($spec);
|
||||
}
|
||||
|
||||
function createTree(data, learnedTalents, container) {
|
||||
const $columns = $(container).find(".col");
|
||||
let points = 0;
|
||||
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const nbRows = Math.max(...data.spells.filter(s => s.columnIndex === i).map(s => s.tierId));
|
||||
const $column = $columns.eq(i);
|
||||
if (nbRows < 0) {
|
||||
// Remove empty column
|
||||
$column.remove();
|
||||
continue;
|
||||
function createArrow(width, height, left, right, top, file, bgPos, container) {
|
||||
const $arrow = $("<div>");
|
||||
$arrow.css("position", "absolute");
|
||||
if (left !== undefined) {
|
||||
$arrow.css("left", `${left}px`);
|
||||
}
|
||||
for (let j = 0; j <= nbRows; ++j) {
|
||||
$("<div>")
|
||||
.addClass("iconmedium")
|
||||
.appendTo($column);
|
||||
if (right !== undefined) {
|
||||
$arrow.css("right", `${right}px`);
|
||||
}
|
||||
$arrow.css("top", `${top}px`);
|
||||
$arrow.css("width", `${width}px`);
|
||||
$arrow.css("height", `${height}px`);
|
||||
$arrow.css("background-image", `url("{{aowow}}/static/images/TalentCalc/arrows/${file}.png")`);
|
||||
$arrow.css("background-position", bgPos);
|
||||
$(container).append($arrow);
|
||||
}
|
||||
|
||||
for (const spell of data.spells) {
|
||||
const $column = $columns.eq(spell.columnIndex);
|
||||
const $placeholder = $column.find(".iconmedium").eq(spell.tierId);
|
||||
const $template = $("#talent-template");
|
||||
const $talent = $template.clone(false);
|
||||
$talent.removeAttr("id");
|
||||
$talent.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/medium/${spell.icon}.jpg")`);
|
||||
const rank = spell.id in learnedTalents ? (learnedTalents[spell.id].rank - 1) : 0;
|
||||
$talent.find("a").attr("href", `{{aowow}}/?spell=${spell["spellRank" + rank]}`);
|
||||
$talent.data("id", spell.id);
|
||||
function main() {
|
||||
for (let spec = 0; spec < 2; ++spec) {
|
||||
const $spec = $(`#talents-spec-${spec}`);
|
||||
|
||||
if (spell.id in learnedTalents) {
|
||||
points += learnedTalents[spell.id].rank;
|
||||
const ranks = [spell.spellRank0, spell.spellRank1, spell.spellRank2, spell.spellRank3, spell.spellRank4].filter(r => r !== 0).length;
|
||||
const maxRank = learnedTalents[spell.id].rank === ranks;
|
||||
$talent
|
||||
.removeClass("empty")
|
||||
.addClass(maxRank ? "full" : "notfull");
|
||||
$talent.attr("rank", `${learnedTalents[spell.id].rank}/${ranks}`);
|
||||
$talent.data("rank", learnedTalents[spell.id].rank);
|
||||
}
|
||||
let nbLearned = 0;
|
||||
for (const tree of talentsData.trees) {
|
||||
const learned = {};
|
||||
for (const talent of talentsData.talents[spec]) {
|
||||
for (const s of tree.spells) {
|
||||
const ranks = [s.spellRank0, s.spellRank1, s.spellRank2, s.spellRank3, s.spellRank4];
|
||||
const idx = ranks.indexOf(talent);
|
||||
if (idx === -1) {
|
||||
continue;
|
||||
}
|
||||
learned[s.id] = {
|
||||
spell: s,
|
||||
rank: idx + 1,
|
||||
};
|
||||
++nbLearned;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$placeholder.replaceWith($talent);
|
||||
}
|
||||
const $tree = $("#talent-tree-template")
|
||||
.clone(false)
|
||||
.removeAttr("id")
|
||||
.appendTo($spec);
|
||||
$tree.find(".header .image").attr("src", `{{aowow}}/static/images/wow/icons/medium/${tree.icon}.jpg`);
|
||||
$tree.find(".header .name").text(tree.name);
|
||||
|
||||
// Add arrows
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const $column = $columns.eq(i);
|
||||
for (const icon of $column.find(".iconmedium")) {
|
||||
const $icon = $(icon);
|
||||
const spell = data.spells.find(s => s.id === $icon.data("id"));
|
||||
if (spell === undefined || spell.prereqTalent0 === 0) {
|
||||
createTree(tree, learned, $tree[0]);
|
||||
}
|
||||
|
||||
if (nbLearned === 0 && spec !== 0) {
|
||||
$spec.addClass("hidden");
|
||||
$("#spec-links").hide();
|
||||
continue;
|
||||
}
|
||||
const req = data.spells.find(s => s.id === spell.prereqTalent0);
|
||||
if (req === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (req.columnIndex === spell.columnIndex) {
|
||||
// Down
|
||||
const height = (44 + 14) * (spell.tierId - req.tierId - 1) + 25;
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(15, height, 16, undefined, -(height - 8), "down" + arrowFile, "center bottom", $icon);
|
||||
} else if (req.tierId === spell.tierId && req.columnIndex < spell.columnIndex) {
|
||||
// Right
|
||||
const width = (44 + 14) * (spell.columnIndex - req.columnIndex - 1) + 23;
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, -(width - 8), undefined, 16, "right" + arrowFile, "right center", $icon);
|
||||
} else if (req.tierId === spell.tierId && req.columnIndex > spell.columnIndex) {
|
||||
// Left
|
||||
const width = (44 + 14) * (req.columnIndex - spell.columnIndex - 1) + 23;
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, undefined, -(width - 8), 16, "left" + arrowFile, "left center", $icon)
|
||||
} else if (req.tierId < spell.tierId && req.columnIndex < spell.columnIndex) {
|
||||
// Right Down
|
||||
const width = 44 * (spell.columnIndex - req.columnIndex) + 2;
|
||||
const left = -((44 + 14) * (spell.columnIndex - req.columnIndex - 1) + 15);
|
||||
const top = -((44 + 14) * (spell.tierId - req.tierId) - 14);
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, left, undefined, top, "rightdown" + arrowFile, "right center", $icon);
|
||||
|
||||
const height = (44 + 14) * (spell.tierId - req.tierId - 1) + 40;
|
||||
createArrow(15, height, 16, undefined, -(height - 8), "down" + arrowFile, "center bottom", $icon);
|
||||
} else {
|
||||
// Left Down
|
||||
const width = 44 * (req.columnIndex - spell.columnIndex) - 1;
|
||||
const right = -((44 + 14) * (req.columnIndex - spell.columnIndex - 1) + 15);
|
||||
const top = -((44 + 14) * (spell.tierId - req.tierId) - 14);
|
||||
const arrowFile = $icon.data("rank") > 0 ? "2" : "";
|
||||
createArrow(width, 15, undefined, right, top, "leftdown" + arrowFile, "left center", $icon);
|
||||
|
||||
const height = (44 + 14) * (spell.tierId - req.tierId - 1) + 40;
|
||||
createArrow(15, height, 16, undefined, -(height - 8), "down" + arrowFile, "center bottom", $icon);
|
||||
const $glyphs = $spec.find(".glyphs");
|
||||
for (const spell of talentsData.glyphs[spec]) {
|
||||
const $a = $("<a>");
|
||||
$a.attr("href", `{{aowow}}?spell=${spell}`);
|
||||
$glyphs.append($a);
|
||||
$glyphs.append("<br>");
|
||||
}
|
||||
$glyphs.parent().appendTo($spec);
|
||||
}
|
||||
}
|
||||
|
||||
$(container).find(".header .points").text(points);
|
||||
}
|
||||
$("#link-spec-0").on("click", () => {
|
||||
$("#talents-spec-0").removeClass("hidden");
|
||||
$("#talents-spec-1").addClass("hidden");
|
||||
});
|
||||
|
||||
function createArrow(width, height, left, right, top, file, bgPos, container) {
|
||||
const $arrow = $("<div>");
|
||||
$arrow.css("position", "absolute");
|
||||
if (left !== undefined) {
|
||||
$arrow.css("left", `${left}px`);
|
||||
}
|
||||
if (right !== undefined) {
|
||||
$arrow.css("right", `${right}px`);
|
||||
}
|
||||
$arrow.css("top", `${top}px`);
|
||||
$arrow.css("width", `${width}px`);
|
||||
$arrow.css("height", `${height}px`);
|
||||
$arrow.css("background-image", `url("{{aowow}}/static/images/TalentCalc/arrows/${file}.png")`);
|
||||
$arrow.css("background-position", bgPos);
|
||||
$(container).append($arrow);
|
||||
}
|
||||
$("#link-spec-1").on("click", () => {
|
||||
$("#talents-spec-1").removeClass("hidden");
|
||||
$("#talents-spec-0").addClass("hidden");
|
||||
});
|
||||
|
||||
$("#link-spec-0").on("click", () => {
|
||||
$("#talents-spec-0").removeClass("hidden");
|
||||
$("#talents-spec-1").addClass("hidden");
|
||||
if (location.hash === "#1") {
|
||||
$("#link-spec-1").click();
|
||||
}
|
||||
|
||||
$("<script>")
|
||||
.attr("src", "{{aowow}}/static/widgets/power.js")
|
||||
.appendTo("body");
|
||||
}
|
||||
main();
|
||||
});
|
||||
|
||||
$("#link-spec-1").on("click", () => {
|
||||
$("#talents-spec-1").removeClass("hidden");
|
||||
$("#talents-spec-0").addClass("hidden");
|
||||
});
|
||||
|
||||
if (location.hash === "#1") {
|
||||
$("#link-spec-1").click();
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/character.css">
|
||||
{{> icons }}
|
||||
<script type="application/javascript">
|
||||
const aowow_tooltips = {
|
||||
renamelinks: true,
|
||||
colorlinks: true,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script type="application/javascript" src="{{websiteRoot}}/js/viewer.min.js"></script>
|
||||
<script type="application/javascript" src="{{aowow}}/static/widgets/power.js"></script>
|
||||
|
||||
{{> character-header }}
|
||||
|
||||
|
|
@ -16,8 +21,16 @@
|
|||
<span data-icon-size="large" class="icon-size is-hidden-mobile"></span>
|
||||
<span data-icon-size="medium" class="icon-size is-hidden-tablet"></span>
|
||||
|
||||
<br>
|
||||
<div id="char-options" class="columns">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<label class="checkbox">
|
||||
<input id="cb-enable-3d-viewer" type="checkbox">
|
||||
Enable 3D viewer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="3d-viewer-options" class="columns">
|
||||
<div class="column is-one-quarter">
|
||||
<label class="checkbox">
|
||||
<input id="cb-hide-helmet" type="checkbox">
|
||||
|
|
@ -35,12 +48,26 @@
|
|||
<input id="cb-hide-tabard" type="checkbox">
|
||||
Hide tabard
|
||||
</label>
|
||||
<br>
|
||||
|
||||
<label id="lbl-hide-transmogs" class="checkbox">
|
||||
<input id="cb-hide-transmogs" type="checkbox">
|
||||
Hide transmogrifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="list-transmogs-container" class="column is-one-quarter">
|
||||
<button id="btn-transmogs" class="button is-primary">Transmogrifications</button>
|
||||
|
||||
<div id="list-transmogs" class="box" style="display: none;">
|
||||
<ul></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mounts-container" class="column">
|
||||
<button id="btn-mounts" class="button is-primary">Mounts</button>
|
||||
|
||||
<div id="mount-template" class="iconmedium">
|
||||
<div id="mount-template" class="iconmedium no-link-text">
|
||||
<div class="icon"></div>
|
||||
<div class="border"></div>
|
||||
<a target="_blank"></a>
|
||||
|
|
@ -49,234 +76,385 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="item-slot-template" class="item-slot">
|
||||
<div id="item-slot-template" class="item-slot no-link-text">
|
||||
<div class="inventory-slot"></div>
|
||||
<div class="icon"></div>
|
||||
<div class="border"></div>
|
||||
<a target="_blank"></a>
|
||||
</div>
|
||||
|
||||
<div style="display: none;">
|
||||
<!-- Hackfix for aowow's tooltip opening when the page loads -->
|
||||
<a href="{{aowow}}/?spell=10"></a>
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
const $model = $("#model");
|
||||
const $itemSlotTemplate = $("#item-slot-template").detach().removeAttr("id");
|
||||
const $mountTemplate = $("#mount-template").detach().removeAttr("id");
|
||||
$(window).on("load", () => {
|
||||
const $model = $("#model");
|
||||
const $itemSlotTemplate = $("#item-slot-template").detach().removeAttr("id");
|
||||
const $mountTemplate = $("#mount-template").detach().removeAttr("id");
|
||||
|
||||
window.WH = {
|
||||
//debug: console.log,
|
||||
debug: () => { },
|
||||
};
|
||||
window.WH = {
|
||||
//debug: console.log,
|
||||
debug: () => { },
|
||||
};
|
||||
|
||||
const charData = {{{JSONstringify data}}};
|
||||
const races = {
|
||||
1: "human",
|
||||
2: "orc",
|
||||
3: "dwarf",
|
||||
4: "nightelf",
|
||||
5: "scourge",
|
||||
6: "tauren",
|
||||
7: "gnome",
|
||||
8: "troll",
|
||||
10: "bloodelf",
|
||||
11: "draenei",
|
||||
};
|
||||
const genders = ["male", "female"];
|
||||
const charData = {{{ JSONstringify data }}};
|
||||
const races = {
|
||||
1: "human",
|
||||
2: "orc",
|
||||
3: "dwarf",
|
||||
4: "nightelf",
|
||||
5: "scourge",
|
||||
6: "tauren",
|
||||
7: "gnome",
|
||||
8: "troll",
|
||||
10: "bloodelf",
|
||||
11: "draenei",
|
||||
};
|
||||
const genders = ["male", "female"];
|
||||
|
||||
const hideHelm = (charData.flags & 0x0400) === 0x0400;
|
||||
$("#cb-hide-helmet").prop("checked", hideHelm);
|
||||
const hideCloak = (charData.flags & 0x0800) === 0x0800;
|
||||
$("#cb-hide-cloak").prop("checked", hideCloak);
|
||||
|
||||
$("#cb-hide-helmet").change(() => {
|
||||
const hide = $("#cb-hide-helmet").prop("checked");
|
||||
setSlotVisible(1, !hide);
|
||||
});
|
||||
$("#cb-hide-cloak").change(() => {
|
||||
const hide = $("#cb-hide-cloak").prop("checked");
|
||||
setSlotVisible(16, !hide);
|
||||
});
|
||||
$("#cb-hide-tabard").change(() => {
|
||||
const hide = $("#cb-hide-tabard").prop("checked");
|
||||
setSlotVisible(19, !hide);
|
||||
});
|
||||
|
||||
let viewer;
|
||||
function createViewer() {
|
||||
viewer?.destroy();
|
||||
viewer = new ZamModelViewer(characterModel);
|
||||
}
|
||||
|
||||
function setSlotVisible(slot, visible) {
|
||||
if (visible) {
|
||||
const item = charData.characterModelItems.find(([sl, appearance]) => sl === slot);
|
||||
if (item !== undefined) {
|
||||
setItems([item]);
|
||||
}
|
||||
if (Cookies.get("disable-3d-viewer") === "1") {
|
||||
$("#3d-viewer-options").hide();
|
||||
} else {
|
||||
clearSlots([slot]);
|
||||
$("#cb-enable-3d-viewer").prop("checked", true);
|
||||
}
|
||||
}
|
||||
$("#cb-enable-3d-viewer").change(() => {
|
||||
const enabled = $("#cb-enable-3d-viewer").prop("checked");
|
||||
|
||||
function clearSlots(slots) {
|
||||
viewer.method("clearSlots", slots.join(","));
|
||||
characterModel.items = characterModel.items.filter(item => !slots.includes(item[0]));
|
||||
}
|
||||
|
||||
function setItems(items) {
|
||||
const data = items.map(([slot, display]) => {
|
||||
return {
|
||||
slot,
|
||||
display,
|
||||
};
|
||||
if (enabled) {
|
||||
Cookies.remove("disable-3d-viewer");
|
||||
createViewer();
|
||||
$("#3d-viewer-options").slideDown("fast");
|
||||
} else {
|
||||
Cookies.set("disable-3d-viewer", "1");
|
||||
viewer?.destroy();
|
||||
$("#3d-viewer-options").slideUp("fast");
|
||||
}
|
||||
});
|
||||
viewer.method("setItems", [data]);
|
||||
|
||||
characterModel.items = characterModel.items.filter(item => !items.some(item2 => item[0] === item2[0]));
|
||||
characterModel.items.push(...items);
|
||||
}
|
||||
const hideHelm = (charData.flags & 0x0400) === 0x0400;
|
||||
$("#cb-hide-helmet").prop("checked", hideHelm);
|
||||
const hideCloak = (charData.flags & 0x0800) === 0x0800;
|
||||
$("#cb-hide-cloak").prop("checked", hideCloak);
|
||||
|
||||
function createItemSlot(item, invSlot, icon, rel, container) {
|
||||
const $item = $itemSlotTemplate.clone();
|
||||
$item
|
||||
.data("icon", icon)
|
||||
.find(".inventory-slot")
|
||||
.css("background-image", `url("{{websiteRoot}}/img/inventory-slot/${invSlot}.png")`);
|
||||
if (item !== undefined) {
|
||||
$item.data("item", item);
|
||||
$item.find("a").attr("href", `{{aowow}}/?item=${item}`);
|
||||
$item.find("a").attr("rel", rel);
|
||||
}
|
||||
$item.appendTo($(container));
|
||||
}
|
||||
const invSlotContainers = [
|
||||
{ element: "#equipment-col-left", slots: [0, 1, 2, 14, 4, 3, 18, 8] },
|
||||
{ element: "#equipment-col-right", slots: [9, 5, 6, 7, 10, 11, 12, 13] },
|
||||
{ element: "#equipment-bottom", slots: [15, 16, 17] }
|
||||
];
|
||||
for (const side of invSlotContainers) {
|
||||
for (const slot of side.slots) {
|
||||
const item = charData.equipment.find(item => item.slot === slot);
|
||||
const rel = [];
|
||||
if (item !== undefined) {
|
||||
rel.push("pcs=" + charData.equipment.map(item => item.itemEntry).join(":"));
|
||||
if (item.gems.length !== 0) {
|
||||
rel.push("gems=" + item.gems.join(":"));
|
||||
}
|
||||
if (item.enchantments.length !== 0) {
|
||||
rel.push("ench=" + item.enchantments.join(":"));
|
||||
}
|
||||
if (item.randomPropertyId !== 0) {
|
||||
rel.push("rand=" + item.randomPropertyId);
|
||||
$("#cb-hide-helmet").change(() => {
|
||||
const hide = $("#cb-hide-helmet").prop("checked");
|
||||
setSlotVisible(1, !hide);
|
||||
});
|
||||
$("#cb-hide-cloak").change(() => {
|
||||
const hide = $("#cb-hide-cloak").prop("checked");
|
||||
setSlotVisible(16, !hide);
|
||||
});
|
||||
$("#cb-hide-tabard").change(() => {
|
||||
const hide = $("#cb-hide-tabard").prop("checked");
|
||||
setSlotVisible(19, !hide);
|
||||
});
|
||||
if (charData.characterModelTransmogs !== undefined && charData.equipment.some((item) => item.transmog !== null)) {
|
||||
$("#lbl-hide-transmogs").show();
|
||||
$("#list-transmogs-container").show();
|
||||
|
||||
for (const item of charData.equipment) {
|
||||
if (item.transmog !== undefined && item.transmog !== 1) {
|
||||
const $li = $("<li>").appendTo("#list-transmogs ul");
|
||||
$("<a>")
|
||||
.attr("href", `{{aowow}}/?item=${item.transmog}`)
|
||||
.appendTo($li);
|
||||
}
|
||||
}
|
||||
createItemSlot(item?.itemEntry, slot, item?.icon?.toLowerCase(), rel.join("&"), side.element);
|
||||
}
|
||||
}
|
||||
const nbItemRows = Math.max($("#equipment-col-left").children().length, $("#equipment-col-right").children().length);
|
||||
$("#cb-hide-transmogs").change(() => {
|
||||
const hide = $("#cb-hide-transmogs").prop("checked");
|
||||
const items = hide ? charData.characterModelItems : charData.characterModelTransmogs;
|
||||
for (const slot of (items.map((item) => item[0]))) {
|
||||
clearSlots([slot]);
|
||||
}
|
||||
|
||||
function onResize(init = false) {
|
||||
const iconSize = $(".icon-size:visible").data("icon-size");
|
||||
const iconSizePx = iconSize === "medium" ? 44 : 68;
|
||||
const hideHelm = $("#cb-hide-helmet").prop("checked");
|
||||
const hideCloak = $("#cb-hide-cloak").prop("checked");
|
||||
const hideTabard = $("#cb-hide-tabard").prop("checked");
|
||||
setItems(items.filter(item => !((item[0] === 1 && hideHelm) || (item[0] === 16 && hideCloak) || (item[0] === 19 && hideTabard)) && item[1] !== -1));
|
||||
});
|
||||
|
||||
$("#model-container").css("height", (iconSizePx * nbItemRows) + "px");
|
||||
let viewer;
|
||||
function createViewer() {
|
||||
if (Cookies.get("disable-3d-viewer") === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
$(".item-slot")
|
||||
.removeClass("iconmedium iconlarge")
|
||||
.addClass(`icon${iconSize}`)
|
||||
.each((idx, el) => {
|
||||
const $item = $(el);
|
||||
const item = $item.data("item");
|
||||
const icon = $item.data("icon");
|
||||
if (item !== undefined) {
|
||||
$item.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/${iconSize}/${icon}.jpg")`);
|
||||
viewer?.destroy();
|
||||
viewer = new ZamModelViewer(characterModel);
|
||||
const isLoadedInterval = setInterval(() => {
|
||||
if (viewer.method("isLoaded")) {
|
||||
clearInterval(isLoadedInterval);
|
||||
setBackground("background.png");
|
||||
}
|
||||
});
|
||||
|
||||
if (!init) {
|
||||
characterModel.aspect = $model.outerWidth() / $model.outerHeight();
|
||||
createViewer();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
onResize(true);
|
||||
|
||||
function setMount(mountId) {
|
||||
if (characterModel.mount.id !== mountId) {
|
||||
function setSlotVisible(slot, visible) {
|
||||
if (visible) {
|
||||
const transmogged = !$("#cb-hide-transmogs").prop("checked");
|
||||
const items = transmogged ? charData.characterModelTransmogs : charData.characterModelItems;
|
||||
const item = items.find(([sl, appearance]) => sl === slot);
|
||||
if (item !== undefined) {
|
||||
if (item[1] === -1) {
|
||||
clearSlots([slot]);
|
||||
} else {
|
||||
setItems([item]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearSlots([slot]);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSlots(slots) {
|
||||
viewer.method("clearSlots", slots.join(","));
|
||||
characterModel.items = characterModel.items.filter(item => !slots.includes(item[0]));
|
||||
}
|
||||
|
||||
function setItems(items) {
|
||||
const data = items.map(([slot, display]) => {
|
||||
return {
|
||||
slot,
|
||||
display,
|
||||
};
|
||||
});
|
||||
viewer.method("setItems", [data]);
|
||||
|
||||
characterModel.items = characterModel.items.filter(item => !items.some(item2 => item[0] === item2[0]));
|
||||
characterModel.items.push(...items);
|
||||
}
|
||||
|
||||
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) {
|
||||
$item.data("item", item);
|
||||
$item.find("a").attr("href", `{{aowow}}/?item=${item}`);
|
||||
$item.find("a").attr("rel", rel);
|
||||
}
|
||||
$item.appendTo($(container));
|
||||
}
|
||||
const invSlotContainers = [
|
||||
{ element: "#equipment-col-left", slots: [0, 1, 2, 14, 4, 3, 18, 8] },
|
||||
{ element: "#equipment-col-right", slots: [9, 5, 6, 7, 10, 11, 12, 13] },
|
||||
{ element: "#equipment-bottom", slots: [15, 16, 17] }
|
||||
];
|
||||
for (const side of invSlotContainers) {
|
||||
for (const slot of side.slots) {
|
||||
const item = charData.equipment.find(item => item.slot === slot);
|
||||
const rel = [];
|
||||
if (item !== undefined) {
|
||||
rel.push("pcs=" + charData.equipment.map(item => item.itemEntry).join(":"));
|
||||
if (item.gems.length !== 0) {
|
||||
rel.push("gems=" + item.gems.join(":"));
|
||||
}
|
||||
if (item.enchantments.length !== 0) {
|
||||
rel.push("ench=" + item.enchantments.join(":"));
|
||||
}
|
||||
if (item.randomPropertyId !== 0) {
|
||||
rel.push("rand=" + item.randomPropertyId);
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
function onResize(init = false) {
|
||||
const iconSize = $(".icon-size:visible").data("icon-size");
|
||||
const iconSizePx = iconSize === "medium" ? 44 : 68;
|
||||
|
||||
$("#model-container").css("height", (iconSizePx * nbItemRows) + "px");
|
||||
|
||||
$(".item-slot")
|
||||
.removeClass("iconmedium iconlarge")
|
||||
.addClass(`icon${iconSize}`)
|
||||
.each((idx, el) => {
|
||||
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) {
|
||||
characterModel.aspect = $model.outerWidth() / $model.outerHeight();
|
||||
createViewer();
|
||||
}
|
||||
}
|
||||
onResize(true);
|
||||
|
||||
function setMount(mountId) {
|
||||
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 = mainHand !== undefined ? sheathTypes[mainHand.classId][mainHand.subclassId] : undefined;
|
||||
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) {
|
||||
$("#mounts-container").hide();
|
||||
} else {
|
||||
const $dismount = $mountTemplate.clone();
|
||||
$dismount.find(".icon").css("background-image", `url("{{websiteRoot}}/img/UI-GearManager-LeaveItem.png")`);
|
||||
$dismount.find("a")
|
||||
.on("click", () => {
|
||||
setMount(0);
|
||||
return false;
|
||||
});
|
||||
$("#mounts").append($dismount);
|
||||
function setBackground(file) {
|
||||
if (!viewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const mount of charData.mounts) {
|
||||
const $mount = $mountTemplate.clone();
|
||||
$mount.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/medium/${mount.icon}.jpg")`);
|
||||
$mount.find("a")
|
||||
.attr("href", `{{aowow}}/?spell=${mount.spell}`)
|
||||
const originalContentPath = viewer.options.contentPath;
|
||||
viewer.options.contentPath = "{{websiteRoot}}/data/";
|
||||
viewer.options.background = file;
|
||||
viewer.renderer.loadBackground();
|
||||
viewer.options.contentPath = originalContentPath;
|
||||
viewer.options.background = undefined;
|
||||
}
|
||||
|
||||
if (charData.mounts.length === 0) {
|
||||
$("#mounts-container").hide();
|
||||
} else {
|
||||
const $dismount = $mountTemplate.clone();
|
||||
$dismount.find(".icon").css("background-image", `url("{{websiteRoot}}/img/UI-GearManager-LeaveItem.png")`);
|
||||
$dismount.find("a")
|
||||
.on("click", () => {
|
||||
setMount(mount.creatureDisplayId);
|
||||
setMount(0);
|
||||
return false;
|
||||
});
|
||||
$("#mounts").append($mount);
|
||||
$("#mounts").append($dismount);
|
||||
|
||||
for (const mount of charData.mounts) {
|
||||
const $mount = $mountTemplate.clone();
|
||||
$mount.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/medium/${mount.icon}.jpg")`);
|
||||
$mount.find("a")
|
||||
.attr("href", `{{aowow}}/?spell=${mount.spell}`)
|
||||
.on("click", () => {
|
||||
setMount(mount.creatureDisplayId);
|
||||
return false;
|
||||
});
|
||||
$("#mounts").append($mount);
|
||||
}
|
||||
}
|
||||
}
|
||||
$("#btn-mounts").on("click", () => {
|
||||
$("#mounts").slideToggle("fast");
|
||||
});
|
||||
$("#btn-mounts").on("click", () => {
|
||||
$("#mounts").slideToggle("fast");
|
||||
});
|
||||
$("#btn-transmogs").on("click", () => {
|
||||
$("#list-transmogs").slideToggle("fast");
|
||||
});
|
||||
|
||||
const characterModel = {
|
||||
type: ZamModelViewer.WOW,
|
||||
contentPath: "{{websiteRoot}}/data/",
|
||||
background: "background.png",
|
||||
container: $model,
|
||||
hd: true,
|
||||
aspect: $model.outerWidth() / $model.outerHeight(),
|
||||
charCustomization: {
|
||||
race: charData.race,
|
||||
gender: charData.gender,
|
||||
options: charData.customizationOptions,
|
||||
sheathMain: -1,
|
||||
sheathOff: -1,
|
||||
},
|
||||
cls: charData.class,
|
||||
items: charData.characterModelItems.filter(item => !((item[0] === 1 && hideHelm) || (item[0] === 16 && hideCloak))),
|
||||
models: {
|
||||
type: ZamModelViewer.Wow.Types.CHARACTER,
|
||||
id: `${races[charData.race]}${genders[charData.gender]}`,
|
||||
},
|
||||
mount: {
|
||||
type: ZamModelViewer.Wow.Types.NPC,
|
||||
id: 0,
|
||||
},
|
||||
};
|
||||
createViewer();
|
||||
const sheathTypes = {
|
||||
2: {
|
||||
// One handed weapons
|
||||
0: 3,
|
||||
4: 3,
|
||||
7: 3,
|
||||
14: 3,
|
||||
15: 3,
|
||||
17: 3,
|
||||
20: 3,
|
||||
|
||||
function debounce(func, timeout) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => { func.apply(this, args); }, timeout);
|
||||
// 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
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let windowWidth = $(window).width();
|
||||
$(window).resize(debounce(() => {
|
||||
const newWidth = $(window).width();
|
||||
if (windowWidth === newWidth) {
|
||||
// Do nothing if only the height changed.
|
||||
// This prevents triggering a resize when the navigation bar moves on mobile when scrolling.
|
||||
return;
|
||||
const characterModel = {
|
||||
type: ZamModelViewer.WOW,
|
||||
contentPath: "{{ contentPath }}",
|
||||
container: $model,
|
||||
hd: true,
|
||||
aspect: $model.outerWidth() / $model.outerHeight(),
|
||||
charCustomization: {
|
||||
race: charData.race,
|
||||
gender: charData.gender,
|
||||
options: charData.customizationOptions,
|
||||
sheathMain: -1,
|
||||
sheathOff: -1,
|
||||
},
|
||||
cls: charData.class,
|
||||
items: (charData.characterModelTransmogs ?? charData.characterModelItems)
|
||||
.filter(item => !((item[0] === 1 && hideHelm) || (item[0] === 16 && hideCloak)) && item[1] !== -1),
|
||||
models: {
|
||||
type: ZamModelViewer.Wow.Types.CHARACTER,
|
||||
id: `${races[charData.race]}${genders[charData.gender]}`,
|
||||
},
|
||||
mount: {
|
||||
type: ZamModelViewer.Wow.Types.NPC,
|
||||
id: 0,
|
||||
},
|
||||
};
|
||||
createViewer();
|
||||
|
||||
function debounce(func, timeout) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => { func.apply(this, args); }, timeout);
|
||||
};
|
||||
}
|
||||
windowWidth = newWidth;
|
||||
onResize();
|
||||
}, 500));
|
||||
|
||||
let windowWidth = $(window).width();
|
||||
$(window).resize(debounce(() => {
|
||||
const newWidth = $(window).width();
|
||||
if (windowWidth === newWidth) {
|
||||
// Do nothing if only the height changed.
|
||||
// This prevents triggering a resize when the navigation bar moves on mobile when scrolling.
|
||||
return;
|
||||
}
|
||||
windowWidth = newWidth;
|
||||
onResize();
|
||||
}, 500));
|
||||
|
||||
$("<script>")
|
||||
.attr("src", "{{aowow}}/static/widgets/power.js")
|
||||
.appendTo("body");
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
29
static/css/arena-team.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#arena-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#arena-header .emblem-container {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
#arena-header .info {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#arena-header .info .team-name {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
#stats td,
|
||||
#stats th {
|
||||
border: 1px solid #dddddd;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dataTables_info {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -24,3 +24,12 @@
|
|||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.no-link-text a span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#list-transmogs-container,
|
||||
#lbl-hide-transmogs {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
position: absolute;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 1;
|
||||
background-size: 36px;
|
||||
}
|
||||
|
||||
.iconlarge {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
#select-realm {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#results_processing {
|
||||
height: 70px;
|
||||
}
|
||||
|
|
|
|||
8
static/css/ladder-arena.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#select-arena-type-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#select-arena-type-container .arena-type-label {
|
||||
align-self: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
|
@ -59,43 +59,45 @@
|
|||
</table>
|
||||
|
||||
<script type="application/javascript">
|
||||
createGuildEmblem({{{ JSONstringify emblem }}}, $(".emblem-container .guild-emblem")[0]);
|
||||
$(window).on("load", () => {
|
||||
createGuildEmblem({{{ JSONstringify emblem }}}, $(".emblem-container .guild-emblem")[0]);
|
||||
|
||||
dt = $("#members").DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
searchDelay: 800,
|
||||
ajax: {
|
||||
url: `{{websiteRoot}}/guild/{{realm}}/{{id}}/members`,
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 0,
|
||||
render: (name) => `<a href="{{websiteRoot}}/character/{{realm}}/${name}">${name}</a>`,
|
||||
dt = $("#members").DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
searchDelay: 800,
|
||||
ajax: {
|
||||
url: `{{websiteRoot}}/guild/{{{realm}}}/{{id}}/members`,
|
||||
},
|
||||
{
|
||||
targets: 1,
|
||||
render: (rank, type, row, meta) => meta.settings.json.ranks[rank],
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 0,
|
||||
render: (name) => `<a href="{{websiteRoot}}/character/{{realm}}/${name}">${name}</a>`,
|
||||
},
|
||||
{
|
||||
targets: 1,
|
||||
render: (rank, type, row, meta) => meta.settings.json.ranks[rank],
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 3,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/class_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 4,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/race_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 5,
|
||||
render: online => online ? "🟢" : "🔴",
|
||||
},
|
||||
],
|
||||
responsive: {
|
||||
details: true,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 3,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/class_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 4,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/race_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 5,
|
||||
render: online => online ? "🟢" : "🔴",
|
||||
},
|
||||
],
|
||||
responsive: {
|
||||
details: true,
|
||||
},
|
||||
order: [[1, "asc"]],
|
||||
order: [[1, "asc"]],
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
BIN
static/img/icon-border/large/default.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/icon-border/large/q0.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/img/icon-border/large/q1.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/img/icon-border/large/q2.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/img/icon-border/large/q3.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/img/icon-border/large/q4.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/icon-border/large/q5.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/icon-border/large/q6.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/icon-border/large/q7.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/icon-border/large/q8.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/img/icon-border/large/q9.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
static/img/icon-border/medium/default.png
Normal file
|
After Width: | Height: | Size: 784 B |
BIN
static/img/icon-border/medium/q0.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
static/img/icon-border/medium/q1.png
Normal file
|
After Width: | Height: | Size: 946 B |
BIN
static/img/icon-border/medium/q2.png
Normal file
|
After Width: | Height: | Size: 858 B |
BIN
static/img/icon-border/medium/q3.png
Normal file
|
After Width: | Height: | Size: 851 B |
BIN
static/img/icon-border/medium/q4.png
Normal file
|
After Width: | Height: | Size: 883 B |
BIN
static/img/icon-border/medium/q5.png
Normal file
|
After Width: | Height: | Size: 876 B |
BIN
static/img/icon-border/medium/q6.png
Normal file
|
After Width: | Height: | Size: 933 B |
BIN
static/img/icon-border/medium/q7.png
Normal file
|
After Width: | Height: | Size: 933 B |
BIN
static/img/icon-border/medium/q8.png
Normal file
|
After Width: | Height: | Size: 933 B |
BIN
static/img/icon-border/medium/q9.png
Normal file
|
After Width: | Height: | Size: 884 B |
|
|
@ -3,8 +3,11 @@
|
|||
|
||||
<h1 class="title is-size-1">Armory</h1>
|
||||
|
||||
{{#if (not (equalsLength realms 1))}}
|
||||
<a href="{{websiteRoot}}/arena">Arena Ladder</a> 
|
||||
|
||||
<br><br>
|
||||
|
||||
{{#if (not (equalsLength realms 1))}}
|
||||
<div id="select-realm-container">
|
||||
<span class="realm-label">Realm:</span>
|
||||
<div class="select">
|
||||
|
|
@ -34,49 +37,51 @@
|
|||
</table>
|
||||
|
||||
<script type="application/javascript">
|
||||
let dt;
|
||||
$(window).on("load", () => {
|
||||
let dt;
|
||||
|
||||
$("#select-realm").on("change", () => {
|
||||
dt.draw();
|
||||
});
|
||||
$("#select-realm").on("change", () => {
|
||||
dt.draw();
|
||||
});
|
||||
|
||||
dt = $("#results").DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
searchDelay: 800,
|
||||
ajax: {
|
||||
url: `{{websiteRoot}}/search`,
|
||||
data: d => {
|
||||
d.realm = $("#select-realm").val();
|
||||
dt = $("#results").DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
searchDelay: 800,
|
||||
ajax: {
|
||||
url: `{{websiteRoot}}/search`,
|
||||
data: d => {
|
||||
d.realm = $("#select-realm").val();
|
||||
},
|
||||
},
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 0,
|
||||
render: (name, type, row, meta) => `<a href="{{websiteRoot}}/character/${meta.settings.json.realm}/${name}">${name}</a>`,
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 0,
|
||||
render: (name, type, row, meta) => `<a href="{{websiteRoot}}/character/${meta.settings.json.realm}/${name}">${name}</a>`,
|
||||
},
|
||||
{
|
||||
targets: 1,
|
||||
render: (guild, type, row, meta) => guild === null ? "" : `<a href="{{websiteRoot}}/guild/${meta.settings.json.realm}/${guild}">${guild}</a>`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 3,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/class_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 4,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/race_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 5,
|
||||
render: online => online ? "🟢" : "🔴",
|
||||
},
|
||||
],
|
||||
responsive: {
|
||||
details: true,
|
||||
},
|
||||
{
|
||||
targets: 1,
|
||||
render: (guild, type, row, meta) => guild === null ? "" : `<a href="{{websiteRoot}}/guild/${meta.settings.json.realm}/${guild}">${guild}</a>`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 3,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/class_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 4,
|
||||
render: data => `<img src="{{aowow}}/static/images/wow/icons/medium/race_${data}.jpg">`,
|
||||
},
|
||||
{
|
||||
searchable: false,
|
||||
targets: 5,
|
||||
render: online => online ? "🟢" : "🔴",
|
||||
},
|
||||
],
|
||||
responsive: {
|
||||
details: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 = $("<div>").addClass("images").appendTo($emblem);
|
||||
const bgUpper = $("<img>").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 = $("<div>").addClass("images").appendTo($emblem);
|
||||
const banner = $("<img>").attr("src", imgUrl(teamSize)).appendTo($images)[0];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
window.parent.postMessage({
|
||||
url: window.location.pathname.replace(handlebarsData.websiteRoot, ""),
|
||||
}, "*");
|
||||
window.parent.postMessage(
|
||||
{
|
||||
url: window.location.pathname.replace(handlebarsData.websiteRoot, ""),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
|
|
|||
101
static/ladder-arena.hbs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/index.css">
|
||||
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/ladder-arena.css">
|
||||
{{> datatables}}
|
||||
|
||||
<h1 class="title is-size-1">Arena Ladder</h1>
|
||||
|
||||
<a href="{{websiteRoot}}/">Armory</a> 
|
||||
|
||||
<br><br>
|
||||
|
||||
{{#if (not (equalsLength realms 1))}}
|
||||
<div id="select-realm-container">
|
||||
<span class="realm-label">Realm:</span>
|
||||
<div class="select">
|
||||
<select id="select-realm">
|
||||
{{#each realms}}
|
||||
<option>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
 
|
||||
<div class="select">
|
||||
<select id="select-arena-type">
|
||||
<option value="2">2v2</option>
|
||||
<option value="3">3v3</option>
|
||||
<option value="5">5v5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div id="select-arena-type-container">
|
||||
<span class="arena-type-label">Type:</span>
|
||||
<div class="select">
|
||||
<select id="select-arena-type">
|
||||
<option value="2">2v2</option>
|
||||
<option value="3">3v3</option>
|
||||
<option value="5">5v5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<br>
|
||||
|
||||
<table id="results" class="stripe hover row-border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Rating</th>
|
||||
<th>Wins</th>
|
||||
<th>Losses</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<script type="application/javascript">
|
||||
$(window).on("load", () => {
|
||||
let dt;
|
||||
|
||||
$("#select-realm, #select-arena-type").on("change", () => {
|
||||
dt.draw();
|
||||
});
|
||||
|
||||
dt = $("#results").DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
searchDelay: 800,
|
||||
ajax: {
|
||||
url: `{{websiteRoot}}/arena/ladder`,
|
||||
data: d => {
|
||||
d.realm = $("#select-realm").val();
|
||||
d.teamsize = $("#select-arena-type").val();
|
||||
},
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 0,
|
||||
render: (name, type, row, meta) => `<a href="{{websiteRoot}}/arena/team/${meta.settings.json.realm}/${name}">${name}</a>`,
|
||||
},
|
||||
{
|
||||
targets: 1,
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
targets: 2,
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
targets: 3,
|
||||
searchable: false,
|
||||
render: (gamesPlayed, type, row, meta) => gamesPlayed - row[2],
|
||||
},
|
||||
],
|
||||
responsive: {
|
||||
details: true,
|
||||
},
|
||||
order: [[1, "desc"], [0, "asc"]],
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
const handlebarsData = {{{JSONstringify locals}}};
|
||||
</script>
|
||||
<script type="application/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script type="application/javascript" src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.1/dist/js.cookie.min.js"></script>
|
||||
|
||||
{{#if iframeMode.enabled}}
|
||||
<script type="application/javascript">
|
||||
|
|
@ -36,3 +37,5 @@
|
|||
{{{ body }}}
|
||||
</section>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<a href="{{websiteRoot}}/">Back to Armory</a>
|
||||
<br><br>
|
||||
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}">Character</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a>
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a>
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}">Character</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a> 
|
||||
<br>
|
||||
<br>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/icons.css">
|
||||
<style>
|
||||
.iconlarge .border {
|
||||
background-image: url("{{aowow}}/static/images/Icon/large/border/default.png");
|
||||
background-image: url("{{websiteRoot}}/img/icon-border/large/default.png");
|
||||
}
|
||||
|
||||
.iconlarge a {
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
}
|
||||
|
||||
.iconmedium .border {
|
||||
background-image: url("{{aowow}}/static/images/Icon/medium/border/default.png");
|
||||
background-image: url("{{websiteRoot}}/img/icon-border/medium/default.png");
|
||||
}
|
||||
|
||||
.iconmedium a {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
"outDir": "build",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||