Compare commits

...

58 commits

Author SHA1 Message Date
92fda8086e Merge pull request 'Filter the auction-house bot as well as Playerbots' (#4) from filter-service-accounts into master
Some checks failed
Build / build (push) Has been cancelled
Lint / eslint (push) Has been cancelled
Reviewed-on: #4
2026-09-02 15:42:06 -06:00
Claude
28b9fd1aee
Filter several service accounts, not just Playerbots
Some checks failed
Build / build (push) Has been cancelled
Lint / eslint (push) Has been cancelled
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled
The auction-house bot account (ahouse) owns 9 mule characters that were
showing up in the armory alongside real players. One LIKE pattern could not
cover both it and RNDBOT%.

Replaces botAccountPattern with botAccountPatterns, a comma-separated list
of LIKE patterns, and builds the clause with Utils.botAccountFilter. An empty
list now disables the filter outright rather than emitting a clause that
matches nothing, so misconfiguration fails open instead of hiding everyone.

The stack default becomes "RNDBOT%,ahouse"; config.default.json keeps the
generic "RNDBOT%" so the fork stays upstreamable.

Verified against the live realm: 29 listed before, 20 after. The 9 excluded
are the ahouse mules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
2026-09-02 21:41:06 +00:00
24c7c403ba Merge pull request 'Fix ambiguous 'online' column breaking every character page' (#3) from fix-ambiguous-online into master
Some checks are pending
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
Reviewed-on: #3
2026-09-02 15:12:08 -06:00
Claude
0e7cf0f988
Fix ambiguous 'online' column on character pages
Some checks failed
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled
The bot-account filter joins acore_auth.account, which has its own `online`
column. The character query selected several columns unqualified, so `online`
became ambiguous and every character page returned a 500:

  Error: Column 'online' in field list is ambiguous
    at CharacterController.getCharacterData

Qualifies every column in that SELECT with `characters`, rather than only the
one that collided, so a future join cannot reintroduce this. The search and
guild listings were never affected: DataTablesSsp always qualifies its columns
with the base table, and only this hand-written query did not.

Verified against the live realm - the previously failing query for
Heavenlymagi now returns its row, and a bot name still returns none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
2026-09-02 21:10:41 +00:00
424d65233b Merge pull request 'Hide Playerbot accounts from search, character pages and guild rosters' (#2) from hide-bot-accounts into master
Some checks are pending
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
Reviewed-on: #2
2026-09-02 14:21:49 -06:00
Claude
6d7398d055
Show game master characters in the armory
Some checks failed
Build / build (push) Has been cancelled
Lint / eslint (push) Has been cancelled
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled
Upstream hides GM accounts by default, which assumes GM means staff-only.
On this realm GM accounts belong to real players — Spinnaker, the highest
level character on the server, is gmlevel 3 — so the filter was hiding
people who should be listed.

Flips the stack default to 0 and documents both this and the bot filter.
ARMORY_HIDE_GMS=1 restores upstream behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
2026-09-02 20:10:54 +00:00
Claude
cc7ca06182
Hide Playerbot accounts from search, character pages and guild rosters
Some checks failed
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled
Playerbots are ordinary accounts, not game masters, so hideGameMasters does
not touch them. On a realm running mod-playerbots that means the armory is
almost entirely bots: 1,377 characters listed, of which about 1,350 are bots.

Adds two options, hideBotAccounts (default true) and botAccountPattern
(default "RNDBOT%"), applied the same way the game master filter already
works: left join the accounts we do not want, then require the join to have
missed. A realm without bots matches nothing, so the default is harmless.

Applied in the search listing, guild rosters, and the character lookup, so a
bot's page 404s rather than rendering.

The pattern is spliced into join clauses the query builder emits as raw SQL,
so it cannot be a bound parameter. It comes from the operator's own config
rather than from a request, but Utils.quoteSqlString quotes it so a stray
apostrophe cannot produce a broken query.

Verified against the live realm:

  listed before  1377
  listed after     27
  Spinnaker, Kdog  shown
  Rarzosh (bot)    hidden

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
2026-09-02 20:08:24 +00:00
ffdeacc0c4 Merge pull request #1: Portainer stack and deployment doc for Nox
Some checks are pending
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
2026-09-02 13:10:56 -06:00
Claude
788afd6e8e
docs: chown the data directory before downloading into it
Some checks failed
Build / build (push) Has been cancelled
Lint / eslint (push) Has been cancelled
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled
The procedure created /opt/mythica-armory with sudo but then ran the curl
downloads as the normal user, so every one failed with "Permission denied /
Failure writing output to destination". Adds the chown and notes that user
ownership is fine, since the container reads the bind mount as root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
2026-09-02 17:31:50 +00:00
Claude
52a3af6642
Add a Portainer stack and deployment doc for Nox
Some checks failed
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled
Deploys this fork alongside the Mythica web page, reading the AzerothCore
databases over the LAN. Nothing runs on the game server itself.

docker-compose.mythica.yml differs from upstream's compose because Portainer
deploys from a git checkout it manages:

- Configuration comes from Portainer stack environment variables rather than
  a .env file in the repo, which git stacks do not provide.
- Only the four large model-data directories are bind-mounted from the host,
  so the DBC csv files stay versioned with the code in the image and a repo
  re-clone cannot wipe the 2 GB of model data.
- The unused ac-network block is dropped.

The doc also records why the model data is a manual step: upstream's fetchdata
tool no longer works, because Zam reorganised the CDN and every character path
now 404s. A run appears to succeed while producing no character data, and the
app then fails at startup on meta/charactercustomization2/1_0.json. The v1.0.0
snapshot mirrored into this fork's release is the only remaining source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
2026-09-02 17:21:45 +00:00
Stefano Borzì
6cb84817ac
fix: column ordering (#47) 2026-01-24 13:12:59 +01:00
Axel Cocat
a6001ee47a fix: output realm as raw in urls 2025-08-27 19:42:09 +02:00
Yehonal
f37189e431
fix(docker-compose): add extra_hosts for host access and define ac-nework (#43)
* fix(docker-compose): add extra_hosts for host access and define ac-network

* fix(docker-compose): remove extra_hosts configuration for host access
2025-07-11 11:07:12 +02:00
Axel Cocat
2df1fad904
docs(README): remove bugged fetchdata script 2024-07-08 20:41:43 +02:00
Axel Cocat
13ae2e9b2d fix: change default aowow url 2024-04-06 00:20:58 +02:00
Axel Cocat
f739a92e1e fix(DataTablesSsp): clamp offset and limit values 2024-03-02 16:36:27 +01:00
Axel Cocat
5e868f9dad Merge branch 'master' of github.com:r-o-b-o-t-o/azerothcore-armory 2024-02-02 18:34:54 +01:00
Axel Cocat
9801c03013 fix(CharacterController): add check for empty equipment 2024-02-02 18:34:37 +01:00
Axel Cocat
a1e5bb6712
docs(README): update Discord username 2023-08-26 17:13:50 +02:00
Axel Cocat
401e92eb70 style: fix typo 2023-08-05 00:48:16 +02:00
Axel Cocat
4f0f579028 docs(README): add build badge 2023-08-05 00:45:49 +02:00
Axel Cocat
f7be06074e feat: arena ladder 2023-08-05 00:42:55 +02:00
Axel Cocat
ac6ca4c84e v1.1.1 2022-06-27 23:52:56 +02:00
Axel Cocat
dc1af0802f
ci: fix npm ci (#27) 2022-06-27 23:43:46 +02:00
Axel Cocat
58ca06d1e0
feat: handle hidden transmog items (#26) 2022-06-27 23:25:28 +02:00
Axel Cocat
0e4fa65d3c fix(character): fix mounts when wielding no weapons 2022-06-21 01:54:21 +02:00
Axel Cocat
e497ecb725 build: upgrade dependencies 2022-06-21 01:41:44 +02:00
Axel Cocat
6a847c8ccb style: apply prettier 2022-06-21 01:28:22 +02:00
Axel Cocat
dda4aae6f6 fix(DbcReader): revert parsing 2022-06-21 01:28:04 +02:00
Axel Cocat
6116fea58e fix(character/achievements): fix recent achievements sorting 2022-05-29 20:11:42 +02:00
Axel Cocat
a4c6586bd0 v1.1.0 2022-05-15 00:51:36 +02:00
Axel Cocat
980467f49f fix(character): take tabard toggle into account when showing/hiding transmogs 2022-05-15 00:50:03 +02:00
Axel Cocat
0dd11a77f5 feat(character): list of transmog appearances 2022-05-15 00:46:43 +02:00
Axel Cocat
c95ad80e3f
Merge pull request #23 from r-o-b-o-t-o/fix/add-on-loads 2022-05-14 23:58:11 +02:00
Axel Cocat
11f13544c9 fix(character): fix setBackground error 2022-05-14 23:46:35 +02:00
Axel Cocat
f34edb0ea0 fix: run js scripts on window load 2022-05-14 23:14:33 +02:00
Axel Cocat
843d5f856c chore(.dockerignore): fix logs ignore 2022-05-14 22:55:26 +02:00
Axel Cocat
905f1549fd
feat(character): add option to use the ZAM CDN for the model viewer assets (#22) 2022-05-14 22:19:21 +02:00
Axel Cocat
a98f9b4658 chore(package.json): update dependencies 2022-05-14 19:00:05 +02:00
Axel Cocat
72a1d3e402 chore(package.json): add missing eslint plugin 2022-05-14 18:48:47 +02:00
Axel Cocat
588b30c0ce ci: add github workflows 2022-05-14 18:46:54 +02:00
Axel Cocat
bb6428955f chore: add and apply eslint 2022-05-14 18:44:50 +02:00
Axel Cocat
7cd2748d33
feat(character): add transmog module compatibility (#21) 2022-05-14 16:39:31 +02:00
Axel Cocat
8b1d172690
feat(character): option to disable the 3d viewer (#17) 2022-05-07 17:50:41 +02:00
Axel Cocat
1156ceb17f
feat(character): colored item borders for quality (#20) 2022-05-07 17:33:42 +02:00
Axel Cocat
08009a575c
fix: character search duplicates because of account_access (#19) 2022-05-06 22:01:04 +02:00
Stefano Borzì
216904fd6e
chore: minor improvements in for cycle (#18) 2022-05-06 20:24:22 +02:00
Stefano Borzì
8331e4dd73
chore: apply prettier on all files (#8)
* chore: apply prettier on all files

* chore: add prettier npm script

* Update .prettierrc

Co-authored-by: Axel Cocat <ax.cocat@gmail.com>

* Update .prettierignore

Co-authored-by: Axel Cocat <ax.cocat@gmail.com>

* Update .prettierignore

Co-authored-by: Axel Cocat <ax.cocat@gmail.com>

* Update .prettierignore

Co-authored-by: Axel Cocat <ax.cocat@gmail.com>

* chore: apply prettier with new conf

* Revert "chore: apply prettier with new conf"

This reverts commit db2a04c03f.

* chore: apply prettier correctly

* chore: update .prettierignore

* chore: restore viewer.min.js minification

* chore: apply again prettier

Co-authored-by: Stefano Borzi <stefano.borzi@fedex.com>
Co-authored-by: Axel Cocat <ax.cocat@gmail.com>
2022-05-06 19:35:26 +02:00
Axel Cocat
fe21209817 fix(layout): add missing closing html tag 2022-05-06 14:25:51 +02:00
Axel Cocat
8906047a06
docs(README): fix typo 2022-04-14 11:44:47 +02:00
dependabot[bot]
43ccb61341
build(deps): bump moment from 2.29.1 to 2.29.2 (#15)
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.2.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.2)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-10 18:47:41 +02:00
dependabot[bot]
4062263e8d
build(deps): bump minimist from 1.2.5 to 1.2.6 (#14)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-02 21:22:01 +02:00
Axel Cocat
6933e8ee86
feat: add FUNDING.yml 2022-03-21 00:33:27 +01:00
Axel Cocat
5b0ab5b118 v1.0.1 2022-03-21 00:30:53 +01:00
Axel Cocat
05f5cabf5c docs(README): add demo link to top 2022-03-21 00:29:12 +01:00
Axel Cocat
91f3ec424a
fix(character): fix sheath type when mounted (#5) 2022-03-21 00:28:13 +01:00
Axel Cocat
af497b7ea6
fix(character): fix equipment slot icons size on mobile (#4) 2022-03-20 21:02:17 +01:00
Axel Cocat
2d672fd5ed docs(README): fix issue links 2022-03-20 20:29:11 +01:00
70 changed files with 5565 additions and 1410 deletions

View file

@ -14,7 +14,7 @@ config.json
.env .env
# Log files # Log files
*.log logs/
# Git folder # Git folder
.git/ .git/

View file

@ -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_URL="https://mywebsite.com"
ACORE_ARMORY_WEBSITE_NAME="My Website" ACORE_ARMORY_WEBSITE_NAME="My Website"
ACORE_ARMORY_WEBSITE_ROOT="" 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_IFRAME_MODE__URL="https://mywebsite.com/armory"
ACORE_ARMORY_LOAD_DBCS=1 ACORE_ARMORY_LOAD_DBCS=1
ACORE_ARMORY_HIDE_GAME_MASTERS=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__NAME="AzerothCore"
ACORE_ARMORY_REALMS__0__REALM_ID=1 ACORE_ARMORY_REALMS__0__REALM_ID=1
ACORE_ARMORY_REALMS__0__AUTH_DATABASE="acore_auth" ACORE_ARMORY_REALMS__0__AUTH_DATABASE="acore_auth"

33
.eslintrc.json Normal file
View 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
View file

@ -0,0 +1 @@
ko_fi: roboto

13
.github/workflows/build.yml vendored Normal file
View 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
View 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
View file

@ -15,3 +15,6 @@ config.json
# Log files # Log files
*.log *.log
# IDEs
.vscode/

21
.prettierignore Normal file
View 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
View file

@ -0,0 +1,9 @@
{
"printWidth": 140,
"useTabs": true,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always"
}

View file

@ -1,4 +1,5 @@
<!-- SHIELDS --> <!-- SHIELDS -->
[![Build](https://github.com/r-o-b-o-t-o/azerothcore-armory/actions/workflows/build.yml/badge.svg)](https://github.com/r-o-b-o-t-o/azerothcore-armory/actions/workflows/build.yml)
[![Stargazers][stars-shield]][stars-url] [![Stargazers][stars-shield]][stars-url]
[![Forks][forks-shield]][forks-url] [![Forks][forks-shield]][forks-url]
[![Issues][issues-shield]][issues-url] [![Issues][issues-shield]][issues-url]
@ -11,11 +12,13 @@
<p align="center"> <p align="center">
A website to view your <a href="https://github.com/azerothcore/azerothcore-wotlk">AzerothCore</a> server's characters A website to view your <a href="https://github.com/azerothcore/azerothcore-wotlk">AzerothCore</a> server's characters
<br /> <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> </p>
</div> </div>
@ -108,21 +111,14 @@ I also noticed that such a tool was frequently requested in the AzerothCore Disc
```sh ```sh
git clone git@github.com:r-o-b-o-t-o/azerothcore-armory.git git clone git@github.com:r-o-b-o-t-o/azerothcore-armory.git
``` ```
3. Install the dependencies: 2. Install the dependencies:
```sh ```sh
cd azerothcore-armory/ cd azerothcore-armory/
npm install 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. See the [Configuration Reference](#configuration-reference) below for a description of all values.
5. Download the model viewer's data: 4. Download the model viewer's data from the [Releases](https://github.com/r-o-b-o-t-o/azerothcore-armory/releases) page and extract it to the `data/` directory.
* 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.
### Configuration Reference ### Configuration Reference
@ -130,8 +126,8 @@ I also noticed that such a tool was frequently requested in the AzerothCore Disc
<summary>Main configuration</summary> <summary>Main configuration</summary>
| config.json | .env | Type | Default value | Description | | 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 | | `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 | | `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 | | `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"` | | `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"` |
@ -140,6 +136,8 @@ I also noticed that such a tool was frequently requested in the AzerothCore Disc
| `iframeMode.url` | `ACORE_ARMORY_IFRAME_MODE__URL` | String | `"https://mywebsite.com/armory"` | Set this to the URL of the page that hosts the `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 | | `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 | | `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` | `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].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].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 |
@ -180,8 +178,7 @@ Open a web browser and navigate to http://localhost:48733
Other useful npm scripts: Other useful npm scripts:
* `npm run clean`: cleans the build directory * `npm run clean`: cleans the build directory
* `npm run watch`: watches for changes and rebuilds automatically, useful for development * `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 ### With Docker
@ -257,7 +254,7 @@ This repository is used in production over at [ChromieCraft](https://www.chromie
- [X] Character page - [X] Character page
- [X] Online/offline status - [X] Online/offline status
- [X] Equipment with tooltips - [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] Talent trees, including glyphs and dual spec support
- [X] Achievements - [X] Achievements
- [X] PvP statistics, including arena teams - [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 - [X] Multiple realms support
- [ ] PvE ladder - [ ] PvE ladder
- [ ] PvP 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. 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 -->
## 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> <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 [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-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 [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

View file

@ -1,5 +1,5 @@
{ {
"aowowUrl": "https://wotlkdb.com", "aowowUrl": "https://wowgaming.altervista.org/aowow",
"websiteUrl": "https://mywebsite.com", "websiteUrl": "https://mywebsite.com",
"websiteName": "My Website", "websiteName": "My Website",
"websiteRoot": "", "websiteRoot": "",
@ -9,6 +9,10 @@
}, },
"loadDbcs": true, "loadDbcs": true,
"hideGameMasters": true, "hideGameMasters": true,
"hideBotAccounts": true,
"botAccountPatterns": "RNDBOT%",
"transmogModule": false,
"useZamCdn": false,
"realms": [ "realms": [
{ {
"name": "AzerothCore", "name": "AzerothCore",

View 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"

View file

@ -17,3 +17,6 @@ services:
networks: networks:
local-private-net: local-private-net:
driver: bridge 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "azerothcore-armory", "name": "azerothcore-armory",
"version": "1.0.0", "version": "1.1.1",
"description": "", "description": "",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
@ -8,6 +8,8 @@
"start": "node --expose-gc build/armory/main.js", "start": "node --expose-gc build/armory/main.js",
"watch": "concurrently \"tsc -w --project .\" \"npm run nodemon\"", "watch": "concurrently \"tsc -w --project .\" \"npm run nodemon\"",
"nodemon": "nodemon -q -w build -w config.json build/armory/main.js", "nodemon": "nodemon -q -w build -w config.json build/armory/main.js",
"lint": "eslint src/",
"prettier": "prettier . -w",
"fetchdata": "node build/tools/fetchdata.js", "fetchdata": "node build/tools/fetchdata.js",
"cleardata": "rimraf data/bone data/meta data/mo3 data/textures" "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", "homepage": "https://github.com/r-o-b-o-t-o/azerothcore-armory",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/cli-progress": "^3.9.2", "@types/cli-progress": "^3.11.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/glob": "^7.2.0", "@types/glob": "^7.2.0",
"@types/mkdirp": "^1.0.2", "@types/mkdirp": "^1.0.2",
"@types/morgan": "^1.9.3", "@types/morgan": "^1.9.3",
"@types/node": "^17.0.21", "@types/node": "^18.0.0",
"@types/node-fetch": "^2.6.1", "@types/node-fetch": "^2.6.2",
"@types/pako": "^1.0.3", "@types/pako": "^2.0.0",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"concurrently": "^7.0.0", "@typescript-eslint/eslint-plugin": "^5.30.0",
"nodemon": "^2.0.15", "concurrently": "^7.2.2",
"eslint": "^8.18.0",
"nodemon": "^2.0.18",
"prettier": "^2.7.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"typescript": "^4.5.5" "typescript": "^4.7.4"
}, },
"dependencies": { "dependencies": {
"@supercharge/promise-pool": "^2.1.0", "@supercharge/promise-pool": "^2.3.0",
"camelcase": "^6.3.0", "camelcase": "^6.3.0",
"cli-progress": "^3.10.0", "cli-progress": "^3.11.2",
"dotenv": "^16.0.0", "dotenv": "^16.0.1",
"express": "^4.17.3", "express": "^4.18.1",
"express-handlebars": "^6.0.2", "express-handlebars": "^6.0.6",
"glob": "^7.2.0", "glob": "^8.0.3",
"glob-promise": "^4.2.2",
"handlebars-helpers": "^0.9.8", "handlebars-helpers": "^0.9.8",
"html-entities": "^2.3.2", "html-entities": "^2.3.3",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"mysql2": "^2.3.3", "mysql2": "^2.3.3",
@ -48,6 +52,6 @@
"pretty-ms": "^7.0.1", "pretty-ms": "^7.0.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"winston": "^3.6.0" "winston": "^3.8.0"
} }
} }

View file

@ -13,6 +13,7 @@ import { CharacterCustomization } from "./data/CharacterCustomization";
import { IndexController } from "./controllers/IndexController"; import { IndexController } from "./controllers/IndexController";
import { CharacterController } from "./controllers/CharacterController"; import { CharacterController } from "./controllers/CharacterController";
import { GuildController } from "./controllers/GuildController"; import { GuildController } from "./controllers/GuildController";
import { ArenaController } from "./controllers/ArenaController";
export class Armory { export class Armory {
public characterCustomization: CharacterCustomization; public characterCustomization: CharacterCustomization;
@ -85,22 +86,24 @@ export class Armory {
websiteRoot: this.config.websiteRoot, websiteRoot: this.config.websiteRoot,
iframeMode: this.config.iframeMode, iframeMode: this.config.iframeMode,
}; };
for (const key in locals) { for (const key of Object.keys(locals)) {
if (locals.hasOwnProperty(key)) {
app.locals[key] = locals[key]; app.locals[key] = locals[key];
} }
}
app.locals.locals = locals; app.locals.locals = locals;
app.engine(".hbs", handlebarsEngine({ app.engine(
".hbs",
handlebarsEngine({
extname: "hbs", extname: "hbs",
partialsDir: path.join(process.cwd(), "static", "partials"), partialsDir: path.join(process.cwd(), "static", "partials"),
layoutsDir: path.join(process.cwd(), "static"), layoutsDir: path.join(process.cwd(), "static"),
defaultLayout: "layout.hbs", defaultLayout: "layout.hbs",
helpers: { helpers: {
// eslint-disable-next-line @typescript-eslint/no-var-requires
...require("handlebars-helpers")(), ...require("handlebars-helpers")(),
}, },
})); }),
);
app.set("view engine", "handlebars"); app.set("view engine", "handlebars");
app.set("views", path.join(process.cwd(), "static")); app.set("views", path.join(process.cwd(), "static"));
@ -122,20 +125,22 @@ export class Armory {
} }
return req.socket.remoteAddress; return req.socket.remoteAddress;
}); });
app.use(morgan(":method :url :status - ID :id - IP :ip - :response-time ms", { app.use(
morgan(":method :url :status - ID :id - IP :ip - :response-time ms", {
stream: { stream: {
write: (msg) => this.logger.http(msg.trim()), write: (msg) => this.logger.http(msg.trim()),
}, },
})); }),
);
app.use("/js", express.static(`static/js`)); app.use("/js", express.static("static/js"));
app.use("/css", express.static(`static/css`)); app.use("/css", express.static("static/css"));
app.use("/img", express.static(`static/img`)); app.use("/img", express.static("static/img"));
app.use("/data/mo3", express.static(`data/mo3`)); app.use("/data/mo3", express.static("data/mo3"));
app.use("/data/meta", express.static(`data/meta`)); app.use("/data/meta", express.static("data/meta"));
app.use("/data/bone", express.static(`data/bone`)); app.use("/data/bone", express.static("data/bone"));
app.use("/data/textures", express.static(`data/textures`)); app.use("/data/textures", express.static("data/textures"));
app.use("/data/background.png", express.static(`data/modelviewer-background.png`)); app.use("/data/background.png", express.static("data/modelviewer-background.png"));
const indexController = new IndexController(this); const indexController = new IndexController(this);
app.get("/", this.wrapRoute(indexController.index.bind(indexController))); 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/:name", this.wrapRoute(guildsController.guild.bind(guildsController)));
app.get("/guild/:realm/:guild/members", this.wrapRoute(guildsController.members.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) => { app.use((err, req: express.Request, res: express.Response, next: express.NextFunction) => {
// Error handler // Error handler
@ -198,14 +208,14 @@ export class Armory {
} }
public getRealm(realm: string): IRealmConfig { 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> { public async getDatabaseCharset(realm: string): Promise<string> {
const db = this.getCharactersDb(realm); const db = this.getCharactersDb(realm);
if (!(realm in this.charsetCache)) { if (!(realm in this.charsetCache)) {
const [rows, fields] = await db.query({ const [rows] = await db.query({
sql: ` sql: `
SELECT CCSA.character_set_name AS charset FROM information_schema.\`TABLES\` T, SELECT CCSA.character_set_name AS charset FROM information_schema.\`TABLES\` T,
information_schema.\`COLLATION_CHARACTER_SET_APPLICABILITY\` CCSA information_schema.\`COLLATION_CHARACTER_SET_APPLICABILITY\` CCSA
@ -232,7 +242,7 @@ export class Armory {
}, 500); }, 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 // Adds error handling for promise-based controller methods
return async (req: express.Request, res: express.Response, next: express.NextFunction) => { return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try { try {

View file

@ -31,12 +31,16 @@ export class Config {
public iframeMode: IIframeModeConfig; public iframeMode: IIframeModeConfig;
public loadDbcs: boolean; public loadDbcs: boolean;
public hideGameMasters: boolean; public hideGameMasters: boolean;
public hideBotAccounts: boolean;
public botAccountPatterns: string;
public transmogModule: boolean;
public useZamCdn: boolean;
public realms: IRealmConfig[]; public realms: IRealmConfig[];
public worldDatabase: IDatabaseConfig; public worldDatabase: IDatabaseConfig;
public dbQueryTimeout: number; public dbQueryTimeout: number;
private static envPrefix: string = "ACORE_ARMORY"; private static envPrefix = "ACORE_ARMORY";
private static checkedMissingField: boolean = false; private static checkedMissingField = false;
public static async load(logger: winston.Logger): Promise<Config> { public static async load(logger: winston.Logger): Promise<Config> {
try { try {
@ -70,13 +74,13 @@ export class Config {
return config as 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 !== "") { if (parentName !== "") {
parentName += "."; parentName += ".";
} }
for (const field in model) { for (const field in model) {
if (!model.hasOwnProperty(field)) { if (!Object.hasOwnProperty.call(model, field)) {
continue; continue;
} }
@ -85,9 +89,9 @@ export class Config {
} else if (typeof model[field] === "object") { } else if (typeof model[field] === "object") {
obj[field] = {}; obj[field] = {};
Config.loadObjFromEnv(logger, obj[field], model[field], parentName + 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); 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]); obj[field] = Config.parseEnvValue(process.env[key], model[field]);
} else if (!Config.checkedMissingField) { } else if (!Config.checkedMissingField) {
logger.warn(`Config field ${key} is missing from .env!`); 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 !== "") { if (parentName !== "") {
parentName += "."; parentName += ".";
} }
const arr = []; const arr = [];
let i = 0; let i = 0;
while (true) { for (;;) {
const key = Config.getEnvKey(parentName + i); 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) { if (!found) {
break; break;
} }
@ -115,10 +119,10 @@ export class Config {
} else if (typeof model === "object") { } else if (typeof model === "object") {
const obj = {}; const obj = {};
Config.loadObjFromEnv(logger, obj, model, parentName + i); Config.loadObjFromEnv(logger, obj, model, parentName + i);
if (Object.keys(obj).length > 0) { if (Object.keys(obj).length) {
arr.push(obj); 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)); arr.push(Config.parseEnvValue(process.env[key], model));
} else { } else {
break; break;
@ -131,13 +135,17 @@ export class Config {
} }
private static getEnvKey(key: string): string { private static getEnvKey(key: string): string {
return Config.envPrefix + "_" + key return (
Config.envPrefix +
"_" +
key
.replace(/\./g, "__") .replace(/\./g, "__")
.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) .replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
.toUpperCase(); .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 type = typeof model;
const lower = value.toLowerCase(); const lower = value.toLowerCase();
if (type === "boolean") { if (type === "boolean") {
@ -149,7 +157,7 @@ export class Config {
return value; 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); const missing = Config.hasMissingFields(obj, model);
if (parentName !== "") { if (parentName !== "") {
parentName += "."; parentName += ".";
@ -157,20 +165,16 @@ export class Config {
for (const field of missing) { for (const field of missing) {
logger.warn(`Field ${parentName}${field} is missing from config.json!`); logger.warn(`Field ${parentName}${field} is missing from config.json!`);
} }
for (const key in model) { for (const key of Object.keys(model)) {
if (typeof model[key] === "object" && obj.hasOwnProperty(key)) { if (typeof model[key] === "object" && Object.hasOwnProperty.call(obj, key)) {
Config.checkAllMissingFields(logger, obj[key], model[key], parentName + key); Config.checkAllMissingFields(logger, obj[key], model[key], parentName + key);
} }
} }
} }
private static hasMissingFields(obj: object, model: object): string[] { private static hasMissingFields(obj: object, model: object): string[] {
const missing = []; const objProp = Object.keys(obj);
for (const key in model) { const missingProps = Object.keys(model).filter((key) => !objProp.includes(key));
if (!obj.hasOwnProperty(key)) { return missingProps;
missing.push(key);
} }
} }
return missing;
}
};

View file

@ -1,17 +1,17 @@
import { Pool } from "mysql2/promise"; import { Pool, RowDataPacket } from "mysql2/promise";
import { Query } from "express-serve-static-core"; import { Query } from "express-serve-static-core";
export interface IResult { export interface IResult {
recordsTotal: number; recordsTotal: number;
recordsFiltered: number; recordsFiltered: number;
draw: number; draw: number;
data: any[][]; data: unknown[][];
} }
export interface IColumnSettings { export interface IColumnSettings {
name: string; name: string;
collation?: string; collation?: string;
formatter?: (data: string | number | null, row: any) => string; formatter?: (data: string | number | null, row: unknown) => string;
table?: string; table?: string;
database?: string; database?: string;
} }
@ -23,6 +23,7 @@ export interface IColumnJoin {
column2: string; column2: string;
database2?: string; database2?: string;
kind: "INNER" | "FULL OUTER" | "LEFT" | "RIGHT"; kind: "INNER" | "FULL OUTER" | "LEFT" | "RIGHT";
where?: string;
} }
export class DataTablesSsp { export class DataTablesSsp {
@ -38,41 +39,44 @@ export class DataTablesSsp {
private start: number; private start: number;
private length: number; private length: number;
private _order: { private _order: {
column: number, column: number;
dir: string, dir: string;
}[]; }[];
private columns: { private columns: {
data: number, data: number;
name: string, name: string;
searchable: boolean, searchable: boolean;
orderable: boolean, orderable: boolean;
search: { search: {
value: string, value: string;
regex: boolean, regex: boolean;
} };
}[]; }[];
private search: { private search: {
value: string, value: string;
regex: boolean, regex: boolean;
}; };
private wheres: string[] = []; private wheres: string[] = [];
private filterBindings: (string | number)[] = []; private filterBindings: (string | number)[] = [];
private customBindings: (string | number)[] = []; private customBindings: (string | number)[] = [];
private filterWhereSql: string = "1"; private filterWhereSql = "1";
private customWhereSql: string = "1"; private customWhereSql = "1";
private limitSql: string = ""; private limitSql = "";
private orderSql: string = ""; private orderSql = "";
private joinSql: string = ""; private joinSql = "";
public constructor(query: Query, db: Pool, table: string, primaryKey: string, columnSettings: IColumnSettings[]) { public constructor(query: Query, db: Pool, table: string, primaryKey: string, columnSettings: IColumnSettings[]) {
this.start = parseInt(query.start as string, 10); this.start = Math.max(0, parseInt(query.start as string, 10));
this.length = parseInt(query.length 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.draw = parseInt(query.draw as string, 10);
this._order = (query.order as { column: string, dir: string }[]) this._order = (query.order as { column: string; dir: string }[]).map((order) => {
.map(order => { return { column: parseInt(order.column, 10), dir: order.dir, }; }); const dir = order.dir.toUpperCase() === "DESC" ? "DESC" : "ASC";
this.columns = (query.columns as { data: string, name: string, searchable: string, orderable: string, search: any }[]) return { column: parseInt(order.column, 10), dir };
.map(column => { });
this.columns = (
query.columns as { data: string; name: string; searchable: string; orderable: string; search: { value: string; regex: string } }[]
).map((column) => {
return { return {
data: parseInt(column.data, 10), data: parseInt(column.data, 10),
name: column.name, name: column.name,
@ -82,8 +86,8 @@ export class DataTablesSsp {
}; };
}); });
this.search = { this.search = {
value: (query.search as any).value as string, value: (query.search as unknown)["value"] as string,
regex: (query.search as any).regex === "true", regex: (query.search as unknown)["regex"] === "true",
}; };
this.db = db; this.db = db;
@ -93,7 +97,7 @@ export class DataTablesSsp {
} }
private colSettingsToStr(colSettings: IColumnSettings) { private colSettingsToStr(colSettings: IColumnSettings) {
const db = colSettings.database ? ("`" + colSettings.database + "`.") : ""; const db = colSettings.database ? "`" + colSettings.database + "`." : "";
return `${db}\`${colSettings.table || this.table}\`.\`${colSettings.name}\``; return `${db}\`${colSettings.table || this.table}\`.\`${colSettings.name}\``;
} }
@ -130,8 +134,9 @@ export class DataTablesSsp {
private join() { private join() {
for (const join of this.joins) { for (const join of this.joins) {
const db2 = join.database2 ? "`" + join.database2 + "`." : ""; 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 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; return this;
@ -153,19 +158,16 @@ export class DataTablesSsp {
this.filterBindings.push(`%${this.search.value}%`); this.filterBindings.push(`%${this.search.value}%`);
} }
if (filterWheres.length > 0) { 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; return this;
} }
public sql(): string { public sql(): string {
const columns = [ const columns = [...this.columnSettings.map((c) => this.colSettingsToStr(c)), ...this.extraDataColumns];
...this.columnSettings.map(c => this.colSettingsToStr(c)),
...this.extraDataColumns,
];
return ` return `
SELECT ${columns.join(", ")} SELECT ${columns.join(", ")}
FROM ${this.table} FROM ${this.table}
@ -198,35 +200,32 @@ export class DataTablesSsp {
`; `;
} }
public async run(queryTimeout: number = 10_000): Promise<IResult> { public async run(queryTimeout = 10_000): Promise<IResult> {
this.limit() this.limit().order().join().filter();
.order()
.join()
.filter();
const bindings = [...this.filterBindings, ...this.customBindings]; const bindings = [...this.filterBindings, ...this.customBindings];
let [rows, fields] = await this.db.query({ let [rows] = await this.db.query({
sql: this.buildTotalCountSql(), sql: this.buildTotalCountSql(),
values: this.customBindings, values: this.customBindings,
timeout: queryTimeout, timeout: queryTimeout,
}); });
const recordsTotal = rows[0].count; const recordsTotal = rows[0].count;
[rows, fields] = await this.db.query({ [rows] = await this.db.query({
sql: this.buildFilteredCountSql(), sql: this.buildFilteredCountSql(),
values: bindings, values: bindings,
timeout: queryTimeout, timeout: queryTimeout,
}); });
const recordsFiltered = rows[0].count; const recordsFiltered = rows[0].count;
[rows, fields] = await this.db.query({ [rows] = await this.db.query({
sql: this.sql(), sql: this.sql(),
rowsAsArray: true, rowsAsArray: true,
values: bindings, values: bindings,
timeout: queryTimeout, timeout: queryTimeout,
}); });
rows = (rows as any[][]).map(row => { rows = (rows as RowDataPacket[]).map((row) => {
for (let i = 0; i < this.columnSettings.length; ++i) { for (let i = 0; i < this.columnSettings.length; ++i) {
const col = this.columnSettings[i]; const col = this.columnSettings[i];
if (col.formatter !== undefined) { if (col.formatter !== undefined) {
@ -240,7 +239,7 @@ export class DataTablesSsp {
recordsTotal, recordsTotal,
recordsFiltered, recordsFiltered,
draw: this.draw, draw: this.draw,
data: rows, data: rows as unknown[][],
}; };
} }

View file

@ -41,7 +41,16 @@ export class Utils {
return [1, 3, 4, 7, 11].includes(race) ? EFaction.Alliance : EFaction.Horde; 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; const padLength = padWithZeroes ? 2 : 0;
return { return {
icon: obj.emblemStyle.toString().padStart(padLength, "0"), icon: obj.emblemStyle.toString().padStart(padLength, "0"),
@ -51,4 +60,37 @@ export class Utils {
background: obj.background.toString().padStart(padLength, "0"), 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 ")})`;
}
} }

View 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,
};
}
}

View file

@ -1,10 +1,10 @@
import * as express from "express"; import * as express from "express";
import { RowDataPacket } from "mysql2/promise"; import { RowDataPacket } from "mysql2/promise";
import { Utils } from "../Utils";
import { Armory } from "../Armory"; import { Armory } from "../Armory";
import { IRealmConfig } from "../Config"; import { IRealmConfig } from "../Config";
import { IAchievement } from "../data/DbcReader"; import { IEmblem, Utils } from "../Utils";
import { IAchievement as IAchievementDbc } from "../data/DbcReader";
interface ICharacterData { interface ICharacterData {
guid: number; guid: number;
@ -27,8 +27,14 @@ interface IEquipmentData {
slot: number; slot: number;
itemEntry: number; itemEntry: number;
flags: number; flags: number;
enchantments: string; enchantments: string | number[];
randomPropertyId: number; randomPropertyId: number;
classId: number;
subclassId: number;
quality: number;
transmog?: number;
icon?: number;
gems?: number[];
} }
interface ICustomizationOption { interface ICustomizationOption {
@ -42,6 +48,30 @@ interface IMount {
icon: string; 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 ItemClassGem = 3;
const SpellMechanicMounted = 21; const SpellMechanicMounted = 21;
const RaceDisplayName = { const RaceDisplayName = {
@ -78,7 +108,7 @@ export class CharacterController {
private itemSocketBonuses: { [key: number]: number }; private itemSocketBonuses: { [key: number]: number };
private mountSpells: number[]; private mountSpells: number[];
private mountBySpellId: { [key: number]: IMount }; private mountBySpellId: { [key: number]: IMount };
private achievementById: { [key: number]: IAchievement }; private achievementById: { [key: number]: IAchievementDbc };
public constructor(armory: Armory) { public constructor(armory: Armory) {
this.armory = armory; this.armory = armory;
@ -88,7 +118,7 @@ export class CharacterController {
this.itemInventoryTypes = {}; this.itemInventoryTypes = {};
const itemsRetail = await this.armory.dbc.itemRetail().toArray(); const itemsRetail = await this.armory.dbc.itemRetail().toArray();
for await (const item of this.armory.dbc.item()) { 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) { if (retailItem !== undefined) {
this.itemInventoryTypes[item.id] = retailItem.inventoryType; this.itemInventoryTypes[item.id] = retailItem.inventoryType;
} }
@ -107,7 +137,7 @@ export class CharacterController {
} }
this.gemItems = {}; 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; this.gemItems[row.id] = true;
} }
@ -117,7 +147,7 @@ export class CharacterController {
} }
this.itemSocketBonuses = {}; 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", sql: "SELECT entry, socketBonus FROM item_template WHERE socketBonus <> 0",
timeout: this.armory.config.dbQueryTimeout, timeout: this.armory.config.dbQueryTimeout,
}); });
@ -125,16 +155,17 @@ export class CharacterController {
this.itemSocketBonuses[row.entry] = row.socketBonus; this.itemSocketBonuses[row.entry] = row.socketBonus;
} }
const mountSpells = await this.armory.dbc.spell() const mountSpells = await this.armory.dbc
.filter(m => m.mechanic === SpellMechanicMounted) .spell()
.filter((m) => m.mechanic === SpellMechanicMounted)
.toArray(); .toArray();
this.mountSpells = mountSpells.map(spell => spell.id); this.mountSpells = mountSpells.map((spell) => spell.id);
this.mountBySpellId = {}; this.mountBySpellId = {};
for (const spell of mountSpells) { for (const spell of mountSpells) {
const mount = await this.armory.dbc.mount().find(m => m.sourceSpellId === spell.id); 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 icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === spell.spellIconId);
if (mount !== undefined) { 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) { if (display !== undefined) {
this.mountBySpellId[spell.id] = { this.mountBySpellId[spell.id] = {
creatureDisplayId: display.creatureDisplayInfoId, creatureDisplayId: display.creatureDisplayInfoId,
@ -169,23 +200,27 @@ export class CharacterController {
const equipmentData = await this.getEquipmentData(realmName, charData.guid); const equipmentData = await this.getEquipmentData(realmName, charData.guid);
const customization = this.getCustomizationOptions(charData); const customization = this.getCustomizationOptions(charData);
const equipment = equipmentData.map(row => { const equipment = equipmentData.map((row) => {
(row as any).icon = this.itemIcons[row.itemEntry]; row.icon = this.itemIcons[row.itemEntry];
(row as any).gems = this.getGemsFromEnchantments(row.enchantments); row.gems = this.getGemsFromEnchantments(row.enchantments as string);
(row as any).enchantments = this.filterEnchantments(row.itemEntry, row.enchantments); row.enchantments = this.filterEnchantments(row.itemEntry, row.enchantments as string);
return row; return row;
}); });
const mounts = await this.getMounts(realmName, charData.guid); 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", { res.render("character.hbs", {
title: `Armory - ${charData.name}`, title: `Armory - ${charData.name}`,
...this.makeSharedDataObject(realm, charData), ...this.makeSharedDataObject(realm, charData),
contentPath: this.armory.config.useZamCdn ? "https://wow.zamimg.com/modelviewer/live/" : this.armory.config.websiteRoot + "/data/",
data: { data: {
race: charData.race, race: charData.race,
gender: charData.gender, gender: charData.gender,
class: charData.class, class: charData.class,
flags: charData.playerFlags, flags: charData.playerFlags,
characterModelItems: await this.getModelViewerItems(equipmentData, charData.class), characterModelItems,
characterModelTransmogs: transmogs,
customizationOptions: customization, customizationOptions: customization,
equipment, equipment,
mounts, mounts,
@ -262,7 +297,7 @@ export class CharacterController {
res.json({ res.json({
categories: await this.armory.dbc.achievementCategory().toArray(), 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", { res.render("character-pvp.hbs", {
title: `Armory - ${charData.name} - PvP`, title: `Armory - ${charData.name} - PvP`,
realm: realm.name,
...this.makeSharedDataObject(realm, charData), ...this.makeSharedDataObject(realm, charData),
faction: Utils.getFactionFromRaceId(charData.race), faction: Utils.getFactionFromRaceId(charData.race),
kills: await this.getPvpKills(realm.name, charData.guid), 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> { private async getCharacterData(realm: IRealmConfig, character: string | number): Promise<ICharacterData> {
const where = typeof character === "string" ? "LOWER(`characters`.`name`) = LOWER(?)" : "`characters`.`guid` = ?"; 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: ` 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\` FROM \`characters\`
LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\` LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\`
LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\` 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
${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], values: [character, this.armory.config.hideGameMasters ? 1 : 0],
timeout: this.armory.config.dbQueryTimeout, timeout: this.armory.config.dbQueryTimeout,
@ -328,21 +380,50 @@ export class CharacterController {
} }
private async getEquipmentData(realm: string, charGuid: number): Promise<IEquipmentData[]> { 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: ` 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 FROM character_inventory
JOIN item_instance ON item_instance.guid = character_inventory.item 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) 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], values: [charGuid],
timeout: this.armory.config.dbQueryTimeout, 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[]> { 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: ` sql: `
SELECT spell SELECT spell
FROM character_spell FROM character_spell
@ -352,34 +433,52 @@ export class CharacterController {
timeout: this.armory.config.dbQueryTimeout, timeout: this.armory.config.dbQueryTimeout,
}); });
return (rows as RowDataPacket[]) return (rows as RowDataPacket[]).map((row) => this.mountBySpellId[row.spell]).filter((m) => m !== undefined);
.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) { if (charClass !== 3) {
// Keep ranged weapon only if the character is a hunter // 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. const visibleEquipment = equipmentData.filter(
filter(item => (item) =>
[0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18].includes(item.slot) && // visible slots [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[][] = []; const items: number[][] = [];
for (const equipment of visibleEquipment) { 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) { if (modifiedAppearance === undefined) {
continue; 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) { if (appearance === undefined) {
continue; 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; return items;
@ -389,52 +488,53 @@ export class CharacterController {
return enchantments return enchantments
.trim() .trim()
.split(" ") .split(" ")
.map(enchant => parseInt(enchant)) .map((enchant) => parseInt(enchant))
.filter(enchant => enchant !== 0); .filter((enchant) => enchant !== 0);
} }
private getGemsFromEnchantments(enchantments: string): number[] { private getGemsFromEnchantments(enchantments: string): number[] {
return this.parseEnchantmentsString(enchantments) return this.parseEnchantmentsString(enchantments)
.filter(enchant => enchant in this.enchantSrcItems && this.enchantSrcItems[enchant] in this.gemItems) .filter((enchant) => enchant in this.enchantSrcItems && this.enchantSrcItems[enchant] in this.gemItems)
.map(enchant => this.enchantSrcItems[enchant]); .map((enchant) => this.enchantSrcItems[enchant]);
} }
private filterEnchantments(item: number, enchantments: string): number[] { private filterEnchantments(item: number, enchantments: string): number[] {
const socketBonus = this.itemSocketBonuses[item]; const socketBonus = this.itemSocketBonuses[item];
return this.parseEnchantmentsString(enchantments) return this.parseEnchantmentsString(enchantments).filter(
.filter(enchant => enchant in this.enchantSrcItems && !(this.enchantSrcItems[enchant] in this.gemItems) && enchant !== socketBonus); (enchant) => enchant in this.enchantSrcItems && !(this.enchantSrcItems[enchant] in this.gemItems) && enchant !== socketBonus,
);
} }
private getCustomizationOptions(charData: ICharacterData): ICustomizationOption[] { private getCustomizationOptions(charData: ICharacterData): ICustomizationOption[] {
const data = this.armory.characterCustomization.getCharacterCustomizationData(charData.race, charData.gender); const data = this.armory.characterCustomization.getCharacterCustomizationData(charData.race, charData.gender);
const options = []; const options = [];
const setOptionByChoiceIndex = (optionName: string, choiceIndex: number) => { 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) { if (option !== undefined) {
const choice = option.Choices.find(choice => choice.OrderIndex === choiceIndex); const choice = option.Choices.find((choice) => choice.OrderIndex === choiceIndex);
if (choice !== undefined) { if (choice !== undefined) {
options.push({ optionId: option.Id, choiceId: choice.Id }); options.push({ optionId: option.Id, choiceId: choice.Id });
} }
} }
}; };
const setOptionByChoiceName = (optionName: string, choiceName: string) => { 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) { if (option !== undefined) {
const choice = option.Choices.find(ch => ch.Name === choiceName); const choice = option.Choices.find((ch) => ch.Name === choiceName);
if (choice !== undefined) { if (choice !== undefined) {
options.push({ optionId: option.Id, choiceId: choice.Id }); options.push({ optionId: option.Id, choiceId: choice.Id });
} }
} }
}; };
const setOptionByChoiceId = (optionName: string, choiceId: number) => { 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) { if (option !== undefined) {
options.push({ optionId: option.Id, choiceId: choiceId }); options.push({ optionId: option.Id, choiceId: choiceId });
} }
}; };
const optionMapping = { const optionMapping = {
"Face": charData.face, Face: charData.face,
"Skin Color": charData.skin, "Skin Color": charData.skin,
"Hair Style": charData.hairStyle, "Hair Style": charData.hairStyle,
"Hair Color": charData.hairColor, "Hair Color": charData.hairColor,
@ -447,19 +547,54 @@ export class CharacterController {
switch (charData.race) { switch (charData.race) {
case 1: // Human case 1: // Human
if (charData.gender === 0) { 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(
setOptionByChoiceName("Beard", { 0: "Short", 1: "Chin Puff", 2: "Soul Patch", 3: "Goatee", 4: "Goatee", 5: "None", 6: "Goatee", 7: "None", 8: "None" }[charData.facialStyle]); "Mustache",
setOptionByChoiceName("Sideburns", { 0: "Medium", 1: "None", 2: "None", 3: "Medium", 4: "Long", 5: "Long", 6: "None", 8: "None", 7: "None" }[charData.facialStyle]); { 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("Eyebrows", "Natural");
setOptionByChoiceName("Face Shape", "Narrow"); 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 { } else {
setOptionByChoiceIndex("Piercings", charData.facialStyle); setOptionByChoiceIndex("Piercings", charData.facialStyle);
setOptionByChoiceName("Eyebrows", "Natural"); setOptionByChoiceName("Eyebrows", "Natural");
setOptionByChoiceName("Face Shape", "Narrow"); setOptionByChoiceName("Face Shape", "Narrow");
setOptionByChoiceName("Makeup", "None"); setOptionByChoiceName("Makeup", "None");
setOptionByChoiceName("Necklace", "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) { if (charData.class === 6) {
// Death Knight // Death Knight
@ -471,14 +606,32 @@ export class CharacterController {
setOptionByChoiceIndex("Tattoo Color", 0); setOptionByChoiceIndex("Tattoo Color", 0);
setOptionByChoiceIndex("Eyebrows", 0); setOptionByChoiceIndex("Eyebrows", 0);
if (charData.gender === 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); setOptionByChoiceIndex("Beard", charData.facialStyle);
setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Earrings", "None");
setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceName("Nose Ring", "None");
setOptionByChoiceIndex("Eye Color", 0); // TODO setOptionByChoiceIndex("Eye Color", 0); // TODO
} else { } else {
setOptionByChoiceIndex("Earrings", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 0, 5: 4 }[charData.facialStyle]); 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 setOptionByChoiceIndex("Eye Color", 0); // TODO
} }
if (charData.class === 6) { if (charData.class === 6) {
@ -508,7 +661,10 @@ export class CharacterController {
setOptionByChoiceName("Ears", "Thin"); setOptionByChoiceName("Ears", "Thin");
setOptionByChoiceName("Scars", "None"); setOptionByChoiceName("Scars", "None");
if (charData.gender === 0) { 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("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("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]); 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("Horn Decoration", "None");
setOptionByChoiceName("Tail", charData.gender === 0 ? "Long" : "Short"); setOptionByChoiceName("Tail", charData.gender === 0 ? "Long" : "Short");
if (charData.gender === 0) { 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(
setOptionByChoiceName("Tendrils", { 0: "None", 1: "Splayed", 2: "Double", 3: "Fanned", 4: "Single", 5: "Paired", 6: "Uniform", 7: "Twin" }[charData.facialStyle]); "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 { } 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) { if (charData.class === 6) {
// Death Knight // Death Knight
@ -557,8 +724,28 @@ export class CharacterController {
setOptionByChoiceName("War Paint", "None"); setOptionByChoiceName("War Paint", "None");
setOptionByChoiceName("War Paint Color", "None"); setOptionByChoiceName("War Paint Color", "None");
if (charData.gender === 0) { 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(
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]); "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("Earrings", "None");
setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceName("Nose Ring", "None");
setOptionByChoiceName("Tusks", "Natural"); setOptionByChoiceName("Tusks", "Natural");
@ -578,13 +765,71 @@ export class CharacterController {
case 5: // Undead case 5: // Undead
setOptionByChoiceName("Skin Type", "Bony"); setOptionByChoiceName("Skin Type", "Bony");
if (charData.gender === 0) { 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]); setOptionByChoiceName(
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]); "Jaw Features",
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]); {
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 { } else {
setOptionByChoiceName("Face Features", { 0: "None", 1: "None", 2: "Strapped", 3: "Rotting", 4: "None", 5: "None", 6: "None", 7: "Putrid" }[charData.facialStyle]); setOptionByChoiceName(
setOptionByChoiceName("Jaw Features", { 0: "Intact", 1: "Stitched", 2: "Intact", 3: "Intact", 4: "Bonejawed", 5: "Toothy", 6: "Cheeky", 7: "Intact" }[charData.facialStyle]); "Face Features",
setOptionByChoiceId("Eye Color", { 0: 5337, 1: 5337, 2: 6305, 3: 5337, 4: 5337, 5: 6305, 6: 5337, 7: 5337 }[charData.facialStyle]); { 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) { if (charData.class === 6) {
// Death Knight // Death Knight
@ -603,9 +848,18 @@ export class CharacterController {
setOptionByChoiceName("Body Paint", "None"); setOptionByChoiceName("Body Paint", "None");
setOptionByChoiceIndex("Paint Color", 0); setOptionByChoiceIndex("Paint Color", 0);
if (charData.gender === 0) { if (charData.gender === 0) {
setOptionByChoiceName("Hair", { 0: "Mane", 1: "Braids", 2: "Chops", 3: "Sideburns", 4: "Mane", 5: "Wrapped", 6: "Braids" }[charData.facialStyle]); setOptionByChoiceName(
setOptionByChoiceName("Facial Hair", { 0: "Clean", 1: "Braid", 2: "Beard", 3: "Wrapped", 4: "Curtain", 5: "Clean", 6: "Split" }[charData.facialStyle]); "Hair",
setOptionByChoiceName("Nose Ring", { 0: "None", 1: "Small", 2: "Open", 3: "None", 4: "None", 5: "Bead", 6: "Open" }[charData.facialStyle]); { 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 setOptionByChoiceIndex("Eye Color", 0); // TODO
} else { } else {
setOptionByChoiceIndex("Hair", charData.facialStyle); setOptionByChoiceIndex("Hair", charData.facialStyle);
@ -623,8 +877,38 @@ export class CharacterController {
setOptionByChoiceName("Body Paint Color", "None"); setOptionByChoiceName("Body Paint Color", "None");
setOptionByChoiceName("Piercing", "None"); setOptionByChoiceName("Piercing", "None");
if (charData.gender === 0) { 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(
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]); "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); setOptionByChoiceIndex("Face Paint Color", charData.hairColor + 1);
setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Earrings", "None");
setOptionByChoiceIndex("Eye Color", 0); // TODO setOptionByChoiceIndex("Eye Color", 0); // TODO
@ -677,7 +961,7 @@ export class CharacterController {
} }
private async getTalents(realm: string, character: number): Promise<number[][]> { 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: ` sql: `
SELECT spell, specMask SELECT spell, specMask
FROM character_talent FROM character_talent
@ -701,16 +985,18 @@ export class CharacterController {
} }
private async getTalentTrees(classId: number) { private async getTalentTrees(classId: number) {
const items = await this.armory.dbc.talentTab() const items = await this.armory.dbc
.filter(tab => tab.classMask === Math.pow(2, classId - 1)) .talentTab()
.map(async tab => { .filter((tab) => tab.classMask === Math.pow(2, classId - 1))
const icon = await this.armory.dbc.spellIcon().find(icon => icon.id === tab.spellIconId); .map(async (tab) => {
const spells = await this.armory.dbc.talent() const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === tab.spellIconId);
.filter(row => row.tabId === tab.id) const spells = await this.armory.dbc
.map(async row => { .talent()
const spell = await this.armory.dbc.spell().find(spell => spell.id === row.spellRank0); .filter((row) => row.tabId === tab.id)
const icon = await this.armory.dbc.spellIcon().find(icon => icon.id === spell?.spellIconId); .map(async (row) => {
return { ...row, icon: this.processSpellIconTexture(icon?.textureFilename ?? ""), }; 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(); .toArray();
return { return {
@ -724,15 +1010,11 @@ export class CharacterController {
} }
private processSpellIconTexture(texturePath: string): string { private processSpellIconTexture(texturePath: string): string {
return texturePath return texturePath.toLowerCase().replace("interface\\icons\\", "").replace("interface\\spellbook\\", "").replace(/\.$/, "");
.toLowerCase()
.replace("interface\\icons\\", "")
.replace("interface\\spellbook\\", "")
.replace(/\.$/, "");
} }
private async getGlyphs(realm: string, character: number): Promise<any[][]> { private async getGlyphs(realm: string, character: number): Promise<number[][]> {
const [rows, fields] = await this.armory.getCharactersDb(realm).query({ const [rows] = await this.armory.getCharactersDb(realm).query({
sql: ` sql: `
SELECT guid, talentGroup, glyph1, glyph2, glyph3, glyph4, glyph5, glyph6 SELECT guid, talentGroup, glyph1, glyph2, glyph3, glyph4, glyph5, glyph6
FROM character_glyphs FROM character_glyphs
@ -742,11 +1024,11 @@ export class CharacterController {
timeout: this.armory.config.dbQueryTimeout, timeout: this.armory.config.dbQueryTimeout,
}); });
const glyphs = [[], []]; const glyphs: number[][] = [[], []];
for (const row of rows as RowDataPacket[]) { 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) { 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) { if (glyph === undefined) {
continue; continue;
} }
@ -757,11 +1039,15 @@ export class CharacterController {
return glyphs; return glyphs;
} }
private async getAchievements(realm: string, charData: ICharacterData): Promise<{ achievements: any[]; earned: { [key: number]: any }; }> { private async getAchievements(
const promises = await this.armory.dbc.achievement() realm: string,
.filter(ach => ach.faction === -1 || ach.faction === Utils.getFactionFromRaceId(charData.race)) 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) => { .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 { return {
id: ach.id, id: ach.id,
category: ach.category, category: ach.category,
@ -774,7 +1060,7 @@ export class CharacterController {
.toArray(); .toArray();
const achievements = await Promise.all(promises); const achievements = await Promise.all(promises);
const [rows, fields] = await this.armory.getCharactersDb(realm).query({ const [rows] = await this.armory.getCharactersDb(realm).query({
sql: ` sql: `
SELECT achievement, date SELECT achievement, date
FROM character_achievement FROM character_achievement
@ -783,11 +1069,9 @@ export class CharacterController {
values: [charData.guid], values: [charData.guid],
timeout: this.armory.config.dbQueryTimeout, timeout: this.armory.config.dbQueryTimeout,
}); });
const earned = {}; const earned: { [key: number]: number } = {};
for (const row of rows as RowDataPacket[]) { for (const row of rows as RowDataPacket[]) {
earned[row.achievement] = { earned[row.achievement] = row.date;
date: row.date,
};
} }
return { return {
@ -796,8 +1080,8 @@ export class CharacterController {
}; };
} }
private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number, today: number, yesterday: number }> { private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number; today: number; yesterday: number }> {
const [rows, fields] = await this.armory.getCharactersDb(realm).query({ const [rows] = await this.armory.getCharactersDb(realm).query({
sql: ` sql: `
SELECT totalKills, todayKills, yesterdayKills SELECT totalKills, todayKills, yesterdayKills
FROM characters FROM characters
@ -815,8 +1099,8 @@ export class CharacterController {
}; };
} }
private async getArenaTeams(realm: string, charGuid: number): Promise<any[]> { private async getArenaTeams(realm: string, charGuid: number): Promise<IArenaTeam[]> {
const [rows, fields] = await this.armory.getCharactersDb(realm).query({ const [rows] = await this.armory.getCharactersDb(realm).query({
sql: ` sql: `
SELECT SELECT
arena_team.arenaTeamId AS id, arena_team.name, arena_team.type, arena_team.rating, arena_team.seasonWins, arena_team.seasonGames, 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, timeout: this.armory.config.dbQueryTimeout,
}); });
return (rows as RowDataPacket[]).map(row => { return (rows as IArenaTeam[]).map((row) => {
row.emblem = Utils.makeEmblemObject(row, false); row.emblem = Utils.makeEmblemObject(row, false);
return row; return row;
}); });

View file

@ -2,16 +2,25 @@ import * as express from "express";
import { encode } from "html-entities"; import { encode } from "html-entities";
import { RowDataPacket } from "mysql2/promise"; import { RowDataPacket } from "mysql2/promise";
import { Utils } from "../Utils";
import { Armory } from "../Armory"; import { Armory } from "../Armory";
import { IRealmConfig } from "../Config"; import { IRealmConfig } from "../Config";
import { DataTablesSsp } from "../DataTablesSsp"; import { DataTablesSsp } from "../DataTablesSsp";
import { Utils, IEmblem, EFaction } from "../Utils";
interface IGuildRank { interface IGuildRank {
id: number; id: number;
name: string; name: string;
} }
interface IGuildData {
id: number;
name: string;
leader: string;
faction: EFaction;
emblem: IEmblem;
membersCount: number;
}
export class GuildController { export class GuildController {
private armory: Armory; private armory: Armory;
@ -64,37 +73,59 @@ export class GuildController {
{ name: "name", table: "characters", collation: `${charSet}_general_ci` }, { name: "name", table: "characters", collation: `${charSet}_general_ci` },
{ name: "rank" }, { name: "rank" },
{ name: "level", table: "characters" }, { 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: "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 = [ ssp.joins = [{ table1: "guild_member", column1: "guid", table2: "characters", column2: "guid", kind: "LEFT" }];
{ table1: "guild_member", column1: "guid", table2: "characters", column2: "guid", kind: "LEFT" },
];
ssp.extraDataColumns = ["`characters`.`gender`"]; ssp.extraDataColumns = ["`characters`.`gender`"];
if (this.armory.config.hideGameMasters) { if (this.armory.config.hideGameMasters) {
ssp.joins.push({ table1: "characters", column1: "account", table2: "account_access", column2: "id", database2: realm.authDatabase, kind: "LEFT" }); ssp.joins.push({
ssp = ssp.where(`\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`); 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 const botFilter = this.armory.config.hideBotAccounts
.where("`guildid` = ?", guildId) ? Utils.botAccountFilter(`\`${realm.authDatabase}\`.\`account\`.\`username\``, this.armory.config.botAccountPatterns)
.where("`deleteInfos_Account` IS NULL") : "";
.run(this.armory.config.dbQueryTimeout); 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); const ranks = await this.getGuildRanks(realm, guildId);
(result as any).ranks = {}; const ranksObj: { [key: number]: string } = {};
for (const rank of ranks) { 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); const db = this.armory.getCharactersDb(realm.name);
let [rows, fields] = await db.query({ let [rows] = await db.query({
sql: ` sql: `
SELECT guildid, name, leaderguid, EmblemStyle AS emblemStyle, EmblemColor AS emblemColor, BorderStyle AS borderStyle, BorderColor AS borderColor, BackgroundColor AS background SELECT guildid, name, leaderguid, EmblemStyle AS emblemStyle, EmblemColor AS emblemColor, BorderStyle AS borderStyle, BorderColor AS borderColor, BackgroundColor AS background
FROM guild WHERE name = ? FROM guild WHERE name = ?
@ -107,7 +138,7 @@ export class GuildController {
} }
const guild = rows[0]; const guild = rows[0];
[rows, fields] = await db.query({ [rows] = await db.query({
sql: ` sql: `
SELECT name, race FROM characters SELECT name, race FROM characters
WHERE guid = ? WHERE guid = ?
@ -117,7 +148,7 @@ export class GuildController {
}); });
const leader = rows[0]; const leader = rows[0];
[rows, fields] = await db.query({ [rows] = await db.query({
sql: ` sql: `
SELECT COUNT(guid) AS \`count\` FROM guild_member SELECT COUNT(guid) AS \`count\` FROM guild_member
WHERE guildid = ? WHERE guildid = ?
@ -139,7 +170,7 @@ export class GuildController {
private async getGuildId(realm: IRealmConfig, name: string): Promise<number> { private async getGuildId(realm: IRealmConfig, name: string): Promise<number> {
const db = this.armory.getCharactersDb(realm.name); const db = this.armory.getCharactersDb(realm.name);
const [rows, fields] = await db.query({ const [rows] = await db.query({
sql: ` sql: `
SELECT guildid SELECT guildid
FROM guild WHERE name = ? FROM guild WHERE name = ?
@ -156,7 +187,7 @@ export class GuildController {
private async guildExists(realm: IRealmConfig, id: number): Promise<boolean> { private async guildExists(realm: IRealmConfig, id: number): Promise<boolean> {
const db = this.armory.getCharactersDb(realm.name); const db = this.armory.getCharactersDb(realm.name);
const [rows, fields] = await db.query({ const [rows] = await db.query({
sql: ` sql: `
SELECT guildid SELECT guildid
FROM guild WHERE guildid FROM guild WHERE guildid
@ -170,7 +201,7 @@ export class GuildController {
private async getGuildRanks(realm: IRealmConfig, id: number): Promise<IGuildRank[]> { private async getGuildRanks(realm: IRealmConfig, id: number): Promise<IGuildRank[]> {
const db = this.armory.getCharactersDb(realm.name); const db = this.armory.getCharactersDb(realm.name);
const [rows, fields] = await db.query({ const [rows] = await db.query({
sql: ` sql: `
SELECT rid AS id, rname AS name SELECT rid AS id, rname AS name
FROM guild_rank WHERE guildid = ? FROM guild_rank WHERE guildid = ?

View file

@ -14,15 +14,13 @@ export class IndexController {
public async index(req: express.Request, res: express.Response): Promise<void> { public async index(req: express.Request, res: express.Response): Promise<void> {
res.render("index.hbs", { res.render("index.hbs", {
title: "Armory", 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> { public async search(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.query.realm as string; const realmName = req.query.realm as string;
const realm = realmName === undefined ? const realm = realmName === undefined ? this.armory.config.realms[0] : this.armory.config.realms.find((r) => r.name === realmName);
this.armory.config.realms[0] :
this.armory.config.realms.find(r => r.name === realmName);
if (realm === undefined) { if (realm === undefined) {
return next(400); return next(400);
} }
@ -34,9 +32,9 @@ export class IndexController {
{ name: "name", collation: `${charSet}_general_ci` }, { name: "name", collation: `${charSet}_general_ci` },
{ table: "guild", name: "name" }, { table: "guild", name: "name" },
{ name: "level" }, { 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: "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 = [ ssp.joins = [
{ table1: "characters", column1: "guid", table2: "guild_member", column2: "guid", kind: "LEFT" }, { table1: "characters", column1: "guid", table2: "guild_member", column2: "guid", kind: "LEFT" },
@ -45,15 +43,39 @@ export class IndexController {
ssp.extraDataColumns = ["`characters`.`gender`"]; ssp.extraDataColumns = ["`characters`.`gender`"];
if (this.armory.config.hideGameMasters) { if (this.armory.config.hideGameMasters) {
ssp.joins.push({ table1: "characters", column1: "account", table2: "account_access", column2: "id", database2: realm.authDatabase, kind: "LEFT" }); ssp.joins.push({
ssp = ssp.where(`\`account_access\`.\`id\` IS NULL OR \`account_access\`.\`RealmID\` NOT IN (-1, ${realm.realmId}) OR \`account_access\`.\`gmlevel\` = 0`); 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 const botFilter = this.armory.config.hideBotAccounts
.where("`deleteInfos_Account` IS NULL") ? Utils.botAccountFilter(`\`${realm.authDatabase}\`.\`account\`.\`username\``, this.armory.config.botAccountPatterns)
.run(this.armory.config.dbQueryTimeout); : "";
(result as any).realm = realm.name; 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,
});
} }
} }

View file

@ -3,7 +3,7 @@ const fsp = fs.promises;
import * as path from "path"; import * as path from "path";
export class CharacterCustomization { export class CharacterCustomization {
private data: { [key: number]: { [key: number]: any } }; private data: { [key: number]: { [key: number]: unknown } };
public async loadData(): Promise<void> { public async loadData(): Promise<void> {
this.data = {}; 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]; return this.data[race][gender];
} }
} }

View file

@ -28,6 +28,7 @@ export interface IAchievementCategory {
export interface IItemDbc { export interface IItemDbc {
id: number; id: number;
classId: number; classId: number;
subclassId: number;
displayInfoId: number; displayInfoId: number;
inventoryType: number; inventoryType: number;
} }
@ -147,11 +148,13 @@ class AsyncGenWrapper<T> implements IAsyncGeneratorWithArrayMethods<T> {
} }
public static from<T>(array: T[]): AsyncGenWrapper<T> { public static from<T>(array: T[]): AsyncGenWrapper<T> {
return new AsyncGenWrapper<T>(async function* () { return new AsyncGenWrapper<T>(
(async function* () {
for (const x of array) { for (const x of array) {
yield x; yield x;
} }
}()); })(),
);
} }
public async *[Symbol.asyncIterator](): AsyncGenerator<T> { public async *[Symbol.asyncIterator](): AsyncGenerator<T> {
@ -216,11 +219,15 @@ class DbcReader<T> {
return; return;
} }
const headerCols = headerLine.value const headerCols = headerLine.value.map((header) => camelCase(header).replace(/[[\]]/g, ""));
.map(header => camelCase(header).replace(/[\[\]]/g, ""));
for await (const arr of itr) { 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 = {}; const row = {};
headerCols.forEach((header, headerIdx) => { headerCols.forEach((header, headerIdx) => {
if (this.fields.length === 0 || this.fields.includes(header)) { if (this.fields.length === 0 || this.fields.includes(header)) {
@ -241,7 +248,8 @@ class DbcReader<T> {
const str = chunk.toString(); const str = chunk.toString();
// Iterate over each character, keep track of current column (of the returned array) // Iterate over each character, keep track of current column (of the returned array)
for (let c = 0; c < str.length; ++c) { 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)) { if (!(col in arr)) {
arr[col] = ""; // Create a new column (start with empty string) if necessary 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 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; ++col;
continue; continue;
} }
// If it's a newline (CRLF) and we're not in a quoted field, skip the next character // 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 // 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; yield arr;
arr.length = 0; // Clear the row arr.length = 0; // Clear the row
col = 0; 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, // 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 // 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; yield arr;
arr.length = 0; // Clear the row arr.length = 0; // Clear the row
col = 0; col = 0;
@ -316,7 +324,7 @@ const dbcFields = {
achievement: ["id", "faction", "titleLang0", "descriptionLang0", "category", "points", "flags", "iconId"], achievement: ["id", "faction", "titleLang0", "descriptionLang0", "category", "points", "flags", "iconId"],
achievementCategory: ["id", "parent", "nameLang0"], achievementCategory: ["id", "parent", "nameLang0"],
glyphProperties: ["id", "spellId"], glyphProperties: ["id", "spellId"],
item: ["id", "classId", "displayInfoId", "inventoryType"], item: ["id", "classId", "subclassId", "displayInfoId", "inventoryType"],
itemRetail: ["id", "inventoryType"], itemRetail: ["id", "inventoryType"],
itemAppearance: ["id", "itemDisplayInfoId"], itemAppearance: ["id", "itemDisplayInfoId"],
itemModifiedAppearance: ["id", "itemId", "itemAppearanceId"], itemModifiedAppearance: ["id", "itemId", "itemAppearanceId"],
@ -326,7 +334,19 @@ const dbcFields = {
spell: ["id", "mechanic", "spellIconId"], spell: ["id", "mechanic", "spellIconId"],
spellItemEnchantment: ["id", "srcItemId"], spellItemEnchantment: ["id", "srcItemId"],
spellIcon: ["id", "textureFilename"], 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"], talentTab: ["id", "nameLang0", "spellIconId", "classMask"],
}; };
@ -349,17 +369,26 @@ export class DbcManager {
public async loadAllFiles(): Promise<void> { public async loadAllFiles(): Promise<void> {
this._achievement = await this.read<IAchievement>(DbcFiles.achievement, dbcFields.achievement).toArray(); 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._glyphProperties = await this.read<IGlyphProperties>(DbcFiles.glyphProperties, dbcFields.glyphProperties).toArray();
this._item = await this.read<IItemDbc>(DbcFiles.item, dbcFields.item).toArray(); this._item = await this.read<IItemDbc>(DbcFiles.item, dbcFields.item).toArray();
this._itemRetail = await this.read<IItemRetailDbc>(DbcFiles.itemRetail, dbcFields.itemRetail).toArray(); this._itemRetail = await this.read<IItemRetailDbc>(DbcFiles.itemRetail, dbcFields.itemRetail).toArray();
this._itemAppearance = await this.read<IItemAppearanceDbc>(DbcFiles.itemAppearance, dbcFields.itemAppearance).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._itemDisplayInfo = await this.read<IItemDisplayInfoDbc>(DbcFiles.itemDisplayInfo, dbcFields.itemDisplayInfo).toArray();
this._mount = await this.read<IMountDbc>(DbcFiles.mount, dbcFields.mount).toArray(); this._mount = await this.read<IMountDbc>(DbcFiles.mount, dbcFields.mount).toArray();
this._mountDisplay = await this.read<IMountXDisplayDbc>(DbcFiles.mountDisplay, dbcFields.mountDisplay).toArray(); this._mountDisplay = await this.read<IMountXDisplayDbc>(DbcFiles.mountDisplay, dbcFields.mountDisplay).toArray();
this._spell = await this.read<ISpellDbc>(DbcFiles.spell, dbcFields.spell).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._spellIcon = await this.read<ISpellIcon>(DbcFiles.spellIcon, dbcFields.spellIcon).toArray();
this._talent = await this.read<ITalent>(DbcFiles.talent, dbcFields.talent).toArray(); this._talent = await this.read<ITalent>(DbcFiles.talent, dbcFields.talent).toArray();
this._talentTab = await this.read<ITalentTab>(DbcFiles.talentTab, dbcFields.talentTab).toArray(); this._talentTab = await this.read<ITalentTab>(DbcFiles.talentTab, dbcFields.talentTab).toArray();

View file

@ -1,9 +1,9 @@
import "dotenv/config"; import "dotenv/config";
import "source-map-support/register";
import { Armory } from "./Armory"; import { Armory } from "./Armory";
async function main(): Promise<void> { async function main(): Promise<void> {
require("source-map-support").install();
const armory = new Armory(); const armory = new Armory();
await armory.start(); await armory.start();
} }

2
src/index.d.ts vendored
View file

@ -1,4 +1,4 @@
declare module Express { declare namespace Express {
export interface Request { export interface Request {
id: string; id: string;
} }

View file

@ -3,20 +3,30 @@ const fsp = fs.promises;
import * as path from "path"; import * as path from "path";
import * as pako from "pako"; import * as pako from "pako";
import fetch from "node-fetch";
import * as mkdirp from "mkdirp"; import * as mkdirp from "mkdirp";
import * as glob from "glob-promise"; import * as glob from "glob";
import { Response } from "node-fetch"; import "source-map-support/register";
import * as prettyMs from "pretty-ms"; import * as prettyMs from "pretty-ms";
import * as cliProgress from "cli-progress"; import * as cliProgress from "cli-progress";
import fetch, { Response } from "node-fetch";
import promisepool = require("@supercharge/promise-pool"); import promisepool = require("@supercharge/promise-pool");
import { DbcManager, IItemAppearanceDbc, IItemModifiedAppearanceDbc, IMountDbc, IMountXDisplayDbc } from "../armory/data/DbcReader"; import { DbcManager, IItemAppearanceDbc, IItemModifiedAppearanceDbc, IMountDbc, IMountXDisplayDbc } from "../armory/data/DbcReader";
require("source-map-support").install();
const baseUrl = "https://wow.zamimg.com/modelviewer/live"; 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 { class Stopwatch {
private startTime: number; private startTime: number;
@ -54,9 +64,12 @@ class Progress {
} }
private createProgressBar(text: string, total: number): cliProgress.SingleBar { private createProgressBar(text: string, total: number): cliProgress.SingleBar {
const progress = new cliProgress.SingleBar({ const progress = new cliProgress.SingleBar(
{
format: `${text} {bar} {percentage}% ({value} / {total})`, format: `${text} {bar} {percentage}% ({value} / {total})`,
}, cliProgress.Presets.shades_classic); },
cliProgress.Presets.shades_classic,
);
progress.start(total, 0); progress.start(total, 0);
return progress; return progress;
} }
@ -89,11 +102,10 @@ const modelsDownloadQueue = new Set<number>();
const texturesDownloadQueue = new Set<number>(); const texturesDownloadQueue = new Set<number>();
const bonesDownloadQueue = 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 dataDir = path.join(process.cwd(), "data");
const fullPath = `${dir}/${file}`; const fullPath = `${dir}/${file}`;
try {
const res = await fetch(`${baseUrl}/${fullPath}`); const res = await fetch(`${baseUrl}/${fullPath}`);
if (res.status !== 200) { if (res.status !== 200) {
throw new HttpRequestError(res); throw new HttpRequestError(res);
@ -115,28 +127,21 @@ async function download(dir: string, file: string): Promise<string | any> {
return fileStream.path.toString(); return fileStream.path.toString();
} }
} catch (err) {
throw err;
}
} }
function queueTexturesAndModels(item: any): void { function queueTexturesAndModels(item): void {
if (item.TextureFiles !== null) { if (item.TextureFiles !== null) {
for (const key in item.TextureFiles) { for (const file in Object.values(item.TextureFiles)) {
for (const file of item.TextureFiles[key]) { if (file["FileDataId"] !== 0) {
if (file.FileDataId !== 0) { texturesDownloadQueue.add(file["FileDataId"]);
texturesDownloadQueue.add(file.FileDataId);
}
} }
} }
} }
if (item.ModelFiles !== null) { if (item.ModelFiles !== null) {
for (const key in item.ModelFiles) { for (const file of Object.values(item.ModelFiles)) {
for (const file of item.ModelFiles[key]) { if (file["FileDataId"] !== 0) {
if (file.FileDataId !== 0) { modelsDownloadQueue.add(file["FileDataId"]);
modelsDownloadQueue.add(file.FileDataId);
}
} }
} }
} }
@ -146,7 +151,7 @@ function queueTexturesAndModels(item: any): void {
} }
if (item.Textures !== null) { if (item.Textures !== null) {
for (const key in item.Textures) { for (const key of Object.keys(item.Textures)) {
if (item.Textures[key] !== 0) { if (item.Textures[key] !== 0) {
texturesDownloadQueue.add(item.Textures[key]); texturesDownloadQueue.add(item.Textures[key]);
} }
@ -154,7 +159,7 @@ function queueTexturesAndModels(item: any): void {
} }
if (item.Textures2 !== null) { if (item.Textures2 !== null) {
for (const key in item.Textures2) { for (const key of Object.keys(item.Textures2)) {
if (item.Textures2[key] !== 0) { if (item.Textures2[key] !== 0) {
texturesDownloadQueue.add(item.Textures2[key]); texturesDownloadQueue.add(item.Textures2[key]);
} }
@ -163,35 +168,27 @@ function queueTexturesAndModels(item: any): void {
} }
async function downloadRaces(): Promise<void> { async function downloadRaces(): Promise<void> {
const races = [ const races = ["human", "nightelf", "dwarf", "gnome", "draenei", "orc", "troll", "tauren", "bloodelf", "scourge"];
"human",
"nightelf",
"dwarf",
"gnome",
"draenei",
"orc",
"troll",
"tauren",
"bloodelf",
"scourge",
];
const genders = ["male", "female"]; const genders = ["male", "female"];
const raceGenderCombo = races.map((race) => [race + genders[0], race + genders[1]]).flat(); const raceGenderCombo = races.map((race) => [race + genders[0], race + genders[1]]).flat();
const progress = new Progress("Downloading races data...", raceGenderCombo.length); const progress = new Progress("Downloading races data...", raceGenderCombo.length);
await promisepool.PromisePool await promisepool.PromisePool.for(raceGenderCombo)
.for(raceGenderCombo)
.withConcurrency(4) .withConcurrency(4)
.process(async (race) => { .process(async (race) => {
const characterJson = await download("meta/character", `${race}.json`); 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`); const customizationJson = await download("meta/charactercustomization2", `${characterJson["Race"]}_${characterJson["Gender"]}.json`);
for (const option of customizationJson.Options) { for (const option of customizationJson["Options"]) {
for (const choice of option.Choices) { for (const choice of option.Choices) {
for (const element of choice.Elements) { 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); modelsDownloadQueue.add(element.SkinnedModel.CollectionFileDataID);
} }
if (element.BoneSet !== null && typeof element.BoneSet.BoneFileDataID === "number" && element.BoneSet.BoneFileDataID !== 0) { 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) const textureFiles = Object.values(customizationJson["TextureFiles"]).flat();
.map(key => customizationJson.TextureFiles[key])
.flat();
for (const file of textureFiles) { for (const file of textureFiles) {
texturesDownloadQueue.add(file.FileDataId); texturesDownloadQueue.add(file["FileDataId"]);
} }
progress.increment(); progress.increment();
@ -215,12 +210,14 @@ async function downloadRaces(): Promise<void> {
} }
async function downloadArmors(): 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); const progress = new Progress("Downloading armor data...", rows.length);
await promisepool.PromisePool await promisepool.PromisePool.for(rows)
.for(rows)
.withConcurrency(50) .withConcurrency(50)
.process(async (row) => { .process(async (row) => {
const modifiedAppearance = dbcItemModifiedAppearanceByItemId[row.id]; const modifiedAppearance = dbcItemModifiedAppearanceByItemId[row.id];
@ -247,12 +244,14 @@ async function downloadArmors(): Promise<void> {
} }
async function downloadWeapons(): 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); const progress = new Progress("Downloading weapon data...", rows.length);
await promisepool.PromisePool await promisepool.PromisePool.for(rows)
.for(rows)
.withConcurrency(50) .withConcurrency(50)
.process(async (row) => { .process(async (row) => {
const modifiedAppearance = dbcItemModifiedAppearanceByItemId[row.id]; const modifiedAppearance = dbcItemModifiedAppearanceByItemId[row.id];
@ -262,7 +261,7 @@ async function downloadWeapons(): Promise<void> {
} }
const appearance = dbcItemAppearanceById[modifiedAppearance.itemAppearanceId]; const appearance = dbcItemAppearanceById[modifiedAppearance.itemAppearanceId];
try { try {
const itemJson = await download(`meta/item`, `${appearance.itemDisplayInfoId}.json`); const itemJson = await download("meta/item", `${appearance.itemDisplayInfoId}.json`);
queueTexturesAndModels(itemJson); queueTexturesAndModels(itemJson);
progress.increment(); progress.increment();
} catch (err) { } catch (err) {
@ -307,11 +306,13 @@ async function readDbcData(): Promise<void> {
} }
async function downloadMounts(): 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); const progress = new Progress("Downloading mount data...", mountSpells.length);
await promisepool.PromisePool await promisepool.PromisePool.for(mountSpells)
.for(mountSpells)
.withConcurrency(50) .withConcurrency(50)
.process(async (spell) => { .process(async (spell) => {
const mount = dbcMountBySourceSpellId[spell.id]; const mount = dbcMountBySourceSpellId[spell.id];
@ -333,8 +334,7 @@ async function downloadMounts(): Promise<void> {
async function downloadTextures(): Promise<void> { async function downloadTextures(): Promise<void> {
const progress = new Progress("Downloading textures...", texturesDownloadQueue.size); const progress = new Progress("Downloading textures...", texturesDownloadQueue.size);
await promisepool.PromisePool await promisepool.PromisePool.for(Array.from(texturesDownloadQueue))
.for(Array.from(texturesDownloadQueue))
.withConcurrency(25) .withConcurrency(25)
.process(async (fileDataId) => { .process(async (fileDataId) => {
await download("textures", `${fileDataId}.png`); await download("textures", `${fileDataId}.png`);
@ -347,8 +347,7 @@ async function downloadTextures(): Promise<void> {
async function downloadModels(): Promise<void> { async function downloadModels(): Promise<void> {
const progress = new Progress("Downloading models...", modelsDownloadQueue.size); const progress = new Progress("Downloading models...", modelsDownloadQueue.size);
await promisepool.PromisePool await promisepool.PromisePool.for(Array.from(modelsDownloadQueue))
.for(Array.from(modelsDownloadQueue))
.withConcurrency(25) .withConcurrency(25)
.process(async (fileDataId) => { .process(async (fileDataId) => {
await download("mo3", `${fileDataId}.mo3`); await download("mo3", `${fileDataId}.mo3`);
@ -361,8 +360,7 @@ async function downloadModels(): Promise<void> {
async function downloadBones(): Promise<void> { async function downloadBones(): Promise<void> {
const progress = new Progress("Downloading bones...", bonesDownloadQueue.size); const progress = new Progress("Downloading bones...", bonesDownloadQueue.size);
await promisepool.PromisePool await promisepool.PromisePool.for(Array.from(bonesDownloadQueue))
.for(Array.from(bonesDownloadQueue))
.withConcurrency(25) .withConcurrency(25)
.process(async (fileDataId) => { .process(async (fileDataId) => {
try { try {
@ -381,11 +379,10 @@ async function downloadBones(): Promise<void> {
} }
async function parseModels(): 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); const progress = new Progress("Reading model files for texture references...", files.length);
await promisepool.PromisePool await promisepool.PromisePool.for(files)
.for(files)
.withConcurrency(20) .withConcurrency(20)
.process(async (file) => { .process(async (file) => {
const buffer = await fsp.readFile(file); const buffer = await fsp.readFile(file);

115
static/arena-team.hbs Normal file
View 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>

View file

@ -69,6 +69,7 @@
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
$(window).on("load", () => {
let achievementsData = {}; let achievementsData = {};
let $openCategory; let $openCategory;
const $achievementTemplate = $("#achievement-template").detach().removeAttr("id"); const $achievementTemplate = $("#achievement-template").detach().removeAttr("id");
@ -117,8 +118,7 @@
$achievement.find("a").attr("href", `{{aowow}}?achievement=${achievement.id}`); $achievement.find("a").attr("href", `{{aowow}}?achievement=${achievement.id}`);
if (isEarned) { if (isEarned) {
const earned = achievementsData.earned[achievement.id]; const date = new Date(achievementsData.earned[achievement.id] * 1000);
const date = new Date(earned.date * 1000);
$achievement.find(".earned-date").text(date.toLocaleDateString()); $achievement.find(".earned-date").text(date.toLocaleDateString());
$achievement.addClass("earned"); $achievement.addClass("earned");
} }
@ -156,7 +156,7 @@
.map(achievement => { .map(achievement => {
return { return {
achievement, achievement,
...achievementsData.earned[achievement], date: achievementsData.earned[achievement],
}; };
}); });
const mostRecent = achievements.sort((a, b) => b.date - a.date).slice(0, 4); const mostRecent = achievements.sort((a, b) => b.date - a.date).slice(0, 4);
@ -214,7 +214,7 @@
$totalRow.prependTo($("#progress-bars")); // Move the total bar to the top $totalRow.prependTo($("#progress-bars")); // Move the total bar to the top
} }
$.getJSON(`{{websiteRoot}}/character/{{realm}}/{{guid}}/achievements/data`, (data) => { $.getJSON(`{{websiteRoot}}/character/{{{realm}}}/{{guid}}/achievements/data`, (data) => {
achievementsData = data; achievementsData = data;
const $summaryLink = $("<a>") const $summaryLink = $("<a>")
@ -258,4 +258,5 @@
makeProgressBars(); makeProgressBars();
openSummary(); openSummary();
}); });
});
</script> </script>

View file

@ -20,7 +20,9 @@
<div class="column is-full-touch is-one-quarter-desktop"> <div class="column is-full-touch is-one-quarter-desktop">
<div class="box arena-team" data-team-id="{{this.id}}"> <div class="box arena-team" data-team-id="{{this.id}}">
<div class="info has-text-centered"> <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 class="is-size-5">{{this.type}}v{{this.type}} Team</div>
<div>Rating: {{this.rating}}</div> <div>Rating: {{this.rating}}</div>
<div>{{this.seasonWins}} W / {{subtract this.seasonGames this.seasonWins}} L</div> <div>{{this.seasonWins}} W / {{subtract this.seasonGames this.seasonWins}} L</div>
@ -36,7 +38,9 @@
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
$(window).on("load", () => {
{{#each arenaTeams}} {{#each arenaTeams}}
createArenaEmblem({{this.type}}, {{{JSONstringify this.emblem}}}, $(".arena-team[data-team-id={{this.id}}] .arena-emblem")[0]); createArenaEmblem({{this.type}}, {{{JSONstringify this.emblem}}}, $(".arena-team[data-team-id={{this.id}}] .arena-emblem")[0]);
{{/each}} {{/each}}
});
</script> </script>

View file

@ -3,7 +3,6 @@
<script type="application/javascript"> <script type="application/javascript">
const aowow_tooltips = { "renamelinks": true, }; const aowow_tooltips = { "renamelinks": true, };
</script> </script>
<script type="application/javascript" src="{{aowow}}/static/widgets/power.js"></script>
{{> character-header }} {{> character-header }}
@ -47,62 +46,10 @@
</div> </div>
</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"> <script type="application/javascript">
$(window).on("load", () => {
const talentsData = {{{JSONstringify data}}}; const talentsData = {{{JSONstringify data}}};
for (let spec = 0; spec < 2; ++spec) {
const $spec = $(`#talents-spec-${spec}`);
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;
}
}
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]);
}
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) { function createTree(data, learnedTalents, container) {
const $columns = $(container).find(".col"); const $columns = $(container).find(".col");
let points = 0; let points = 0;
@ -219,6 +166,55 @@
$(container).append($arrow); $(container).append($arrow);
} }
function main() {
for (let spec = 0; spec < 2; ++spec) {
const $spec = $(`#talents-spec-${spec}`);
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;
}
}
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]);
}
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);
}
$("#link-spec-0").on("click", () => { $("#link-spec-0").on("click", () => {
$("#talents-spec-0").removeClass("hidden"); $("#talents-spec-0").removeClass("hidden");
$("#talents-spec-1").addClass("hidden"); $("#talents-spec-1").addClass("hidden");
@ -232,4 +228,11 @@
if (location.hash === "#1") { if (location.hash === "#1") {
$("#link-spec-1").click(); $("#link-spec-1").click();
} }
$("<script>")
.attr("src", "{{aowow}}/static/widgets/power.js")
.appendTo("body");
}
main();
});
</script> </script>

View file

@ -1,8 +1,13 @@
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/character.css"> <link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/character.css">
{{> icons }} {{> 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="{{websiteRoot}}/js/viewer.min.js"></script>
<script type="application/javascript" src="{{aowow}}/static/widgets/power.js"></script>
{{> character-header }} {{> character-header }}
@ -16,8 +21,16 @@
<span data-icon-size="large" class="icon-size is-hidden-mobile"></span> <span data-icon-size="large" class="icon-size is-hidden-mobile"></span>
<span data-icon-size="medium" class="icon-size is-hidden-tablet"></span> <span data-icon-size="medium" class="icon-size is-hidden-tablet"></span>
<br> <div class="columns">
<div id="char-options" 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"> <div class="column is-one-quarter">
<label class="checkbox"> <label class="checkbox">
<input id="cb-hide-helmet" type="checkbox"> <input id="cb-hide-helmet" type="checkbox">
@ -35,12 +48,26 @@
<input id="cb-hide-tabard" type="checkbox"> <input id="cb-hide-tabard" type="checkbox">
Hide tabard Hide tabard
</label> </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>
<div id="mounts-container" class="column"> <div id="mounts-container" class="column">
<button id="btn-mounts" class="button is-primary">Mounts</button> <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="icon"></div>
<div class="border"></div> <div class="border"></div>
<a target="_blank"></a> <a target="_blank"></a>
@ -49,14 +76,20 @@
</div> </div>
</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="inventory-slot"></div>
<div class="icon"></div> <div class="icon"></div>
<div class="border"></div> <div class="border"></div>
<a target="_blank"></a> <a target="_blank"></a>
</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"> <script type="application/javascript">
$(window).on("load", () => {
const $model = $("#model"); const $model = $("#model");
const $itemSlotTemplate = $("#item-slot-template").detach().removeAttr("id"); const $itemSlotTemplate = $("#item-slot-template").detach().removeAttr("id");
const $mountTemplate = $("#mount-template").detach().removeAttr("id"); const $mountTemplate = $("#mount-template").detach().removeAttr("id");
@ -81,6 +114,25 @@
}; };
const genders = ["male", "female"]; const genders = ["male", "female"];
if (Cookies.get("disable-3d-viewer") === "1") {
$("#3d-viewer-options").hide();
} else {
$("#cb-enable-3d-viewer").prop("checked", true);
}
$("#cb-enable-3d-viewer").change(() => {
const enabled = $("#cb-enable-3d-viewer").prop("checked");
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");
}
});
const hideHelm = (charData.flags & 0x0400) === 0x0400; const hideHelm = (charData.flags & 0x0400) === 0x0400;
$("#cb-hide-helmet").prop("checked", hideHelm); $("#cb-hide-helmet").prop("checked", hideHelm);
const hideCloak = (charData.flags & 0x0800) === 0x0800; const hideCloak = (charData.flags & 0x0800) === 0x0800;
@ -98,19 +150,60 @@
const hide = $("#cb-hide-tabard").prop("checked"); const hide = $("#cb-hide-tabard").prop("checked");
setSlotVisible(19, !hide); 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);
}
}
}
$("#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]);
}
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));
});
let viewer; let viewer;
function createViewer() { function createViewer() {
if (Cookies.get("disable-3d-viewer") === "1") {
return;
}
viewer?.destroy(); viewer?.destroy();
viewer = new ZamModelViewer(characterModel); viewer = new ZamModelViewer(characterModel);
const isLoadedInterval = setInterval(() => {
if (viewer.method("isLoaded")) {
clearInterval(isLoadedInterval);
setBackground("background.png");
}
}, 100);
} }
function setSlotVisible(slot, visible) { function setSlotVisible(slot, visible) {
if (visible) { if (visible) {
const item = charData.characterModelItems.find(([sl, appearance]) => sl === slot); 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 !== undefined) {
if (item[1] === -1) {
clearSlots([slot]);
} else {
setItems([item]); setItems([item]);
} }
}
} else { } else {
clearSlots([slot]); clearSlots([slot]);
} }
@ -134,10 +227,11 @@
characterModel.items.push(...items); characterModel.items.push(...items);
} }
function createItemSlot(item, invSlot, icon, rel, container) { function createItemSlot(item, invSlot, icon, quality, rel, container) {
const $item = $itemSlotTemplate.clone(); const $item = $itemSlotTemplate.clone();
$item $item
.data("icon", icon) .data("icon", icon)
.data("quality", quality)
.find(".inventory-slot") .find(".inventory-slot")
.css("background-image", `url("{{websiteRoot}}/img/inventory-slot/${invSlot}.png")`); .css("background-image", `url("{{websiteRoot}}/img/inventory-slot/${invSlot}.png")`);
if (item !== undefined) { if (item !== undefined) {
@ -168,7 +262,7 @@
rel.push("rand=" + item.randomPropertyId); rel.push("rand=" + item.randomPropertyId);
} }
} }
createItemSlot(item?.itemEntry, slot, item?.icon?.toLowerCase(), rel.join("&"), side.element); createItemSlot(item?.itemEntry, slot, item?.icon?.toLowerCase(), item?.quality, rel.join("&"), side.element);
} }
} }
const nbItemRows = Math.max($("#equipment-col-left").children().length, $("#equipment-col-right").children().length); const nbItemRows = Math.max($("#equipment-col-left").children().length, $("#equipment-col-right").children().length);
@ -186,9 +280,13 @@
const $item = $(el); const $item = $(el);
const item = $item.data("item"); const item = $item.data("item");
const icon = $item.data("icon"); const icon = $item.data("icon");
const quality = $item.data("quality");
if (item !== undefined) { if (item !== undefined) {
$item.find(".icon").css("background-image", `url("{{aowow}}/static/images/wow/icons/${iconSize}/${icon}.jpg")`); $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) { if (!init) {
@ -199,10 +297,50 @@
onResize(true); onResize(true);
function setMount(mountId) { function setMount(mountId) {
if (characterModel.mount.id !== 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; characterModel.mount.id = mountId;
createViewer(); createViewer();
} }
function setBackground(file) {
if (!viewer) {
return;
}
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) { if (charData.mounts.length === 0) {
@ -232,11 +370,45 @@
$("#btn-mounts").on("click", () => { $("#btn-mounts").on("click", () => {
$("#mounts").slideToggle("fast"); $("#mounts").slideToggle("fast");
}); });
$("#btn-transmogs").on("click", () => {
$("#list-transmogs").slideToggle("fast");
});
const sheathTypes = {
2: {
// One handed weapons
0: 3,
4: 3,
7: 3,
14: 3,
15: 3,
17: 3,
20: 3,
// Two handed weapons
1: 1,
5: 1,
8: 1,
// Others
10: 2, // Staff
6: 1, // Polearm
2: 4, // Bow
3: 4, // Gun
18: 4, // Crossbow
13: 0, // Fist weapons
19: 3, // Wand
20: 1, // Fishing pole
},
4: {
0: 3, // Held in off-hand
6: 9, // Shield
},
};
const characterModel = { const characterModel = {
type: ZamModelViewer.WOW, type: ZamModelViewer.WOW,
contentPath: "{{websiteRoot}}/data/", contentPath: "{{ contentPath }}",
background: "background.png",
container: $model, container: $model,
hd: true, hd: true,
aspect: $model.outerWidth() / $model.outerHeight(), aspect: $model.outerWidth() / $model.outerHeight(),
@ -248,7 +420,8 @@
sheathOff: -1, sheathOff: -1,
}, },
cls: charData.class, cls: charData.class,
items: charData.characterModelItems.filter(item => !((item[0] === 1 && hideHelm) || (item[0] === 16 && hideCloak))), items: (charData.characterModelTransmogs ?? charData.characterModelItems)
.filter(item => !((item[0] === 1 && hideHelm) || (item[0] === 16 && hideCloak)) && item[1] !== -1),
models: { models: {
type: ZamModelViewer.Wow.Types.CHARACTER, type: ZamModelViewer.Wow.Types.CHARACTER,
id: `${races[charData.race]}${genders[charData.gender]}`, id: `${races[charData.race]}${genders[charData.gender]}`,
@ -279,4 +452,9 @@
windowWidth = newWidth; windowWidth = newWidth;
onResize(); onResize();
}, 500)); }, 500));
$("<script>")
.attr("src", "{{aowow}}/static/widgets/power.js")
.appendTo("body");
});
</script> </script>

29
static/css/arena-team.css Normal file
View 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;
}

View file

@ -24,3 +24,12 @@
height: 100% !important; height: 100% !important;
width: 100% !important; width: 100% !important;
} }
.no-link-text a span {
display: none;
}
#list-transmogs-container,
#lbl-hide-transmogs {
display: none;
}

View file

@ -45,6 +45,7 @@
position: absolute; position: absolute;
background-repeat: no-repeat; background-repeat: no-repeat;
z-index: 1; z-index: 1;
background-size: 36px;
} }
.iconlarge { .iconlarge {

View file

@ -1,7 +1,3 @@
#select-realm {
margin-bottom: 16px;
}
#results_processing { #results_processing {
height: 70px; height: 70px;
} }

View file

@ -0,0 +1,8 @@
#select-arena-type-container {
display: flex;
}
#select-arena-type-container .arena-type-label {
align-self: center;
margin-right: 4px;
}

View file

@ -59,6 +59,7 @@
</table> </table>
<script type="application/javascript"> <script type="application/javascript">
$(window).on("load", () => {
createGuildEmblem({{{ JSONstringify emblem }}}, $(".emblem-container .guild-emblem")[0]); createGuildEmblem({{{ JSONstringify emblem }}}, $(".emblem-container .guild-emblem")[0]);
dt = $("#members").DataTable({ dt = $("#members").DataTable({
@ -66,7 +67,7 @@
serverSide: true, serverSide: true,
searchDelay: 800, searchDelay: 800,
ajax: { ajax: {
url: `{{websiteRoot}}/guild/{{realm}}/{{id}}/members`, url: `{{websiteRoot}}/guild/{{{realm}}}/{{id}}/members`,
}, },
columnDefs: [ columnDefs: [
{ {
@ -98,4 +99,5 @@
}, },
order: [[1, "asc"]], order: [[1, "asc"]],
}); });
});
</script> </script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

View file

@ -3,8 +3,11 @@
<h1 class="title is-size-1">Armory</h1> <h1 class="title is-size-1">Armory</h1>
{{#if (not (equalsLength realms 1))}} <a href="{{websiteRoot}}/arena">Arena Ladder</a>&emsp;
<br><br>
{{#if (not (equalsLength realms 1))}}
<div id="select-realm-container"> <div id="select-realm-container">
<span class="realm-label">Realm:</span> <span class="realm-label">Realm:</span>
<div class="select"> <div class="select">
@ -34,6 +37,7 @@
</table> </table>
<script type="application/javascript"> <script type="application/javascript">
$(window).on("load", () => {
let dt; let dt;
$("#select-realm").on("change", () => { $("#select-realm").on("change", () => {
@ -79,4 +83,5 @@
details: true, details: true,
}, },
}); });
});
</script> </script>

View file

@ -1,5 +1,6 @@
function waitForEmblemImages($emblem) { function waitForEmblemImages($emblem) {
return Promise.all($emblem.find(".images img").map((idx, img) => { return Promise.all(
$emblem.find(".images img").map((idx, img) => {
return new Promise((res, rej) => { return new Promise((res, rej) => {
if (img.complete) { if (img.complete) {
res(); res();
@ -8,7 +9,8 @@ function waitForEmblemImages($emblem) {
img.addEventListener("error", rej); img.addEventListener("error", rej);
} }
}); });
})); }),
);
} }
function createGuildEmblem(emblem, el) { function createGuildEmblem(emblem, el) {
@ -16,7 +18,8 @@ function createGuildEmblem(emblem, el) {
const canvas = $emblem.find("canvas")[0]; const canvas = $emblem.find("canvas")[0];
const ctx = canvas.getContext("2d"); 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 $images = $("<div>").addClass("images").appendTo($emblem);
const bgUpper = $("<img>").attr("src", imgUrl("Background", "U", emblem.background)).appendTo($images)[0]; 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 canvas = $emblem.find("canvas")[0];
const ctx = canvas.getContext("2d"); 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 $images = $("<div>").addClass("images").appendTo($emblem);
const banner = $("<img>").attr("src", imgUrl(teamSize)).appendTo($images)[0]; const banner = $("<img>").attr("src", imgUrl(teamSize)).appendTo($images)[0];

View file

@ -1,3 +1,6 @@
window.parent.postMessage({ window.parent.postMessage(
{
url: window.location.pathname.replace(handlebarsData.websiteRoot, ""), url: window.location.pathname.replace(handlebarsData.websiteRoot, ""),
}, "*"); },
"*",
);

101
static/ladder-arena.hbs Normal file
View 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>&emsp;
<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>
&ensp;
<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>

View file

@ -15,6 +15,7 @@
const handlebarsData = {{{JSONstringify locals}}}; const handlebarsData = {{{JSONstringify locals}}};
</script> </script>
<script type="application/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></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}} {{#if iframeMode.enabled}}
<script type="application/javascript"> <script type="application/javascript">
@ -36,3 +37,5 @@
{{{ body }}} {{{ body }}}
</section> </section>
</body> </body>
</html>

View file

@ -1,10 +1,10 @@
<a href="{{websiteRoot}}/">Back to Armory</a> <a href="{{websiteRoot}}/">Back to Armory</a>
<br><br> <br><br>
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}">Character</a>&ensp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}">Character</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a>&ensp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a> <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a> <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a>&emsp;
<br> <br>
<br> <br>

View file

@ -1,7 +1,7 @@
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/icons.css"> <link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/icons.css">
<style> <style>
.iconlarge .border { .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 { .iconlarge a {
@ -9,7 +9,7 @@
} }
.iconmedium .border { .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 { .iconmedium a {

View file

@ -4,8 +4,6 @@
"outDir": "build", "outDir": "build",
"moduleResolution": "node" "moduleResolution": "node"
}, },
"exclude": [ "exclude": ["node_modules"],
"node_modules"
],
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"]
} }