diff --git a/.gitattributes b/.gitattributes index 6f5933e4..c751ac98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,14 @@ +* text=input + *.php text eol=lf *.js text eol=lf *.css text eol=lf *.sql text eol=lf +aowow text eol=lf +prQueue text eol=lf + +*.png binary +*.jpg binary +*.gif binary +*.ttf binary +*.swf binary diff --git a/.gitignore b/.gitignore index 92491133..8fbefabd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,19 @@ -# Git -*.orig - # cache -/cache/template/* -/setup/generated/alphaMaps/*.png -/cache/firstrun +/cache/* # extract from MPQ /setup/mpqdata/* # generated files /static/js/profile_all.js -/static/js/locale.js -/static/js/Markup.js +/static/js/global.js /static/widgets/power.js /static/widgets/power/demo.html /static/widgets/searchbox.js /static/widgets/searchbox/searchbox.html /static/download/searchplugins/aowow.xml -/config/config.php +/config/* /datasets/* -!/datasets/zones -# /datasets/item-scaling # extracted sounds /static/wowsounds/* @@ -31,7 +23,7 @@ /static/images/wow/icons/medium/* /static/images/wow/icons/small/* /static/images/wow/icons/tiny/* -!/static/images/wow/icons/tiny/quest_* +!/static/images/wow/icons/tiny/quest_[end|start] /static/images/wow/hunterpettalents/* /static/images/wow/Interface/* /static/images/wow/loadingscreens/* @@ -50,4 +42,8 @@ /static/uploads/screenshots/* /static/uploads/signatures/* /static/uploads/temp/* +/static/uploads/guide/images/* +# composer +/includes/libs/* +composer.phar diff --git a/.htaccess b/.htaccess index 0c4ad8e7..0e0530c5 100644 --- a/.htaccess +++ b/.htaccess @@ -26,8 +26,10 @@ AddDefaultCharset utf8 # UHD screenshots can get pretty large (cannot be set in config) + php_value upload_max_filesize 20M php_value post_max_size 25M + RewriteEngine on # RewriteBase /~user/localPath/ # enable if the rules do not work, when they should diff --git a/README.md b/README.md index 67094695..e182aced 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Build Status -![fuck it ship it](http://forthebadge.com/images/badges/fuck-it-ship-it.svg) +![fuck it ship it](https://forthebadge.com/badges/fuck-it-ship-it.svg) ## Introduction @@ -13,20 +13,22 @@ While the first releases can be found as early as 2008, today it is impossible t This is a complete rewrite of the serverside php code and update to the clientside javascripts from 2008 to something 2013ish. I myself take no credit for the clientside scripting, design and layout that these php-scripts cater to. -Also, this project is not meant to be used for commercial puposes of any kind! +Also, this project is not meant to be used for commercial purposes of any kind! ## Requirements -+ Webserver running PHP ≥ 7.4 — 8.0 including extensions: ++ Webserver running PHP ≥ 8.2 including extensions: + [SimpleXML](https://www.php.net/manual/en/book.simplexml.php) + [GD](https://www.php.net/manual/en/book.image) + [MySQL Improved](https://www.php.net/manual/en/book.mysqli.php) + [Multibyte String](https://www.php.net/manual/en/book.mbstring.php) + [File Information](https://www.php.net/manual/en/book.fileinfo.php) + + [Internationalization](https://www.php.net/manual/en/book.intl.php) + [GNU Multiple Precision](https://www.php.net/manual/en/book.gmp.php) (When using TrinityCore as auth source) -+ MySQL ≥ 5.5.30 -+ [TDB 335.21101](https://github.com/TrinityCore/TrinityCore/releases/tag/TDB335.21101) ++ MySQL ≥ 5.7.0 OR MariaDB ≥ 10.6.4 OR similar ++ [Composer](https://getcomposer.org/download/) ++ [TDB 335.25101](https://github.com/TrinityCore/TrinityCore/releases/tag/TDB335.25101) including updates up to [TrinityCore/TrinityCore@f3b691d](https://github.com/TrinityCore/TrinityCore/commit/f3b691dcb085014ec3f0e2c60ab94fc9c00e8aa8) (no other other providers are supported at this time) + WIN: php.exe needs to be added to the `PATH` system variable, if it isn't already. + Tools require cmake: Please refer to the individual repositories for detailed information + [MPQExtractor](https://github.com/Sarjuuk/MPQExtractor) / [FFmpeg](https://ffmpeg.org/download.html) / (optional: [BLPConverter](https://github.com/Sarjuuk/BLPConverter)) @@ -37,9 +39,9 @@ audio processing may require [lame](https://sourceforge.net/projects/lame/files/ #### Highly Recommended -+ setting the following configuration values on your TrinityCore server will greatly increase the accuracy of spawn points ++ setting the following configuration values on your TrinityCore server (and running it once) will greatly increase the accuracy of spawn points > Calculate.Creature.Zone.Area.Data = 1 - > Calculate.Gameoject.Zone.Area.Data = 1 + > Calculate.Gameobject.Zone.Area.Data = 1 ## Install @@ -49,8 +51,10 @@ audio processing may require [lame](https://sourceforge.net/projects/lame/files/ `git clone git@github.com:Sarjuuk/MPQExtractor.git MPQExtractor` #### 2. Prepare the database -Ensure that the account you are going to use has **full** access on the database AoWoW is going to occupy and ideally only **read** access on the world database you are going to reference. -Import `setup/db_structure.sql` into the AoWoW database `mysql -p {your-db-here} < setup/db_structure.sql` +Ensure that the account you are going to use has **full** access on the database AoWoW is going to occupy and ideally only **read** access on the world and optionally auth and characters databases you are going to reference. +Import files 01 - 03 from `setup/sql/` in order into the AoWoW database `mysql --default-character-set=utf8 -p {your-db-here} < setup/sql/01-db_structure.sql`, etc. + +**Optional**: If you are using MySQL ≥ 8.4.0 and want to support fulltext search for locale zhCN, additionally import `setup/sql/04-db_optional_mysql_only.sql`. Enables this in settings after AoWoW has been set up. #### 3. Server created files See to it, that the web server is able to write the following directories and their children. If they are missing, the setup will create them with appropriate permissions @@ -72,26 +76,26 @@ Extract the following directories from the client archives into `setup/mpqdata/` .. once is enough (still apply the localeCode though): > \/Interface/TalentFrame/ - > \/Interface/Glues/Credits/ > \/Interface/Icons/ > \/Interface/Spellbook/ > \/Interface/PaperDoll/ - > \/Interface/GLUES/CHARACTERCREATE/ + > \/Interface/Glues/CharacterCreate/ > \/Interface/Pictures > \/Interface/PvPRankBadges > \/Interface/FlavorImages > \/Interface/Calendar/Holidays/ > \/Sound/ - - .. optionaly (not used in AoWoW): - > \/Interface/GLUES/LOADINGSCREENS/ #### 5. Reencode the audio files WAV-files need to be reencoded as `ogg/vorbis` and some MP3s may identify themselves as `application/octet-stream` instead of `audio/mpeg`. * [example for WIN](https://gist.github.com/Sarjuuk/d77b203f7b71d191509afddabad5fc9f) * [example for \*nix](https://gist.github.com/Sarjuuk/1f05ef2affe49a7e7ca0fad7b01c081d) -#### 6. Run the initial setup from the CLI +#### 6. Install dependencies with composer +`php composer.phar install --no-dev` on a project level composer install, or +`composer install --no-dev` on a system level composer install + +#### 7. Run the initial setup from the CLI `php aowow --setup`. This should guide you through with minimal input required from your end, but will take some time though, especially compiling the zone-images. Use it to familiarize yourself with the other functions this setup has. Yes, I'm dead serious: *Go read the code!* It will help you understand how to configure AoWoW and keep it in sync with your world database. When you've created your admin account you are done. @@ -100,24 +104,24 @@ When you've created your admin account you are done. ## Troubleshooting Q: The Page appears white, without any styles. -A: The static content is not being displayed. You are either using SSL and AoWoW is unable to detect it or STATIC_HOST is not defined poperly. Either way this can be fixed via config `php aowow --siteconfig` +A: The static content is not being displayed. You are either using SSL and AoWoW is unable to detect it or STATIC_HOST is not defined properly. Either way this can be fixed via config `php aowow --configure` Q: Fatal error: Can't inherit abstract function \ (previously declared abstract in \) in \ -A: You are using cache optimization modules for php, that are in confict with each other. (Zend OPcache, XCache, ..) Disable all but one. +A: You are using multiple cache optimization modules for php that are in conflict with each other. (Zend OPcache, XCache, ..) Disable all but one. Q: Some generated images appear distorted or have alpha-channel issues. A: Image compression is beyond my understanding, so i am unable to fix these issues within the blpReader. BUT you can convert the affected blp file into a png file in the same directory, using the provided BLPConverter. - AoWoW will priorize png files over blp files. + AoWoW will prioritize png files over blp files. Q: How can i get the modelviewer to work? A: You can't anymore. Wowhead switched from Flash to WebGL (as they should) and moved or deleted the old files in the process. Q: I'm getting random javascript errors! -A: Some server configurations or external services (like Cloudflare) come with modules, that automaticly minify js and css files. Sometimes they break in the process. Disable the module in this case. +A: Some server configurations or external services (like Cloudflare) come with modules, that automatically minify js and css files. Sometimes they break in the process. Disable the module in this case. Q: Some search results within the profiler act rather strange. How does it work? -A: Whenever you try to view a new character, AoWoW needs to fetch it first. Since the data is structured for the needs of TrinityCore and not for easy viewing, AoWoW needs to save and restructure it locally. To this end, every char request is placed in a queue. While the queue is not empty, a single instance of `prQueue` is run in the background as not to overwhelm the characters database with requests. This also means, some more exotic search queries can't be run agains the characters database and have to use the incomplete/outdated cached profiles of AoWoW. +A: Whenever you try to view a new character, AoWoW needs to fetch it first. Since the data is structured for the needs of TrinityCore and not for easy viewing, AoWoW needs to save and restructure it locally. To this end, every char request is placed in a queue. While the queue is not empty, a single instance of `prQueue` is run in the background as not to overwhelm the characters database with requests. This also means complex search queries can't be run against the characters database and have to use the incomplete/outdated cached profiles of AoWoW. Q: Screenshot upload fails, because the file size is too large and/or the subdirectories are visible from the web! A: That's a web server configuration issue. If you are using Apache you may need to [enable the use of .htaccess](http://httpd.apache.org/docs/2.4/de/mod/core.html#allowoverride). Other servers require individual configuration. @@ -134,7 +138,7 @@ A: A search is only conducted against the currently used locale. You may have on ## Special Thanks -Said website with the red smiling rocket, for providing this beautifull website! -Please do not reagard this project as blatant rip-off, rather as "We do really liked your presentation, but since time and content progresses, you are sadly no longer supplying the data we need". +Said website with the red smiling rocket, for providing this beautiful website! +Please do not regard this project as blatant rip-off, rather as "We do really liked your presentation, but since time and content progresses, you are sadly no longer supplying the data we need". -![uses badges](http://forthebadge.com/images/badges/uses-badges.svg) +![uses badges](https://forthebadge.com/badges/uses-badges.svg) diff --git a/aowow b/aowow index 5acd6a6a..3e1adb62 100755 --- a/aowow +++ b/aowow @@ -1,12 +1,18 @@ +#!/usr/bin/env php diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..fc43b9aa --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "aowow/aowow", + "description": "Server and client database visualization for World of Warcraft/TrinityCore v3.3.5a, including community tools.", + "version": "2.0", + "type": "project", + "keywords": ["World of Warcraft", "wow", "database", "db", "frontend", "Wrath of the Lich King", "wotlk", "335a", "3.3.5a"], + "authors": [ + { + "name": "Sarjuuk", + "role": "Developer" + } + ], + "config": { + "vendor-dir": "includes/libs" + }, + "require": { + "dibi/dibi": "^5.1", + "php": "8.2 - 8.4", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-gd": "*", + "ext-mysqli": "*", + "ext-fileinfo": "*", + "ext-intl": "*" + }, + "require-dev": { + "jfcherng/php-diff": "6.16", + "triggerhappy/mpq": "dev-master" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..8d9a0b87 --- /dev/null +++ b/composer.lock @@ -0,0 +1,454 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "25b289a987a7600746f0fd1f2491864b", + "packages": [ + { + "name": "dibi/dibi", + "version": "v5.1.0", + "source": { + "type": "git", + "url": "https://github.com/dg/dibi.git", + "reference": "32b6976209859f61eb79380c5a8904ea33db47df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dg/dibi/zipball/32b6976209859f61eb79380c5a8904ea33db47df", + "reference": "32b6976209859f61eb79380c5a8904ea33db47df", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "replace": { + "dg/dibi": "*" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.0", + "nette/di": "^3.1", + "nette/tester": "^2.5", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "psr-4": { + "Dibi\\": "src/Dibi" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + } + ], + "description": "Dibi is Database Abstraction Library for PHP", + "homepage": "https://dibiphp.com", + "keywords": [ + "access", + "database", + "dbal", + "mssql", + "mysql", + "odbc", + "oracle", + "pdo", + "postgresql", + "sqlite", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/dg/dibi/issues", + "source": "https://github.com/dg/dibi/tree/v5.1.0" + }, + "time": "2025-08-06T22:26:19+00:00" + } + ], + "packages-dev": [ + { + "name": "chdemko/sorted-collections", + "version": "1.0.10", + "source": { + "type": "git", + "url": "https://github.com/chdemko/php-sorted-collections.git", + "reference": "d9cf7021e6fda1eb68b9f35caf99215327f6db76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chdemko/php-sorted-collections/zipball/d9cf7021e6fda1eb68b9f35caf99215327f6db76", + "reference": "d9cf7021e6fda1eb68b9f35caf99215327f6db76", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.7", + "phpbench/phpbench": "^1.3", + "phpunit/phpunit": "^11.3", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "chdemko\\SortedCollection\\": "src/SortedCollection" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Christophe Demko", + "email": "chdemko@gmail.com", + "homepage": "https://chdemko.pagelab.univ-lr.fr/", + "role": "Developer" + } + ], + "description": "Sorted Collections for PHP >= 8.1", + "homepage": "https://php-sorted-collections.readthedocs.io/en/latest/?badge=latest", + "keywords": [ + "avl", + "collection", + "iterator", + "map", + "ordered", + "set", + "sorted", + "tree", + "treemap", + "treeset" + ], + "support": { + "issues": "https://github.com/chdemko/php-sorted-collections/issues", + "source": "https://github.com/chdemko/php-sorted-collections/tree/1.0.10" + }, + "time": "2024-08-04T14:31:40+00:00" + }, + { + "name": "jfcherng/php-color-output", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-color-output.git", + "reference": "6c7bf16686cc6a291647fcb87491640a2d5edd20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-color-output/zipball/6c7bf16686cc6a291647fcb87491640a2d5edd20", + "reference": "6c7bf16686cc6a291647fcb87491640a2d5edd20", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.19", + "liip/rmt": "^1.6", + "phan/phan": "^2 || ^3 || ^4", + "phpunit/phpunit": ">=7 <10", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Utility\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + } + ], + "description": "Make your PHP command-line application colorful.", + "keywords": [ + "ansi-colors", + "color", + "command-line", + "str-color" + ], + "support": { + "issues": "https://github.com/jfcherng/php-color-output/issues", + "source": "https://github.com/jfcherng/php-color-output/tree/3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2021-05-27T02:45:54+00:00" + }, + { + "name": "jfcherng/php-diff", + "version": "6.16.0", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-diff.git", + "reference": "8b49edeba6e367df22977fca0f0324b4a99b78a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-diff/zipball/8b49edeba6e367df22977fca0f0324b4a99b78a0", + "reference": "8b49edeba6e367df22977fca0f0324b4a99b78a0", + "shasum": "" + }, + "require": { + "jfcherng/php-color-output": "^3", + "jfcherng/php-mb-string": "^1.4.6 || ^2", + "jfcherng/php-sequence-matcher": "^3.2.10 || ^4", + "php": ">=7.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.8", + "liip/rmt": "^1.6", + "phan/phan": "^5", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Diff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + }, + { + "name": "Chris Boulton", + "email": "chris.boulton@interspire.com" + } + ], + "description": "A comprehensive library for generating differences between two strings in multiple formats (unified, side by side HTML etc).", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/jfcherng/php-diff/issues", + "source": "https://github.com/jfcherng/php-diff/tree/6.16.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2024-03-05T08:44:05+00:00" + }, + { + "name": "jfcherng/php-mb-string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-mb-string.git", + "reference": "8407bfefde47849c9e7c9594e6de2ac85a0f845d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-mb-string/zipball/8407bfefde47849c9e7c9594e6de2ac85a0f845d", + "reference": "8407bfefde47849c9e7c9594e6de2ac85a0f845d", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5", + "phpunit/phpunit": "^9 || ^10" + }, + "suggest": { + "ext-iconv": "Either \"ext-iconv\" or \"ext-mbstring\" is requried.", + "ext-mbstring": "Either \"ext-iconv\" or \"ext-mbstring\" is requried." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Utility\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + } + ], + "description": "A high performance multibytes sting implementation for frequently reading/writing operations.", + "support": { + "issues": "https://github.com/jfcherng/php-mb-string/issues", + "source": "https://github.com/jfcherng/php-mb-string/tree/2.0.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2023-04-17T14:23:16+00:00" + }, + { + "name": "jfcherng/php-sequence-matcher", + "version": "4.0.3", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-sequence-matcher.git", + "reference": "d2038ac29627340a7458609072a8ba355e80ec5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-sequence-matcher/zipball/d2038ac29627340a7458609072a8ba355e80ec5b", + "reference": "d2038ac29627340a7458609072a8ba355e80ec5b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5", + "phpunit/phpunit": "^9 || ^10", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Diff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + }, + { + "name": "Chris Boulton", + "email": "chris.boulton@interspire.com" + } + ], + "description": "A longest sequence matcher. The logic is primarily based on the Python difflib package.", + "support": { + "issues": "https://github.com/jfcherng/php-sequence-matcher/issues", + "source": "https://github.com/jfcherng/php-sequence-matcher/tree/4.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2023-05-21T07:57:08+00:00" + }, + { + "name": "triggerhappy/mpq", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/cipherxof/PHP-MPQ.git", + "reference": "628ca77b307d1cdf28b76da9750f3c8cbe958f49" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cipherxof/PHP-MPQ/zipball/628ca77b307d1cdf28b76da9750f3c8cbe958f49", + "reference": "628ca77b307d1cdf28b76da9750f3c8cbe958f49", + "shasum": "" + }, + "require": { + "chdemko/sorted-collections": "1.0.*@dev", + "php": ">=5.4" + }, + "require-dev": { + "phpunit/phpunit": "5.2.*" + }, + "default-branch": true, + "type": "project", + "autoload": { + "psr-4": { + "TriggerHappy\\MPQ\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "description": "Handle the MPQ (MoPaQ) format natively from PHP with support for Warcraft III & Starcraft II.", + "keywords": [ + "MPQ", + "archive", + "mopaq", + "php-mpq", + "phpmpq", + "triggerhappy" + ], + "support": { + "issues": "https://github.com/cipherxof/PHP-MPQ/issues", + "source": "https://github.com/cipherxof/PHP-MPQ/tree/master" + }, + "time": "2018-07-31T04:22:01+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "triggerhappy/mpq": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "8.2 - 8.4", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-gd": "*", + "ext-mysqli": "*", + "ext-fileinfo": "*", + "ext-intl": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/extAuth.php.in b/config/extAuth.php.in index 8d951569..6158987f 100644 --- a/config/extAuth.php.in +++ b/config/extAuth.php.in @@ -4,13 +4,16 @@ if (!defined('AOWOW_REVISION')) die('illegal access'); - function extAuth($user, $pass, &$userId = 0, &$userGroup = -1) + function extAuth(string &$usernameOrEmail, #[\SensitiveParameter] string $password, int &$userId = 0, int &$userGroup = -1) : int { /* insert some auth mechanism here - see defines for usable return values - set userId for identification + set usernameOrEmail to a valid username, do not pass back the email if used for login + set userId to uid from external auth provider for identification + (optional) set userGroup to a valid userGroup (see U_GROUP_* defines) + + return an AUTH_* result (see defines) */ return AUTH_INTERNAL_ERR; diff --git a/datasets/zones b/datasets/zones deleted file mode 100644 index e6766d53..00000000 --- a/datasets/zones +++ /dev/null @@ -1,46 +0,0 @@ -Mapper.multiLevelZones = { - 206: ['206-1', '206-2', '206-3'], - 209: ['209-1', '209-2', '209-3', '209-4', '209-5', '209-6', '209-7'], - 616: ['616-1', '616_1', '616_2'], - 719: ['719-1', '719-2', '719-3'], - 721: ['721-1', '721-2', '721-3', '721-4'], - 796: ['796-1', '796-2', '796-3', '796-4'], - 1196: ['1196-1', '1196-2'], - 1337: ['1337-1', '1337-2'], - 1581: ['1581-1', '1581-2'], - 1583: ['1583-1', '1583-2', '1583-3', '1583-4', '1583-5', '1583-6', '1583-7'], - 1584: ['1584-1', '1584-2'], - 2017: ['2017-1', '2017-2'], - 2057: ['2057-1', '2057-2', '2057-3', '2057-4'], - 2100: ['2100-1', '2100-2'], - 2557: ['2557-1', '2557-2', '2557-3', '2557-4', '2557-5', '2557-6'], - 2677: ['2677-1', '2677-2', '2677-3', '2677-4'], - 3959: ['3959', '3959-1', '3959-2', '3959-3', '3959-4', '3959-5', '3959-6', '3959-7'], - 3428: ['3428-1', '3428-2', '3428-3'], - 3456: ['3456-1', '3456-2', '3456-3', '3456-4', '3456-5', '3456-6'], - 3457: ['3457-1', '3457-2', '3457-3', '3457-4', '3457-5', '3457-6', '3457-7', '3457-8', '3457-9', '3457-10', '3457-11', '3457-12', '3457-13', '3457-14', '3457-15', '3457-16', '3457-17'], - 3477: ['3477-1', '3477-2', '3477-3'], - 3715: ['3715-1', '3715-2'], - 3790: ['3790-1', '3790-2'], - 3791: ['3791-1', '3791-2'], - 3848: ['3848-1', '3848-2', '3848-3'], - 3849: ['3849-1', '3849-2'], - 4075: ['4075', '4075-1'], - 4100: ['4100-1', '4100-2'], - 4131: ['4131-1', '4131-2'], - 4196: ['4196-1', '4196-2'], - 4228: ['4228-1', '4228-2', '4228-3', '4228-4'], - 4272: ['4272-1', '4272-2'], - 4273: ['4273-0', '4273-1', '4273-2', '4273-3', '4273-4', '4273-5'], - 4277: ['4277-1', '4277-2', '4277-3'], - 4395: ['4395-1', '4395-2'], - 4494: ['4494-1', '4494-2'], - 4714: ['4714-1', '4714_1', '4714_2', '4714_3'], - 4722: ['4722-1', '4722-2'], - 4812: ['4812-1', '4812-2', '4812-3', '4812-4', '4812-5', '4812-6', '4812-7', '4812-8'], -}; - -/* -var g_zone_areas = {}; -in locale files -*/ \ No newline at end of file diff --git a/endpoints/aboutus/aboutus.php b/endpoints/aboutus/aboutus.php new file mode 100644 index 00000000..524dfc0c --- /dev/null +++ b/endpoints/aboutus/aboutus.php @@ -0,0 +1,34 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/account/account.php b/endpoints/account/account.php new file mode 100644 index 00000000..5c4ed8dd --- /dev/null +++ b/endpoints/account/account.php @@ -0,0 +1,174 @@ +forwardToSignIn('account'); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + array_unshift($this->title, Lang::account('settings')); + + $user = DB::Aowow()->selectRow('SELECT `debug`, `email`, `description`, `avatar`, `wowicon`, `renameCooldown` FROM ::account WHERE `id` = %i', User::$id); + + Lang::sort('game', 'ra'); + + parent::generate(); + + + /*************/ + /* Ban Popup */ + /*************/ + + $b = DB::Aowow()->selectAssoc( + 'SELECT ab.`end` AS "0", ab.`reason` AS "1", a.`username` AS "2" + FROM ::account_banned ab + LEFT JOIN ::account a ON a.`id` = ab.`staffId` + WHERE ab.`userId` = %i AND ab.`typeMask` & %i AND (ab.`end` = 0 OR ab.`end` > UNIX_TIMESTAMP())', + User::$id, ACC_BAN_TEMP | ACC_BAN_PERM + ); + + $this->bans = $b ?: null; + + + /*******************/ + /* Status Messages */ + /*******************/ + + if (isset($_SESSION['msg'])) + { + [$var, $status, $msg] = $_SESSION['msg']; + if (property_exists($this, $var.'Message')) + $this->{$var.'Message'} = [$status, $msg]; + else + trigger_error('AccountBaseResponse::generate - unknown var in $_SESSION msg: '.$var, E_USER_WARNING); + + unset($_SESSION['msg']); + } + + + /*************/ + /* Form Data */ + /*************/ + + /* GENERAL */ + + // Modelviewer + if ($_ = DB::Aowow()->selectCell('SELECT `data` FROM ::account_cookies WHERE `name` = %s AND `userId` = %i', 'default_3dmodel', User::$id)) + [$this->modelrace, $this->modelgender] = explode(',', $_); + + // Lists + $this->idsInLists = $user['debug'] ? 1 : 0; + + /* PERSONAL */ + + // Email address + $this->curEmail = $user['email'] ?? ''; + + // Username + $this->curName = User::$username; + $this->renameCD = DateTime::formatTimeElapsedFloat(Cfg::get('ACC_RENAME_DECAY') * 1000); + if ($user['renameCooldown'] > time()) + { + $locCode = substr_replace(Lang::getLocale()->json(), '_', 2, 0); // ._. + $this->activeCD = (new \IntlDateFormatter($locCode, pattern: Lang::main('dateFmtIntl')))->format($user['renameCooldown']); + } + + /* COMMUNITY */ + + // Public Description + $this->description = ['body' => $user['description']]; + + // Forum Signature + // $this->signature = ['body' => $user['signature']]; + + // Avatar + $this->wowicon = $user['wowicon']; + $this->avMode = $user['avatar']; + + /* PREMIUM */ + + $this->premium = User::isPremium(); + + if (!$this->premium) + return; + + // required by js to calc reputation border color in user selection + $this->reputation = User::getReputation(); + + // status [reviewing, ok, rejected]? (only 2: rejected processed in js) + // * 'when': uploaded timestamp expected as msec for some reason + // * 'caption': only used for getVisibleText, duplicates name? + // * 'type': always 1 ?, Dialog-popup doesn't work without it + if ($cuAvatars = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `id`, `name`, `name` AS "caption", `current`, `size`, `status`, `when` * 1000 AS "when", 1 AS "type" FROM ::account_avatars WHERE `userId` = %i', User::$id)) + { + foreach ($cuAvatars as $id => $a) + if ($a['status'] != AvatarMgr::STATUS_REJECTED) + $this->customicons[$id] = $a['name']; + + if ($id = array_find_key($cuAvatars, fn($x) => $x['current'] > 0 )) + $this->customicon = $id; + } + + // Avatar Manager + $this->avatarManager = new Listview([ + 'template' => 'avatar', + 'id' => 'avatar', + 'name' => '$LANG.tab_avatars', + 'parent' => 'avatar-manage', + 'hideNav' => 1 | 2, // top | bottom + 'data' => $cuAvatars ?? [], + 'note' => Lang::account('avatarSlots', [count($this->customicons), Cfg::get('acc_max_avatar_uploads')]) + ]); + + // Premium Border Selector + // solved by js + } +} + +?> diff --git a/endpoints/account/activate.php b/endpoints/account/activate.php new file mode 100644 index 00000000..e56fad9e --- /dev/null +++ b/endpoints/account/activate.php @@ -0,0 +1,73 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] + ); + + private bool $success = false; + + public function __construct() + { + parent::__construct(); + + if (!Cfg::get('ACC_ALLOW_REGISTER') || Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + } + + protected function generate() : void + { + $this->title[] = Lang::account('title'); + + $msg = $this->activate(); + + if ($this->success) + $this->inputbox = ['inputbox-status', ['head' => Lang::account('inputbox', 'head', 'register', [2]), 'message' => $msg]]; + else + { + $_SESSION['error']['activate'] = $msg; + $this->forward('?account=resend'); + } + + parent::generate(); + } + + private function activate() : string + { + if (!$this->assertGET('key')) + return Lang::main('intError'); + + if (DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE `status` IN %in AND `token` = %s', [ACC_STATUS_NONE, ACC_STATUS_NEW], $this->_get['key'])) + { + // don't remove the token yet. It's needed on signin page. + DB::Aowow()->qry('UPDATE ::account SET `status` = %i, `statusTimer` = 0, `userGroups` = %i WHERE `token` = %s', ACC_STATUS_NONE, U_GROUP_NONE, $this->_get['key']); + + // fully apply block for further registration attempts from this ip + DB::Aowow()->qry('REPLACE INTO ::account_bannedips (`ip`, `type`, `count`, `unbanDate`) VALUES (%s, %i, %i + 1, UNIX_TIMESTAMP() + %i)', + User::$ip, IP_BAN_TYPE_REGISTRATION_ATTEMPT, Cfg::get('ACC_FAILED_AUTH_COUNT'), Cfg::get('ACC_FAILED_AUTH_BLOCK')); + + $this->success = true; + return Lang::account('inputbox', 'message', 'accActivated', [$this->_get['key']]); + } + + // grace period expired and other user claimed name + return Lang::main('intError'); + } +} + +?> diff --git a/endpoints/account/confirm-delete.php b/endpoints/account/confirm-delete.php new file mode 100644 index 00000000..c0cdcbdc --- /dev/null +++ b/endpoints/account/confirm-delete.php @@ -0,0 +1,128 @@ + [FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] + ); + protected array $expectedPOST = array( + 'submit' => [FILTER_UNSAFE_RAW ], + 'cancel' => [FILTER_UNSAFE_RAW ], + 'confirm' => [FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ], + 'key' => [FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] + ); + + public bool $confirm = true; // just to select the correct localized brick + public string $username = ''; + public string $deleteFormTarget = '?account=confirm-delete'; + public ?array $inputbox = null; + public string $key = ''; + + private bool $success = false; + + public function __construct(string $rawParam) + { + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + array_unshift($this->title, Lang::account('accDelete')); + + $this->username = User::$username; + + parent::generate(); + + $msg = Lang::account('inputbox', 'error', 'purgeTokenUsed'); + + // display default confirm template + if ($this->assertGET('key') && DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE `status` = %i AND `statusTimer` > UNIX_TIMESTAMP() AND `token` = %s', ACC_STATUS_PURGING, $this->_get['key'])) + { + $this->key = $this->_get['key']; + return; + } + + // perform action and display status + if ($this->assertPOST('key') && ($userId = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE `status` = %i AND `statusTimer` > UNIX_TIMESTAMP() AND `token` = %s', ACC_STATUS_PURGING, $this->_post['key']))) + { + if ($this->_post['cancel']) + $msg = $this->cancel($userId); + else if ($this->_post['submit'] && $this->_post['confirm']) + $msg = $this->purge($userId); + } + + // throw error and display in status + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', $this->success ? 'success' : 'error'), + 'message' => $this->success ? $msg : '', + 'error' => $this->success ? '' : $msg + )]; + } + + private function cancel(int $userId) : string + { + if (DB::Aowow()->qry('UPDATE ::account SET `status` = %i, `statusTimer` = 0, `token` = "" WHERE `id` = %i', ACC_STATUS_NONE, $userId)) + { + $this->success = true; + return Lang::account('inputbox', 'message', 'deleteCancel'); + } + + return Lang::main('intError'); + } + + private function purge(int $userId) : string + { + // empty all user settings and cookies + DB::Aowow()->qry('DELETE FROM ::account_cookies WHERE `userId` = %i', $userId); + DB::Aowow()->qry('DELETE FROM ::account_avatars WHERE `userId` = %i', $userId); + DB::Aowow()->qry('DELETE FROM ::account_excludes WHERE `userId` = %i', $userId); + DB::Aowow()->qry('DELETE FROM ::account_favorites WHERE `userId` = %i', $userId); + DB::Aowow()->qry('DELETE FROM ::account_reputation WHERE `userId` = %i', $userId); + DB::Aowow()->qry('DELETE FROM ::account_weightscales WHERE `userId` = %i', $userId); // cascades to aowow_account_weightscale_data + + // delete profiles, unlink chars + DB::Aowow()->qry('DELETE pp FROM ::profiler_profiles pp JOIN ::account_profiles ap ON ap.`profileId` = pp.`id` WHERE ap.`accountId` = %i', $userId); + // DB::Aowow()->qry('DELETE FROM ::account_profiles WHERE `accountId` = %i', $userId); // already deleted via FK? + + // delete all sessions and bans + DB::Aowow()->qry('DELETE FROM ::account_banned WHERE `userId` = %i', $userId); + DB::Aowow()->qry('DELETE FROM ::account_sessions WHERE `userId` = %i', $userId); + + // delete forum posts (msg: This post was from a user who has deleted their account. (no translations at src); comments/replies are unaffected) + // ... + + // replace username with userId and empty fields + DB::Aowow()->qry( + 'UPDATE ::account SET + `login` = "", `passHash` = "", `username` = `id`, `email` = NULL, `userGroups` = 0, `userPerms` = 0, + `curIp` = "", `prevIp` = "", `curLogin` = 0, `prevLogin` = 0, + `locale` = 0, `debug` = 0, `avatar` = 0, `wowicon` = "", `title` = "", `description` = "", `excludeGroups` = 0, + `status` = %i, `statusTimer` = 0, `token` = "", `updateValue` = "", `renameCooldown` = 0 + WHERE `id` = %i', + ACC_STATUS_DELETED, $userId + ); + + $this->success = true; + return Lang::account('inputbox', 'message', 'deleteOk'); + } +} + +?> diff --git a/endpoints/account/confirm-email-address.php b/endpoints/account/confirm-email-address.php new file mode 100644 index 00000000..05a4f217 --- /dev/null +++ b/endpoints/account/confirm-email-address.php @@ -0,0 +1,62 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] + ); + + private bool $success = false; + + protected function generate() : void + { + parent::generate(); + + if (User::isBanned()) + return; + + $msg = $this->change(); + + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', $this->success ? 'success' : 'error'), + 'message' => $this->success ? $msg : '', + 'error' => $this->success ? '' : $msg, + )]; + } + + // this should probably leave change info intact for revert + // todo - move personal settings changes to separate table + private function change() : string + { + if (!$this->assertGET('key')) + return Lang::main('intError'); + + $acc = DB::Aowow()->selectRow('SELECT `updateValue`, `status`, `statusTimer` FROM ::account WHERE `token` = %s', $this->_get['key']); + if (!$acc || $acc['status'] != ACC_STATUS_CHANGE_EMAIL || $acc['statusTimer'] < time()) + return Lang::account('inputbox', 'error', 'mailTokenUsed'); + + // 0 changes == error + if (!DB::Aowow()->qry('UPDATE ::account SET `email` = `updateValue`, `status` = %i, `statusTimer` = 0, `token` = "", `updateValue` = "" WHERE `token` = %s', ACC_STATUS_NONE, $this->_get['key'])) + return Lang::main('intError'); + + $this->success = true; + return Lang::account('inputbox', 'message', 'mailChangeOk'); + } +} + +?> diff --git a/endpoints/account/confirm-password.php b/endpoints/account/confirm-password.php new file mode 100644 index 00000000..bc972f70 --- /dev/null +++ b/endpoints/account/confirm-password.php @@ -0,0 +1,60 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] + ); + + private bool $success = false; + + protected function generate() : void + { + parent::generate(); + + if (User::isBanned()) + return; + + $msg = $this->confirm(); + + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', $this->success ? 'success' : 'error'), + 'message' => $this->success ? $msg : '', + 'error' => $this->success ? '' : $msg, + )]; + } + + private function confirm() : string + { + if (!$this->assertGET('key')) + return Lang::main('intError'); + + $acc = DB::Aowow()->selectRow('SELECT `updateValue`, `status`, `statusTimer` FROM ::account WHERE `token` = %s', $this->_get['key']); + if (!$acc || $acc['status'] != ACC_STATUS_CHANGE_PASS || $acc['statusTimer'] < time()) + return Lang::account('inputbox', 'error', 'passTokenUsed'); + + // 0 changes == error + if (!DB::Aowow()->qry('UPDATE ::account SET `passHash` = `updateValue`, `status` = %i, `statusTimer` = 0, `token` = "", `updateValue` = "" WHERE `token` = %s', ACC_STATUS_NONE, $this->_get['key'])) + return Lang::main('intError'); + + $this->success = true; + return Lang::account('inputbox', 'message', 'passChangeOk'); + } +} + +?> diff --git a/endpoints/account/delete-icon.php b/endpoints/account/delete-icon.php new file mode 100644 index 00000000..4e71147b --- /dev/null +++ b/endpoints/account/delete-icon.php @@ -0,0 +1,47 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + /* + * response not evaluated + */ + protected function generate() : void + { + if (User::isBanned() || !$this->assertPOST('id')) + return; + + // non-int > error + $selected = DB::Aowow()->selectCell('SELECT `current` FROM ::account_avatars WHERE `id` = %i AND `userId` = %i', $this->_post['id'], User::$id); + if ($selected === null || $selected === false) + return; + + DB::Aowow()->qry('DELETE FROM ::account_avatars WHERE `id` = %i AND `userId` = %i', $this->_post['id'], User::$id); + + // if deleted avatar is also currently selected, unset + if ($selected) + DB::Aowow()->qry('UPDATE ::account SET `avatar` = 0 WHERE `id` = %i', User::$id); + + $path = sprintf('static/uploads/avatars/%d.jpg', $this->_post['id']); + if (!unlink($path)) + trigger_error('AccountDeleteiconResponse - failed to delete file: '.$path, E_USER_ERROR); + } +} + +?> diff --git a/endpoints/account/delete.php b/endpoints/account/delete.php new file mode 100644 index 00000000..04a46e2b --- /dev/null +++ b/endpoints/account/delete.php @@ -0,0 +1,71 @@ + ['filter' => FILTER_UNSAFE_RAW] + ); + + public string $username = ''; + public string $deleteFormTarget = '?account=delete'; + public ?array $inputbox = null; + + public function __construct(string $rawParam) + { + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + array_unshift($this->title, Lang::account('accDelete')); + + parent::generate(); + + $this->username = User::$username; + + if ($this->_post['proceed']) + { + $error = false; + if (!DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE `status` NOT IN %in AND `statusTimer` > UNIX_TIMESTAMP() AND `id` = %i', [ACC_STATUS_NEW, ACC_STATUS_NONE, ACC_STATUS_PURGING], User::$id)) + { + $token = Util::createHash(40); + + DB::Aowow()->qry('UPDATE ::account SET `status` = %i, `statusTimer` = UNIX_TIMESTAMP() + %i, `token` = %s WHERE `id` = %i', + ACC_STATUS_PURGING, Cfg::get('ACC_RECOVERY_DECAY'), $token, User::$id); + + Util::sendMail(User::$email, 'delete-account', [$token, User::$email, User::$username]); + } + else + $error = true; + + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', $error ? 'error' : 'success'), + 'message' => $error ? '' : Lang::account('inputbox', 'message', 'deleteAccSent', [User::$email]), + 'error' => $error ? Lang::account('inputbox', 'error', 'isRecovering') : '' + )]; + } + } +} + +?> diff --git a/endpoints/account/exclude.php b/endpoints/account/exclude.php new file mode 100644 index 00000000..17a3a032 --- /dev/null +++ b/endpoints/account/exclude.php @@ -0,0 +1,81 @@ + ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 1]], + 'reset' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 1]], + 'id' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'type' => ['filter' => FILTER_VALIDATE_INT ], + 'groups' => ['filter' => FILTER_VALIDATE_INT ] + ); + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($this->_post['mode'] == 1) // directly set exludes + $this->excludeById(); + + else if ($this->_post['reset'] == 1) // defaults to unavailable + $this->resetExcludes(); + + else if ($this->_post['groups'] !== null) // exclude by group mask + $this->updateGroups(); + } + + private function excludeById() : void + { + if (!$this->assertPOST('type', 'id')) + return; + + if ($validIds = Type::validateIds($this->_post['type'], $this->_post['id'])) + { + // ready for some bullshit? here it comes! + // we don't get signaled whether an id should be added to or removed from either includes or excludes + // so we throw everything into one table and toggle the mode if its already in here + + $includes = DB::Aowow()->selectCol('SELECT `typeId` FROM ::profiler_excludes WHERE `type` = %i AND `typeId` IN %in', $this->_post['type'], $validIds); + $insert = []; + foreach ($validIds as $typeId) + { + $insert['userId'][] = User::$id; + $insert['type'][] = $this->_post['type']; + $insert['typeId'][] = $typeId; + $insert['mode'][] = in_array($typeId, $includes) ? Profiler::COMPLETION_INCLUDE : Profiler::COMPLETION_EXCLUDE; + }; + + DB::Aowow()->qry('INSERT INTO ::account_excludes %m ON DUPLICATE KEY UPDATE `mode` = (`mode` ^ 0x3)', $insert); + } + else + trigger_error('AccountExcludeResponse::excludeById - validation failed [type: '.$this->_post['type'].', typeId: '.implode(',', $this->_post['id']).']', E_USER_NOTICE); + } + + private function resetExcludes() : void + { + DB::Aowow()->qry('DELETE FROM ::account_excludes WHERE `userId` = %i', User::$id); + DB::Aowow()->qry('UPDATE ::account SET `excludeGroups` = %i WHERE `id` = %i', PR_EXCLUDE_GROUP_UNAVAILABLE, User::$id); + } + + private function updateGroups() : void + { + if ($this->assertPOST('groups')) // clamp to real groups + DB::Aowow()->qry('UPDATE ::account SET `excludeGroups` = %i WHERE `id` = %i', $this->_post['groups'] & PR_EXCLUDE_GROUP_ANY, User::$id); + } +} + +?> diff --git a/endpoints/account/favorites.php b/endpoints/account/favorites.php new file mode 100644 index 00000000..e459d253 --- /dev/null +++ b/endpoints/account/favorites.php @@ -0,0 +1,52 @@ + ['filter' => FILTER_VALIDATE_INT], + 'remove' => ['filter' => FILTER_VALIDATE_INT], + 'id' => ['filter' => FILTER_VALIDATE_INT], + // 'sessionKey' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] // usage of sessionKey omitted + ); + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($this->_post['remove']) + $this->removeFavorite(); + + else if ($this->_post['add']) + $this->addFavorite(); + } + + private function removeFavorite() : void + { + if ($this->assertPOST('id', 'remove')) + DB::Aowow()->qry('DELETE FROM ::account_favorites WHERE `userId` = %i AND `type` = %i AND `typeId` = %i', User::$id, $this->_post['remove'], $this->_post['id']); + } + + private function addFavorite() : void + { + if ($this->assertPOST('id', 'add') && Type::validateIds($this->_post['add'], $this->_post['id'])) + DB::Aowow()->qry('INSERT INTO ::account_favorites (`userId`, `type`, `typeId`) VALUES (%i, %i, %i)', User::$id, $this->_post['add'], $this->_post['id']); + else + trigger_error('AccountFavoritesResponse::addFavorite() - failed to add [userId: '.User::$id.', type: '.$this->_post['add'].', typeId: '.$this->_post['id'], E_USER_NOTICE); + } +} + +?> diff --git a/endpoints/account/forgot-password.php b/endpoints/account/forgot-password.php new file mode 100644 index 00000000..c0ab9581 --- /dev/null +++ b/endpoints/account/forgot-password.php @@ -0,0 +1,101 @@ + display email form + * 2. submit email form > send mail with recovery link + * 3. click recovery link from mail > display password reset form + * 4. submit password reset form > update password + */ + +class AccountforgotpasswordResponse extends TemplateResponse +{ + use TrRecoveryHelper, TrGetNext; + + protected string $template = 'text-page-generic'; + protected string $pageName = 'forgot-password'; + + protected array $expectedPOST = array( + 'email' => ['filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW] + ); + + private bool $success = false; + + public function __construct(string $rawParam) + { + // don't redirect logged in users + // you can be forgetful AND logged in + + if (Cfg::get('ACC_EXT_RECOVER_URL')) + $this->forward(Cfg::get('ACC_EXT_RECOVER_URL')); + + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->title[] = Lang::account('title'); + + parent::generate(); + + $msg = $this->processMailForm(); + + if ($this->success) + $this->inputbox = ['inputbox-status', ['head' => Lang::account('inputbox', 'head', 'recoverPass', [1.5]), 'message' => $msg]]; + else + $this->inputbox = ['inputbox-form-email', array( + 'head' => Lang::account('inputbox', 'head', 'recoverPass', [1]), + 'error' => $msg, + 'action' => '?account=forgot-password&next='.$this->getNext(), + 'email' => $this->_post['email'] ?? '' + )]; + } + + private function processMailForm() : string + { + // no input yet. show clean email form + if (is_null($this->_post['email'])) + return ''; + + // truncated due to validation fail + if (!$this->_post['email']) + return Lang::account('emailInvalid'); + + $timeout = DB::Aowow()->selectCell('SELECT `unbanDate` FROM ::account_bannedips WHERE `ip` = %s AND `type` = %i AND `count` > %i AND `unbanDate` > UNIX_TIMESTAMP()', User::$ip, IP_BAN_TYPE_PASSWORD_RECOVERY, Cfg::get('ACC_FAILED_AUTH_COUNT')); + + // on cooldown pretend we dont know the email address + if ($timeout && $timeout > time()) + return Cfg::get('DEBUG') ? 'resend on cooldown: '.DateTime::formatTimeElapsed($timeout * 1000).' remaining' : Lang::account('inputbox', 'error', 'emailNotFound'); + + // pretend recovery started + if (!DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE `email` = %s', $this->_post['email'])) + { + // do not confirm or deny existence of email + $this->success = !Cfg::get('DEBUG'); + return Cfg::get('DEBUG') ? Lang::account('inputbox', 'error', 'emailNotFound') : Lang::account('inputbox', 'message', 'recovPassSent', [$this->_post['email']]); + } + + // recovery actually started + if ($err = $this->startRecovery(ACC_STATUS_RECOVER_PASS, 'reset-password', $this->_post['email'])) + return $err; + + DB::Aowow()->qry('INSERT INTO ::account_bannedips (`ip`, `type`, `count`, `unbanDate`) VALUES (%s, %i, %i, UNIX_TIMESTAMP() + %i) ON DUPLICATE KEY UPDATE `count` = `count` + %i, `unbanDate` = UNIX_TIMESTAMP() + %i', + User::$ip, IP_BAN_TYPE_PASSWORD_RECOVERY, Cfg::get('ACC_FAILED_AUTH_COUNT') + 1, Cfg::get('ACC_FAILED_AUTH_COUNT'), Cfg::get('ACC_FAILED_AUTH_BLOCK'), Cfg::get('ACC_FAILED_AUTH_BLOCK')); + + $this->success = true; + return Lang::account('inputbox', 'message', 'recovPassSent', [$this->_post['email']]); + } +} + +?> diff --git a/endpoints/account/forgot-username.php b/endpoints/account/forgot-username.php new file mode 100644 index 00000000..c1cff916 --- /dev/null +++ b/endpoints/account/forgot-username.php @@ -0,0 +1,100 @@ + display email form + * 2. submit email form > send mail with recovery link + * ( 3. click recovery link from mail to go to signin page (so not on this page) ) + */ + +class AccountforgotusernameResponse extends TemplateResponse +{ + use TrRecoveryHelper; + + protected string $template = 'text-page-generic'; + protected string $pageName = 'forgot-username'; + + protected array $expectedPOST = array( + 'email' => ['filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW] + ); + + private bool $success = false; + + public function __construct(string $rawParam) + { + // if the user is looged in goto account dashboard + if (User::isLoggedIn()) + $this->forward('?account'); + + if (Cfg::get('ACC_EXT_RECOVER_URL')) + $this->forward(Cfg::get('ACC_EXT_RECOVER_URL')); + + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->title[] = Lang::account('title'); + + parent::generate(); + + $msg = $this->processMailForm(); + + if ($this->success) + $this->inputbox = ['inputbox-status', ['head' => Lang::account('inputbox', 'head', 'recoverUser'), 'message' => $msg]]; + else + $this->inputbox = ['inputbox-form-email', array( + 'head' => Lang::account('inputbox', 'head', 'recoverUser'), + 'error' => $msg, + 'action' => '?account=forgot-username' + )]; + } + + private function processMailForm() : string + { + // no input yet. show empty form + if (is_null($this->_post['email'])) + return ''; + + // truncated due to validation fail + if (!$this->_post['email']) + return Lang::account('emailInvalid'); + + $timeout = DB::Aowow()->selectCell('SELECT `unbanDate` FROM ::account_bannedips WHERE `ip` = %s AND `type` = %i AND `count` > %i AND `unbanDate` > UNIX_TIMESTAMP()', User::$ip, IP_BAN_TYPE_USERNAME_RECOVERY, Cfg::get('ACC_FAILED_AUTH_COUNT')); + + // on cooldown pretend we dont know the email address + if ($timeout && $timeout > time()) + return Cfg::get('DEBUG') ? 'resend on cooldown: '.DateTime::formatTimeElapsed($timeout * 1000).' remaining' : Lang::account('inputbox', 'error', 'emailNotFound'); + + // pretend recovery started + if (!DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE `email` = %s', $this->_post['email'])) + { + // do not confirm or deny existence of email + $this->success = !Cfg::get('DEBUG'); + return Cfg::get('DEBUG') ? Lang::account('inputbox', 'error', 'emailNotFound') : Lang::account('inputbox', 'message', 'recovUserSent', [$this->_post['email']]); + } + + // recovery actually started + if ($err = $this->startRecovery(ACC_STATUS_RECOVER_USER, 'recover-user', $this->_post['email'])) + return $err; + + DB::Aowow()->qry('INSERT INTO ::account_bannedips (`ip`, `type`, `count`, `unbanDate`) VALUES (%s, %i, %i, UNIX_TIMESTAMP() + %i) ON DUPLICATE KEY UPDATE `count` = `count` + %i, `unbanDate` = UNIX_TIMESTAMP() + %i', + User::$ip, IP_BAN_TYPE_USERNAME_RECOVERY, Cfg::get('ACC_FAILED_AUTH_COUNT') + 1, Cfg::get('ACC_FAILED_AUTH_COUNT'), Cfg::get('ACC_FAILED_AUTH_BLOCK'), Cfg::get('ACC_FAILED_AUTH_BLOCK')); + + $this->success = true; + return Lang::account('inputbox', 'message', 'recovUserSent', [$this->_post['email']]); + } +} + +?> diff --git a/endpoints/account/forum-avatar.php b/endpoints/account/forum-avatar.php new file mode 100644 index 00000000..e7bd9840 --- /dev/null +++ b/endpoints/account/forum-avatar.php @@ -0,0 +1,108 @@ + ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 0, 'max_range' => 2 ]], + 'wowicon' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/' ]], // file name can have \W chars: inv_misc_fork&knife, achievement_dungeon_drak'tharon_heroic + 'customicon' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1 ]] + ); + // called via ajax + protected array $expectedGET = array( + 'avatar' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 2, 'max_range' => 2]], + 'customicon' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1 ]] + ); + + private bool $success = false; + + protected function generate() : void + { + if (User::isBanned()) + return; + + $msg = match ($this->_post['avatar'] ?? $this->_get['avatar']) + { + 0 => $this->unset(), // none + 1 => $this->fromIcon(), // wow icon + 2 => $this->fromUpload(!$this->_get['avatar']), // custom icon (premium feature) + default => Lang::main('genericError') + }; + + if ($msg) + $_SESSION['msg'] = ['avatar', $this->success, $msg]; + } + + private function unset() : string + { + $x = DB::Aowow()->qry('UPDATE ::account SET `avatar` = 0 WHERE `id` = %i', User::$id); + if ($x === null || $x === false) + return Lang::main('genericError'); + + $this->success = true; + + return Lang::account('updateMessage', $x === 0 ? 'avNoChange' : 'avSuccess'); + } + + private function fromIcon() : string + { + if (!$this->assertPOST('wowicon')) + return Lang::main('intError'); + + $icon = strtolower(trim($this->_post['wowicon'])); + + if (!DB::Aowow()->selectCell('SELECT 1 FROM ::icons WHERE `name` = %s', $icon)) + return Lang::account('updateMessage', 'avNotFound'); + + $x = DB::Aowow()->qry('UPDATE ::account SET `avatar` = 1, `wowicon` = %s WHERE `id` = %i', $icon, User::$id); + if (is_null($x)) + return Lang::main('genericError'); + + $this->success = true; + + $msg = Lang::account('updateMessage', $x === 0 ? 'avNoChange' : 'avSuccess'); + if (($qty = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::account WHERE `wowicon` = %s', $icon)) > 1) + $msg .= ' '.Lang::account('updateMessage', 'avNthUser', [$qty]); + else + $msg .= ' '.Lang::account('updateMessage', 'av1stUser'); + + return $msg; + } + + protected function fromUpload(bool $viaPOST) : string + { + if (!User::isPremium()) + return Lang::main('genericError'); + + if (($viaPOST && !$this->assertPOST('customicon')) || (!$viaPOST && !$this->assertGET('customicon'))) + return Lang::main('intError'); + + $customIcon = $this->_post['customicon'] ?? $this->_get['customicon']; + + $x = DB::Aowow()->qry('UPDATE ::account_avatars SET `current` = IF(`id` = %i, 1, 0) WHERE `userId` = %i AND `status` <> %i', $customIcon, User::$id, AvatarMgr::STATUS_REJECTED); + if (!is_int($x)) + return Lang::main('genericError'); + + if (!is_int(DB::Aowow()->qry('UPDATE ::account SET `avatar` = 2 WHERE `id` = %i', User::$id))) + return Lang::main('intError'); + + $this->success = true; + + return Lang::account('updateMessage', $x === 0 ? 'avNoChange' : 'avSuccess'); + } +} + +?> diff --git a/endpoints/account/premium-border.php b/endpoints/account/premium-border.php new file mode 100644 index 00000000..7374a210 --- /dev/null +++ b/endpoints/account/premium-border.php @@ -0,0 +1,41 @@ + ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 0, 'max_range' => 4]], + ); + + protected function generate() : void + { + if (User::isBanned()) + return; + + if (!$this->assertPOST('avatarborder')) + return; + + $x = DB::Aowow()->qry('UPDATE ::account SET `avatarborder` = %i WHERE `id` = %i', $this->_post['avatarborder'], User::$id); + if (is_null($x)) + $_SESSION['msg'] = ['premiumborder', false, Lang::main('genericError')]; + else if (!$x) + $_SESSION['msg'] = ['premiumborder', true, Lang::account('updateMessage', 'avNoChange')]; + else + $_SESSION['msg'] = ['premiumborder', true, Lang::account('updateMessage', 'avSuccess')]; + } +} + +?> diff --git a/endpoints/account/rename-icon.php b/endpoints/account/rename-icon.php new file mode 100644 index 00000000..c88ff2bb --- /dev/null +++ b/endpoints/account/rename-icon.php @@ -0,0 +1,36 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'name' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' =>'/^[a-zA-Z][a-zA-Z0-9 ]{0,19}$/']] + ); + + /* + * response not evaluated + */ + protected function generate() : void + { + if (User::isBanned() || !$this->assertPOST('id', 'name')) + return; + + // regexp same as in account.js + DB::Aowow()->qry('UPDATE ::account_avatars SET `name` = %s WHERE `id` = %i AND `userId` = %i', trim($this->_post['name']), $this->_post['id'], User::$id); + } +} + +?> diff --git a/endpoints/account/resend-submit.php b/endpoints/account/resend-submit.php new file mode 100644 index 00000000..28fe9c51 --- /dev/null +++ b/endpoints/account/resend-submit.php @@ -0,0 +1,52 @@ + ['filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW] + ); + + public function __construct(string $rawParam) + { + if (!Cfg::get('ACC_ALLOW_REGISTER') || Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->title[] = Lang::account('title'); + + $error = $message = ''; + + if ($this->assertPOST('email')) + $message = Lang::account('inputbox', 'message', 'createAccSent', [$this->_post['email']]); + else + $error = Lang::main('intError'); + + parent::generate(); + + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', 'register', [1.5]), + 'message' => $message, + 'error' => $error + )]; + } +} + +?> diff --git a/endpoints/account/resend.php b/endpoints/account/resend.php new file mode 100644 index 00000000..7c1fecbf --- /dev/null +++ b/endpoints/account/resend.php @@ -0,0 +1,98 @@ + ['filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW] + ); + + private bool $success = false; + + public function __construct(string $rawParam) + { + if (Cfg::get('ACC_EXT_RECOVER_URL')) + $this->forward(Cfg::get('ACC_EXT_RECOVER_URL')); + + if (!Cfg::get('ACC_ALLOW_REGISTER') || Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->title[] = Lang::account('title'); + + parent::generate(); + + // error from account=activate + if (isset($_SESSION['error']['activate'])) + { + $msg = $_SESSION['error']['activate']; + unset($_SESSION['error']['activate']); + } + else + $msg = $this->resend(); + + if ($this->success) + $this->inputbox = ['inputbox-status', ['head' => Lang::account('inputbox', 'head', 'resendMail'), 'message' => $msg]]; + else + $this->inputbox = ['inputbox-form-email', array( + 'head' => Lang::account('inputbox', 'head', 'resendMail'), + 'message' => Lang::account('inputbox', 'message', 'resendMail'), + 'error' => $msg, + 'action' => '?account=resend', + )]; + } + + private function resend() : string + { + // no input yet. show clean form + if (is_null($this->_post['email'])) + return ''; + + // truncated due to validation fail + if (!$this->_post['email']) + return Lang::account('emailInvalid'); + + $timeout = DB::Aowow()->selectCell('SELECT `unbanDate` FROM ::account_bannedips WHERE `ip` = %s AND `type` = %i AND `count` > %i AND `unbanDate` > UNIX_TIMESTAMP()', User::$ip, IP_BAN_TYPE_REGISTRATION_ATTEMPT, Cfg::get('ACC_FAILED_AUTH_COUNT')); + + // on cooldown pretend we dont know the email address + if ($timeout && $timeout > time()) + return Cfg::get('DEBUG') ? 'resend on cooldown: '.DateTime::formatTimeElapsed($timeout * 1000).' remaining' : Lang::account('inputbox', 'error', 'emailNotFound'); + + // check email and account status + if ($token = DB::Aowow()->selectCell('SELECT `token` FROM ::account WHERE `email` = %s AND `status` = %i', $this->_post['email'], ACC_STATUS_NEW)) + { + if (!Util::sendMail($this->_post['email'], 'activate-account', [$token])) + return Lang::main('intError'); + + DB::Aowow()->qry('INSERT INTO ::account_bannedips (`ip`, `type`, `count`, `unbanDate`) VALUES (%s, %i, %i, UNIX_TIMESTAMP() + %i) ON DUPLICATE KEY UPDATE `count` = `count` + %i, `unbanDate` = UNIX_TIMESTAMP() + %i', + User::$ip, IP_BAN_TYPE_REGISTRATION_ATTEMPT, Cfg::get('ACC_FAILED_AUTH_COUNT') + 1, Cfg::get('ACC_FAILED_AUTH_COUNT'), Cfg::get('ACC_FAILED_AUTH_BLOCK'), Cfg::get('ACC_FAILED_AUTH_BLOCK')); + + $this->success = true; + return Lang::account('inputbox', 'message', 'createAccSent', [$this->_post['email']]); + } + + // pretend recovery started + // do not confirm or deny existence of email + $this->success = !Cfg::get('DEBUG'); + return Cfg::get('DEBUG') ? Lang::account('inputbox', 'error', 'emailNotFound') : Lang::account('inputbox', 'message', 'createAccSent', [$this->_post['email']]); + } +} + +?> diff --git a/endpoints/account/reset-password.php b/endpoints/account/reset-password.php new file mode 100644 index 00000000..4d71153f --- /dev/null +++ b/endpoints/account/reset-password.php @@ -0,0 +1,121 @@ + display email form + * 2. submit email form > send mail with recovery link + * 3. click recovery link from mail > display password reset form + * 4. submit password reset form > update password + */ + +class AccountresetpasswordResponse extends TemplateResponse +{ + use TrRecoveryHelper, TrGetNext; + + protected string $template = 'text-page-generic'; + protected string $pageName = 'reset-password'; + + protected array $expectedGET = array( + 'key' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']], + 'next' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/' ]] + ); + protected array $expectedPOST = array( + 'key' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']], + 'email' => ['filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW ], + 'password' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'c_password' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ] + ); + + private bool $success = false; + + public function __construct() + { + $this->title[] = Lang::account('title'); + + parent::__construct(); + + // don't redirect logged in users + // you can be forgetful AND logged in + + if (Cfg::get('ACC_EXT_RECOVER_URL')) + $this->forward(Cfg::get('ACC_EXT_RECOVER_URL')); + + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + } + + protected function generate() : void + { + parent::generate(); + + $errMsg = ''; + if (!$this->assertGET('key') && !$this->assertPOST('key')) + $errMsg = Lang::account('inputbox', 'error', 'passTokenLost'); + else if ($this->_get['key'] && !DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE `token` = %s AND `status` = %i AND `statusTimer` > UNIX_TIMESTAMP()', $this->_get['key'], ACC_STATUS_RECOVER_PASS)) + $errMsg = Lang::account('inputbox', 'error', 'passTokenUsed'); + + if ($errMsg) + { + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', 'error'), + 'error' => $errMsg + )]; + + return; + } + + // step "2.5" + $errMsg = $this->doResetPass(); + if ($this->success) + $this->forward('?account=signin'); + + // step 2 + $this->inputbox = ['inputbox-form-password', array( + 'head' => Lang::account('inputbox', 'head', 'recoverPass', [2]), + 'token' => $this->_post['key'] ?? $this->_get['key'], + 'action' => '?account=reset-password&next=account=signin', + 'error' => $errMsg, + )]; + } + + private function doResetPass() : string + { + // no input yet. show clean form + if (!$this->assertPOST('key', 'password', 'c_password') && is_null($this->_post['email'])) + return ''; + + // truncated due to validation fail + if (!$this->_post['email']) + return Lang::account('emailInvalid'); + + if ($this->_post['password'] != $this->_post['c_password']) + return Lang::account('passCheckFail'); + + $userData = DB::Aowow()->selectRow('SELECT `id`, `passHash` FROM ::account WHERE `token` = %s AND `email` = %s AND `status` = %i AND `statusTimer` > UNIX_TIMESTAMP()', + $this->_post['key'], + $this->_post['email'], + ACC_STATUS_RECOVER_PASS + ); + if (!$userData) + return Lang::account('inputbox', 'error', 'emailNotFound'); + + if (!User::verifyCrypt($this->_post['c_password'], $userData['passHash'])) + return Lang::account('newPassDiff'); + + if (!DB::Aowow()->qry('UPDATE ::account SET `passHash` = %s, `status` = %i WHERE `id` = %i', User::hashCrypt($this->_post['c_password']), ACC_STATUS_NONE, $userData['id'])) + return Lang::main('intError'); + + $this->success = true; + return ''; + } +} + +?> diff --git a/endpoints/account/revert-email-address.php b/endpoints/account/revert-email-address.php new file mode 100644 index 00000000..106fb6f0 --- /dev/null +++ b/endpoints/account/revert-email-address.php @@ -0,0 +1,62 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']] + ); + + private bool $success = false; + + protected function generate() : void + { + parent::generate(); + + if (User::isBanned()) + return; + + $msg = $this->revert(); + + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', $this->success ? 'success' : 'error'), + 'message' => $this->success ? $msg : '', + 'error' => $this->success ? '' : $msg, + )]; + } + + // this should probably take precedence over email-change + // todo - move personal settings changes to separate table + private function revert() : string + { + if (!$this->assertGET('key')) + return Lang::main('intError'); + + $acc = DB::Aowow()->selectRow('SELECT `updateValue`, `status`, `statusTimer` FROM ::account WHERE `token` = %s', $this->_get['key']); + if (!$acc || $acc['status'] != ACC_STATUS_CHANGE_EMAIL || $acc['statusTimer'] < time()) + return Lang::account('inputbox', 'error', 'mailTokenUsed'); + + // 0 changes == error + if (!DB::Aowow()->qry('UPDATE ::account SET `status` = %i, `statusTimer` = 0, `token` = "", `updateValue` = "" WHERE `token` = %s', ACC_STATUS_NONE, $this->_get['key'])) + return Lang::main('intError'); + + $this->success = true; + return Lang::account('inputbox', 'message', 'mailRevertOk'); + } +} + +?> diff --git a/endpoints/account/signin.php b/endpoints/account/signin.php new file mode 100644 index 00000000..ed8fbf07 --- /dev/null +++ b/endpoints/account/signin.php @@ -0,0 +1,148 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateLogin'] ], + 'password' => ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validatePassword']], + 'remember_me' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkRememberMe'] ] + ); + protected array $expectedGET = array( + 'key' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[a-zA-Z0-9]{40}$/']], + 'next' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/'] ] + ); + + private bool $success = false; + + public function __construct() + { + // if the user is logged in, goto user dashboard + if (User::isLoggedIn()) + $this->forward('?user='.User::$username); + + parent::__construct(); + } + + protected function generate() : void + { + $username = + $error = ''; + $rememberMe = !!$this->_post['remember_me']; + + $this->title = [Lang::account('title')]; + + // coming from user recovery or creation, prefill username + if ($this->_get['key']) + { + if ($userData = DB::Aowow()->selectRow('SELECT a.`login` AS "0", IF(s.`expires`, 0, 1) AS "1" FROM ::account a LEFT JOIN ::account_sessions s ON a.`id` = s.`userId` AND a.`token` = s.`sessionId` WHERE a.`status` IN %in AND a.`token` = %s', + [ACC_STATUS_RECOVER_USER, ACC_STATUS_NONE], $this->_get['key'])) + [$username, $rememberMe] = $userData; + } + + if ($this->doSignIn($error)) + $this->forward($this->getNext(true)); + + if ($error) + User::destroy(); + + $this->inputbox = ['inputbox-form-signin', array( + 'head' => Lang::account('inputbox', 'head', 'signin'), + 'action' => '?account=signin&next='.$this->getNext(), + 'error' => $error, + 'username' => $username, + 'rememberMe' => $rememberMe, + 'hasRecovery' => Cfg::get('ACC_EXT_RECOVER_URL') || Cfg::get('ACC_AUTH_MODE') == AUTH_MODE_SELF, + )]; + + parent::generate(); + } + + private function doSignIn(string &$error) : bool + { + if (is_null($this->_post['username']) && is_null($this->_post['password'])) + return false; + + if (!$this->assertPOST('username')) + { + $error = Lang::account('userNotFound'); + return false; + } + + if (!$this->assertPOST('password')) + { + $error = Lang::account('wrongPass'); + return false; + } + + $error = match (User::authenticate($this->_post['username'], $this->_post['password'])) + { + AUTH_OK, AUTH_BANNED => $this->onAuthSuccess(), + // AUTH_BANNED => Lang::account('accBanned'); // ToDo: should this return an error? the actual account functionality should be blocked elsewhere + AUTH_WRONGUSER => Lang::account('userNotFound'), + AUTH_WRONGPASS => Lang::account('wrongPass'), + AUTH_IPBANNED => Lang::account('inputbox', 'error', 'loginExceeded', [DateTime::formatTimeElapsedFloat(Cfg::get('ACC_FAILED_AUTH_BLOCK') * 1000)]), + AUTH_INTERNAL_ERR => Lang::main('intError'), + default => Lang::main('intError') + }; + + return !$error; + } + + private function onAuthSuccess() : string + { + if (!User::$ip) + { + trigger_error('AccountSigninResponse::onAuthSuccess() - tried to login user without ip set', E_USER_ERROR); + return Lang::main('intError'); + } + + // reset account status, update expiration + $ok = DB::Aowow()->qry('UPDATE ::account SET `prevIP` = IF(`curIp` = %s, `prevIP`, `curIP`), `curIP` = IF(`curIp` = %s, `curIP`, %s), `status` = IF(`status` = %i, `status`, 0), `statusTimer` = IF(`status` = %i, `statusTimer`, 0), `token` = IF(`status` = %i, `token`, "") WHERE `id` = %i', + User::$ip, User::$ip, User::$ip, + ACC_STATUS_NEW, ACC_STATUS_NEW, ACC_STATUS_NEW, + User::$id // available after successful User:authenticate + ); + + if (!is_int($ok)) // num updated fields or null on fail + { + trigger_error('AccountSigninResponse::onAuthSuccess() - failed to update account status', E_USER_ERROR); + return Lang::main('intError'); + } + + // DELETE temp session + if ($this->_get['key']) + DB::Aowow()->qry('DELETE FROM ::account_sessions WHERE `sessionId` = %s', $this->_get['key']); + + session_regenerate_id(true); // user status changed => regenerate id + + // create new session entry + DB::Aowow()->qry('INSERT INTO ::account_sessions (`userId`, `sessionId`, `created`, `expires`, `touched`, `deviceInfo`, `ip`, `status`) VALUES (%i, %s, %i, %i, %i, %s, %s, %i)', + User::$id, session_id(), time(), $this->_post['remember_me'] ? 0 : time() + Cfg::get('SESSION_TIMEOUT_DELAY'), time(), User::$agent, User::$ip, SESSION_ACTIVE); + + if (User::init()) // reinitialize the user + User::save(); + + return ''; + } +} + +?> diff --git a/endpoints/account/signout.php b/endpoints/account/signout.php new file mode 100644 index 00000000..73374d1b --- /dev/null +++ b/endpoints/account/signout.php @@ -0,0 +1,40 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/']], + 'global' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ] + ); + + public function __construct(string $rawParam) + { + // if the user not is logged in goto login page + if (!User::isLoggedIn()) + $this->forwardToSignIn(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + if ($this->_get['global']) + DB::Aowow()->qry('UPDATE ::account_sessions SET `touched` = %i, `status` = %i WHERE `userId` = %i', time(), SESSION_FORCED_LOGOUT, User::$id); + else + DB::Aowow()->qry('UPDATE ::account_sessions SET `touched` = %i, `status` = %i WHERE `sessionId` = %s', time(), SESSION_LOGOUT, session_id()); + + User::destroy(); + + $this->redirectTo = $this->getNext(true); + } +} + +?> diff --git a/endpoints/account/signup.php b/endpoints/account/signup.php new file mode 100644 index 00000000..2fa29278 --- /dev/null +++ b/endpoints/account/signup.php @@ -0,0 +1,163 @@ + ['filter' => FILTER_SANITIZE_SPECIAL_CHARS, 'flags' => FILTER_FLAG_STRIP_AOWOW ], + 'email' => ['filter' => FILTER_SANITIZE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW ], + 'password' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'c_password' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'remember_me' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkRememberMe']] + ); + + protected array $expectedGET = array( + 'next' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/']] + ); + + private bool $success = false; + + public function __construct() + { + // if the user is logged in goto account dashboard + if (User::isLoggedIn()) + $this->forward('?account'); + + // redirect to external registration page, if set + if (Cfg::get('ACC_EXT_CREATE_URL')) + $this->forward(Cfg::get('ACC_EXT_CREATE_URL')); + + parent::__construct(); + + // registration not enabled on self + if (!Cfg::get('ACC_ALLOW_REGISTER')) + $this->generateError(); + + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + $this->generateError(); + } + + protected function generate() : void + { + $this->title[] = Lang::account('title'); + + // step 1 - no params > signup form + // step 2 - any param > status box + // step 3 - on ?account=activate + + $message = $this->doSignUp(); + + if ($this->success) + { + $this->inputbox = ['inputbox-status', array( + 'head' => Lang::account('inputbox', 'head', 'register', [1.5]), + 'message' => Lang::account('inputbox', 'message', 'createAccSent', [$this->_post['email']]) + )]; + } + else + { + $this->inputbox = ['inputbox-form-signup', array( + 'head' => Lang::account('inputbox', 'head', 'register', [1]), + 'error' => $message, + 'action' => '?account=signup&next='.$this->getNext(), + 'username' => $this->_post['username'] ?? '', + 'email' => $this->_post['email'] ?? '', + 'rememberMe' => !!$this->_post['remember_me'], + )]; + } + + parent::generate(); + } + + private function doSignUp() : string + { + // no input yet. show clean form + if (!$this->assertPOST('username', 'password', 'c_password') && is_null($this->_post['email'])) + return ''; + + // truncated due to validation fail + if (!$this->_post['email']) + return Lang::account('emailInvalid'); + + // check username + if (!Util::validateUsername($this->_post['username'], $e)) + return Lang::account($e == 1 ? 'errNameLength' : 'errNameChars'); + + // check password + if (!Util::validatePassword($this->_post['password'], $e)) + return $e == 1 ? Lang::account('errPassLength') : Lang::main('intError'); + + if ($this->_post['password'] !== $this->_post['c_password']) + return Lang::account('passMismatch'); + + // check ip + if (!User::$ip) + return Lang::main('intError'); + + // limit account creation + if (DB::Aowow()->selectRow('SELECT 1 FROM ::account_bannedips WHERE `type` = %i AND `ip` = %s AND `count` >= %i AND `unbanDate` >= UNIX_TIMESTAMP()', IP_BAN_TYPE_REGISTRATION_ATTEMPT, User::$ip, Cfg::get('ACC_FAILED_AUTH_COUNT'))) + { + DB::Aowow()->qry('UPDATE ::account_bannedips SET `count` = `count` + 1, `unbanDate` = UNIX_TIMESTAMP() + %i WHERE `ip` = %s AND `type` = %i', Cfg::get('ACC_FAILED_AUTH_BLOCK'), User::$ip, IP_BAN_TYPE_REGISTRATION_ATTEMPT); + return Lang::account('inputbox', 'error', 'signupExceeded', [DateTime::formatTimeElapsedFloat(Cfg::get('ACC_FAILED_AUTH_BLOCK') * 1000)]); + } + + // username / email taken + if ($inUseData = DB::Aowow()->SelectRow('SELECT `id`, `username`, `status` = %i AND `statusTimer` < UNIX_TIMESTAMP() AS "expired" FROM ::account WHERE (LOWER(`username`) = LOWER(%s) OR LOWER(`email`) = LOWER(%s))', ACC_STATUS_NEW, $this->_post['username'], $this->_post['email'])) + { + if ($inUseData['expired']) + DB::Aowow()->qry('DELETE FROM ::account WHERE `id` = %i', $inUseData['id']); + else + return Util::lower($inUseData['username']) == Util::lower($this->_post['username']) ? Lang::account('nameInUse') : Lang::account('mailInUse'); + } + + // create.. + $token = Util::createHash(); + $userId = DB::Aowow()->qry('INSERT INTO ::account (`login`, `passHash`, `username`, `email`, `joindate`, `curIP`, `locale`, `userGroups`, `status`, `statusTimer`, `token`) VALUES (%s, %s, %s, %s, UNIX_TIMESTAMP(), %s, %i, %i, %i, UNIX_TIMESTAMP() + %i, %s)', + $this->_post['username'], + User::hashCrypt($this->_post['password']), + $this->_post['username'], + $this->_post['email'], + User::$ip, + Lang::getLocale()->value, + U_GROUP_PENDING, + ACC_STATUS_NEW, + Cfg::get('ACC_CREATE_SAVE_DECAY'), + $token + ); + + if (!$userId) + return Lang::main('intError'); + + // create session tied to the token to store remember_me status + DB::Aowow()->qry('INSERT INTO ::account_sessions (`userId`, `sessionId`, `created`, `expires`, `touched`, `deviceInfo`, `ip`, `status`) VALUES (%i, %s, %i, %i, %i, %s, %s, %i)', + $userId, $token, time(), $this->_post['remember_me'] ? 0 : time() + Cfg::get('SESSION_TIMEOUT_DELAY'), time(), User::$agent, User::$ip, SESSION_ACTIVE); + + if (!Util::sendMail($this->_post['email'], 'activate-account', [$token], Cfg::get('ACC_CREATE_SAVE_DECAY'))) + return Lang::main('intError2', ['send mail']); + + // success: update ip-bans + DB::Aowow()->qry('INSERT INTO ::account_bannedips (`ip`, `type`, `count`, `unbanDate`) VALUES (%s, %i, 1, UNIX_TIMESTAMP() + %i) ON DUPLICATE KEY UPDATE `count` = `count` + 1, `unbanDate` = UNIX_TIMESTAMP() + %i', + User::$ip, IP_BAN_TYPE_REGISTRATION_ATTEMPT, Cfg::get('ACC_FAILED_AUTH_BLOCK'), Cfg::get('ACC_FAILED_AUTH_BLOCK')); + + Util::gainSiteReputation($userId, SITEREP_ACTION_REGISTER); + + $this->success = true; + return ''; + } +} + +?> diff --git a/endpoints/account/update-community-settings.php b/endpoints/account/update-community-settings.php new file mode 100644 index 00000000..2bc1ed2d --- /dev/null +++ b/endpoints/account/update-community-settings.php @@ -0,0 +1,48 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']] + ); + + private bool $success = false; + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($message = $this->updateSettings()) + $_SESSION['msg'] = ['community', $this->success, $message]; + } + + protected function updateSettings() + { + if (is_null($this->_post['desc'])) // assertPOST tests for empty string which is valid here + return Lang::main('genericError'); + + // description - 0 modified rows is still success + if (!is_int(DB::Aowow()->qry('UPDATE ::account SET `description` = %s WHERE `id` = %i', $this->_post['desc'], User::$id))) + return Lang::main('genericError'); + + $this->success = true; + return Lang::account('updateMessage', 'community'); + } +} + +?> diff --git a/endpoints/account/update-email.php b/endpoints/account/update-email.php new file mode 100644 index 00000000..0cf750be --- /dev/null +++ b/endpoints/account/update-email.php @@ -0,0 +1,80 @@ + ['filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_FLAG_STRIP_AOWOW] + ); + + private bool $success = false; + + public function __construct(string $rawParam) + { + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + (new TemplateResponse())->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($msg = $this->updateMail()) + $_SESSION['msg'] = ['email', $this->success, $msg]; + } + + private function updateMail() : string + { + // no input yet + if (is_null($this->_post['newemail'])) + return Lang::main('intError'); + // truncated due to validation fail + if (!$this->_post['newemail']) + return Lang::account('emailInvalid'); + + if (DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE `email` = %s AND `id` <> %i', $this->_post['newemail'], User::$id)) + return Lang::account('mailInUse'); + + $status = DB::Aowow()->selectCell('SELECT `status` FROM ::account WHERE `statusTimer` > UNIX_TIMESTAMP() AND `id` = %i', User::$id); + if ($status != ACC_STATUS_NONE && $status != ACC_STATUS_CHANGE_EMAIL) + return Lang::account('inputbox', 'error', 'isRecovering', [DateTime::formatTimeElapsedFloat(Cfg::get('ACC_RECOVERY_DECAY') * 1000)]); + + $oldEmail = DB::Aowow()->selectCell('SELECT `email` FROM ::account WHERE `id` = %i', User::$id); + if ($this->_post['newemail'] == $oldEmail) + return Lang::account('newMailDiff'); + + $token = Util::createHash(); + + // store new mail in updateValue field, exchange when confirmation mail gets confirmed + if (!DB::Aowow()->qry('UPDATE ::account SET `updateValue` = %s, `status` = %i, `statusTimer` = UNIX_TIMESTAMP() + %i, `token` = %s WHERE `id` = %i', + $this->_post['newemail'], ACC_STATUS_CHANGE_EMAIL, Cfg::get('ACC_RECOVERY_DECAY'), $token, User::$id)) + return Lang::main('intError'); + + if (!Util::sendMail($this->_post['newemail'], 'change-email', [$token, $this->_post['newemail']], Cfg::get('ACC_RECOVERY_DECAY'))) + return Lang::main('intError2', ['send mail']); + + if (!Util::sendMail($oldEmail, 'revert-email', [$token, $oldEmail], Cfg::get('ACC_RECOVERY_DECAY'))) + return Lang::main('intError2', ['send mail']); + + $this->success = true; + return Lang::account('updateMessage', 'personal', [$this->_post['newemail']]); + } +} + +?> diff --git a/endpoints/account/update-general-settings.php b/endpoints/account/update-general-settings.php new file mode 100644 index 00000000..73a8cda0 --- /dev/null +++ b/endpoints/account/update-general-settings.php @@ -0,0 +1,60 @@ + ['filter' => FILTER_VALIDATE_INT, 'options' => ['default' => 0, 'min_range' => 1, 'max_range' => 11]], + 'modelgender' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['default' => 0, 'min_range' => 1, 'max_range' => 2] ], + 'idsInLists' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCheckbox'] ] + ); + + private bool $success = false; + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($message = $this->updateGeneral()) + $_SESSION['msg'] = ['general', $this->success, $message]; + } + + private function updateGeneral() : string + { + if (!$this->assertPOST('modelrace', 'modelgender')) + return Lang::main('genericError'); + + if ($this->_post['modelrace'] && !ChrRace::tryFrom($this->_post['modelrace'])) + return Lang::main('genericError'); + + // js handles this as cookie, so saved as cookie; Q - also save in ::account table? + if (!DB::Aowow()->qry('REPLACE INTO ::account_cookies (`userId`, `name`, `data`) VALUES (%i, %s, %s)', User::$id, 'default_3dmodel', $this->_post['modelrace']. ',' . $this->_post['modelgender'])) + return Lang::main('genericError'); + + if (!setcookie('default_3dmodel', $this->_post['modelrace']. ',' . $this->_post['modelgender'], 0, '/')) + return Lang::main('intError'); + + // int > number of edited rows > no changes is still success + if (!is_int(DB::Aowow()->qry('UPDATE ::account SET `debug` = %i WHERE `id` = %i', $this->_post['idsInLists'] ? 1 : 0, User::$id))) + return Lang::main('intError'); + + $this->success = true; + return Lang::account('updateMessage', 'general'); + } +} + +?> diff --git a/endpoints/account/update-password.php b/endpoints/account/update-password.php new file mode 100644 index 00000000..8038f8be --- /dev/null +++ b/endpoints/account/update-password.php @@ -0,0 +1,86 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']], + 'newPassword' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']], + 'confirmPassword' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']], + 'globalLogout' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCheckbox']] + ); + + private bool $success = false; + + public function __construct(string $rawParam) + { + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + (new TemplateResponse())->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($msg = $this->updatePassword()) + $_SESSION['msg'] = ['password', $this->success, $msg]; + } + + private function updatePassword() : string + { + if (!$this->assertPOST('currentPassword', 'newPassword', 'confirmPassword')) + return Lang::main('intError'); + + if (!Util::validatePassword($this->_post['newPassword'], $e)) + return $e == 1 ? Lang::account('errPassLength') : Lang::main('intError'); + + if ($this->_post['newPassword'] !== $this->_post['confirmPassword']) + return Lang::account('passMismatch'); + + $userData = DB::Aowow()->selectRow('SELECT `status`, `passHash`, `statusTimer` FROM ::account WHERE `id` = %i', User::$id); + if ($userData['status'] != ACC_STATUS_NONE && $userData['status'] != ACC_STATUS_CHANGE_PASS && $userData['statusTimer'] > time()) + return Lang::account('inputbox', 'error', 'isRecovering', [DateTime::formatTimeElapsedFloat(Cfg::get('ACC_RECOVERY_DECAY') * 1000)]); + + if (!User::verifyCrypt($this->_post['currentPassword'], $userData['passHash'])) + return Lang::account('wrongPass'); + + if (User::verifyCrypt($this->_post['newPassword'], $userData['passHash'])) + return Lang::account('newPassDiff'); + + $token = Util::createHash(); + + // store new hash in updateValue field, exchange when confirmation mail gets confirmed + if (!DB::Aowow()->qry('UPDATE ::account SET `updateValue` = %s, `status` = %i, `statusTimer` = UNIX_TIMESTAMP() + %i, `token` = %s WHERE `id` = %i', + User::hashCrypt($this->_post['newPassword']), ACC_STATUS_CHANGE_PASS, Cfg::get('ACC_RECOVERY_DECAY'), $token, User::$id)) + return Lang::main('intError'); + + $email = DB::Aowow()->selectCell('SELECT `email` FROM ::account WHERE `id` = %i', User::$id); + if (!Util::sendMail($email, 'update-password', [$token, $email], Cfg::get('ACC_RECOVERY_DECAY'))) + return Lang::main('intError2', ['send mail']); + + // logout all other active sessions + if ($this->_post['globalLogout']) + DB::Aowow()->qry('UPDATE ::account_sessions SET `status` = %i, `touched` = %i WHERE `userId` = %i AND `sessionId` <> ? AND `status` = %i', SESSION_FORCED_LOGOUT, time(), User::$id, session_id(), SESSION_ACTIVE); + + $this->success = true; + return Lang::account('updateMessage', 'personal', [User::$email]); + } +} + +?> diff --git a/endpoints/account/update-username.php b/endpoints/account/update-username.php new file mode 100644 index 00000000..7876d605 --- /dev/null +++ b/endpoints/account/update-username.php @@ -0,0 +1,61 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateUsername']] + ); + + private bool $success = false; + + public function __construct(string $rawParam) + { + if (Cfg::get('ACC_AUTH_MODE') != AUTH_MODE_SELF) + (new TemplateResponse())->generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($msg = $this->updateUsername()) + $_SESSION['msg'] = ['username', $this->success, $msg]; + } + + private function updateUsername() : string + { + if (!$this->assertPOST('newUsername')) + return Lang::main('intError'); + + if (DB::Aowow()->selectCell('SELECT `renameCooldown` FROM ::account WHERE `id` = %i', User::$id) > time()) + return Lang::main('intError'); // should have grabbed the error response.. + + // yes, including your current name. you don't want to change into your current name, right? + if (DB::Aowow()->selectCell('SELECT 1 FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_post['newUsername'])) + return Lang::account('nameInUse'); + + DB::Aowow()->qry('UPDATE ::account SET `username` = %s, `renameCooldown` = %i WHERE `id` = %i', $this->_post['newUsername'], time() + Cfg::get('acc_rename_decay'), User::$id); + + $this->success = true; + return Lang::account('updateMessage', 'username', [User::$username, $this->_post['newUsername']]); + } +} + +?> diff --git a/endpoints/account/weightscales.php b/endpoints/account/weightscales.php new file mode 100644 index 00000000..8bae7835 --- /dev/null +++ b/endpoints/account/weightscales.php @@ -0,0 +1,121 @@ + ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 1]], + 'delete' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 1]], + 'id' => ['filter' => FILTER_VALIDATE_INT ], + 'name' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkName'] ], + 'scale' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkScale'] ] + ); + + protected function generate() : void + { + if (User::isBanned()) + return; + + if ($this->_post['save'] && $this->_post['id']) + $this->updateWeights(); + + else if ($this->_post['save']) + $this->createWeights(); + + else if ($this->_post['delete']) + $this->deleteWeights(); + } + + private function createWeights() : void + { + if (!$this->assertPOST('name', 'scale')) + return; + + $nScales = DB::Aowow()->selectCell('SELECT COUNT(`id`) FROM ::account_weightscales WHERE `userId` = %i', User::$id); + if ($nScales >= self::MAX_SCALES) + return; + + if ($id = DB::Aowow()->qry('INSERT INTO ::account_weightscales (`userId`, `name`) VALUES (%i, %s)', User::$id, $this->_post['name'])) + if ($this->storeScaleData($id)) + $this->result = $id; + } + + private function updateWeights() : void + { + if (!$this->assertPOST('name', 'scale', 'id')) + return; + + // not in DB or not owned by user + if (!DB::Aowow()->selectCell('SELECT 1 FROM ::account_weightscales WHERE `userId` = %i AND `id` = %i', User::$id, $this->_post['id'])) + { + trigger_error('AccountWeightscalesResponse::updateWeights - scale #'.$this->_post['id'].' not in db or not owned by user #'.User::$id, E_USER_ERROR); + return; + } + + DB::Aowow()->qry('UPDATE ::account_weightscales SET `name` = %s WHERE `id` = %i', $this->_post['name'], $this->_post['id']); + $this->storeScaleData($this->_post['id']); + + // return edited id on success + $this->result = $this->_post['id']; + } + + private function deleteWeights() : void + { + if ($this->assertPOST('id')) + DB::Aowow()->qry('DELETE FROM ::account_weightscales WHERE `id` = %i AND `userId` = %i', $this->_post['id'], User::$id); + + $this->result = ''; + } + + private function storeScaleData(int $scaleId) : bool + { + if (!is_int(DB::Aowow()->qry('DELETE FROM ::account_weightscale_data WHERE `id` = %i', $scaleId))) + return false; + + // $x['val'] is known to be a positive int due to regex check + $scaleData = array_filter($this->_post['scale'], fn($x) => Stat::getWeightJson($x['field']) && $x['val'] > 0); + + array_walk($scaleData, fn(&$x) => $x['id'] = $scaleId); + + foreach ($scaleData as $sd) + if (is_null(DB::Aowow()->qry('INSERT INTO ::account_weightscale_data %v', $sd))) + return false; + + return true; + } + + + /*************************************/ + /* additional request data callbacks */ + /*************************************/ + + protected static function checkScale(string $val) : array + { + if (preg_match('/^((\w+:\d+)(,\w+:\d+)*)$/', $val)) + return array_map(fn($x) => array_combine(['field', 'val'], explode(':', $x)), explode(',', $val)); + + return []; + } + + protected static function checkName(string $val) : string + { + return mb_substr(preg_replace('/[^[:print:]]/', '', trim(urldecode($val))), 0, 32); + } +} + +?> diff --git a/endpoints/achievement/achievement.php b/endpoints/achievement/achievement.php new file mode 100644 index 00000000..6c63fd11 --- /dev/null +++ b/endpoints/achievement/achievement.php @@ -0,0 +1,520 @@ + 100 +* } +*/ + +class AchievementBaseResponse extends TemplateResponse implements ICache +{ + use TrDetailPage, TrCache; + + protected int $cacheType = CACHE_TYPE_DETAIL_PAGE; + + protected string $template = 'achievement'; + protected string $pageName = 'achievement'; + protected ?int $activeTab = parent::TAB_DATABASE; + protected array $breadcrumb = [0, 9]; + + public int $type = Type::ACHIEVEMENT; + public int $typeId = 0; + public int $reqCrtQty = 0; + public ?array $mail = null; + public string $description = ''; + public array $criteria = []; + public ?array $rewards = null; + + private AchievementList $subject; + + public function __construct(string $id) + { + parent::__construct($id); + + $this->typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new AchievementList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('achievement'), Lang::achievement('notFound')); + + $this->extendGlobalData($this->subject->getJSGlobals(GLOBALINFO_REWARDS)); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + // create page title and path + $curCat = $this->subject->getField('category'); + $catPath = []; + while ($curCat > 0) + { + $catPath[] = $curCat; + $curCat = DB::Aowow()->SelectCell('SELECT `parentCat` FROM ::achievementcategory WHERE `id` = %i', $curCat); + } + + $this->breadcrumb = array_merge($this->breadcrumb, array_reverse($catPath)); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->subject->getField('name', true), Util::ucFirst(Lang::game('achievement'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // points + if ($_ = $this->subject->getField('points')) + $infobox[] = Lang::achievement('points').Lang::main('colon').'[achievementpoints='.$_.']'; + + // location + // todo (low) + + // faction + $infobox[] = Lang::main('side') . match ($this->subject->getField('faction')) + { + SIDE_ALLIANCE => '[span class=icon-alliance]'.Lang::game('si', SIDE_ALLIANCE).'[/span]', + SIDE_HORDE => '[span class=icon-horde]'.Lang::game('si', SIDE_HORDE).'[/span]', + default => Lang::game('si', SIDE_BOTH) // 0, 3 + }; + + // id + $infobox[] = Lang::achievement('id') . $this->typeId; + + // icon + if ($_ = $this->subject->getField('iconId')) + { + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $this->extendGlobalIds(Type::ICON, $_); + } + + // profiler relateed (note that this is part of the cache. I don't think this is important enough to calc for every view) + if (Cfg::get('PROFILER_ENABLE') && !($this->subject->getField('flags') & ACHIEVEMENT_FLAG_COUNTER)) + { + $x = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_completion_achievements WHERE `achievementId` = %i', $this->typeId); + $y = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_profiles WHERE `custom` = 0 AND `stub` = 0'); + $infobox[] = Lang::profiler('attainedBy', [round(($x ?: 0) * 100 / ($y ?: 1))]); + + // completion row added by InfoboxMarkup + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0', !($this->subject->getField('flags') & ACHIEVEMENT_FLAG_COUNTER)); + + + /**********/ + /* Series */ + /**********/ + + $series = []; + if ($c = $this->subject->getField('chainId')) + { + $chainAcv = new AchievementList(array(['chainId', $c])); + + foreach ($chainAcv->iterate() as $aId => $__) + { + $pos = $chainAcv->getField('chainPos'); + if (!isset($series[$pos])) + $series[$pos] = []; + + $series[$pos][] = array( + 'side' => (int)$chainAcv->getField('faction'), + 'typeStr' => Type::getFileString(Type::ACHIEVEMENT), + 'typeId' => $aId, + 'name' => $chainAcv->getField('name', true) + ); + } + } + + if ($series) + $this->series = [[array_values($series), null]]; + + + /****************/ + /* Main Content */ + /****************/ + + $this->headIcons = [$this->subject->getField('iconString')]; + $this->description = $this->subject->getField('description', true); + $this->redButtons = array( + BUTTON_WOWHEAD => !($this->subject->getField('cuFlags') & CUSTOM_SERVERSIDE), + BUTTON_LINKS => array( + 'linkColor' => 'ffffff00', + 'linkId' => Type::getFileString(Type::ACHIEVEMENT).':'.$this->typeId.':"..UnitGUID("player")..":0:0:0:0:0:0:0:0', + 'linkName' => $this->h1, + 'type' => $this->type, + 'typeId' => $this->typeId + ) + ); + $this->reqCrtQty = $this->subject->getField('reqCriteriaCount'); + + if ($this->createMail()) + $this->addScript([SC_CSS_FILE, 'css/Book.css']); + + // create rewards + $rewItems = $rewTitles = []; + if ($foo = $this->subject->getField('rewards')) + { + if ($itemRewards = array_filter($foo, fn($x) => $x[0] == Type::ITEM)) + { + $bar = new ItemList(array(['i.id', array_column($itemRewards, 1)])); + foreach ($bar->iterate() as $id => $__) + $rewItems[] = new IconElement(Type::ITEM, $id, $bar->getField('name', true), quality: $bar->getField('quality')); + } + + if ($titleRewards = array_filter($foo, fn($x) => $x[0] == Type::TITLE)) + { + $bar = new TitleList(array(['id', array_column($titleRewards, 1)])); + foreach ($bar->iterate() as $id => $__) + $rewTitles[] = Lang::achievement('titleReward', [$id, trim(str_replace('%s', '', $bar->getField('male', true)))]); + } + } + + if (($text = $this->subject->getField('reward', true)) || $rewItems || $rewTitles) + $this->rewards = [$rewItems, $rewTitles, $text]; + + // factionchange-equivalent + if ($pendant = DB::World()->selectCell('SELECT IF(`horde_id` = %i, `alliance_id`, -`horde_id`) FROM player_factionchange_achievement WHERE `alliance_id` = %i OR `horde_id` = %i', $this->typeId, $this->typeId, $this->typeId)) + { + $altAcv = new AchievementList(array(['id', abs($pendant)])); + if (!$altAcv->error) + { + $this->transfer = Lang::achievement('_transfer', array( + $altAcv->id, + ITEM_QUALITY_NORMAL, + $altAcv->getField('iconString'), + $altAcv->getField('name', true), + $pendant > 0 ? 'alliance' : 'horde', + $pendant > 0 ? Lang::game('si', SIDE_ALLIANCE) : Lang::game('si', SIDE_HORDE) + )); + } + } + + + /*****************/ + /* Criteria List */ + /*****************/ + + // serverside extra-Data (not sure why ACHIEVEMENT_CRITERIA_DATA_TYPE_NONE is set, let a lone a couple hundred times) + if ($crtIds = array_column($this->subject->getCriteria(), 'id')) + $crtExtraData = DB::World()->selectAssoc('SELECT `criteria_id` AS ARRAY_KEY, `type` AS ARRAY_KEY2, `value1`, `value2`, `ScriptName` FROM achievement_criteria_data WHERE `type` <> %i AND `criteria_id` IN %in', ACHIEVEMENT_CRITERIA_DATA_TYPE_NONE, $crtIds); + else + $crtExtraData = []; + + foreach ($this->subject->getCriteria() as $crt) + { + // hide hidden criteria for regular users (really do..?) + // if (($crt['completionFlags'] & ACHIEVEMENT_CRITERIA_FLAG_HIDDEN) && !User::isInGroup(U_GROUP_STAFF)) + // continue; + + // alternative display option + $crtName = Util::localizedString($crt, 'name'); + $killSuffix = null; + + $obj = (int)$crt['value1']; + $qty = (int)$crt['value2']; + + switch ($crt['type']) + { + // link to npc + case ACHIEVEMENT_CRITERIA_TYPE_KILL_CREATURE: + $killSuffix = Lang::achievement('slain'); + case ACHIEVEMENT_CRITERIA_TYPE_KILLED_BY_CREATURE: + $crtIcon = new IconElement(Type::NPC, $obj, $crtName ?: CreatureList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon', extraText: $crtName ? null : $killSuffix); + break; + // link to area (by map) + case ACHIEVEMENT_CRITERIA_TYPE_WIN_BG: + case ACHIEVEMENT_CRITERIA_TYPE_WIN_ARENA: + case ACHIEVEMENT_CRITERIA_TYPE_PLAY_ARENA: + case ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_BATTLEGROUND: + case ACHIEVEMENT_CRITERIA_TYPE_DEATH_AT_MAP: + $zoneId = DB::Aowow()->selectCell('SELECT `id` FROM ::zones WHERE `mapId` = %s', $obj); + $crtIcon = new IconElement(Type::ZONE, $zoneId ?: 0, $crtName ?: ZoneList::getName($zoneId), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + // link to area + case ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_QUESTS_IN_ZONE: + case ACHIEVEMENT_CRITERIA_TYPE_HONORABLE_KILL_AT_AREA: + $crtIcon = new IconElement(Type::ZONE, $obj, $crtName ?: ZoneList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + // link to skills + case ACHIEVEMENT_CRITERIA_TYPE_REACH_SKILL_LEVEL: + case ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILL_LEVEL: + case ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILLLINE_SPELLS: + case ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILL_LINE: + $crtIcon = new IconElement(Type::SKILL, $obj, $crtName ?: SkillList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + $this->extendGlobalIds(Type::SKILL, $obj); + break; + // link to class + case ACHIEVEMENT_CRITERIA_TYPE_HK_CLASS: + $crtIcon = new IconElement(Type::CHR_CLASS, $obj, $crtName ?: CharClassList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + // link to race + case ACHIEVEMENT_CRITERIA_TYPE_HK_RACE: + $crtIcon = new IconElement(Type::CHR_RACE, $obj, $crtName ?: CharRaceList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + // link to achivement + case ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_ACHIEVEMENT: + $crtIcon = new IconElement(Type::ACHIEVEMENT, $obj, $crtName ?: AchievementList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + $this->extendGlobalIds(Type::ACHIEVEMENT, $obj); + break; + // link to quest + case ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_QUEST: + $crtIcon = new IconElement(Type::QUEST, $obj, $crtName ?: QuestList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + // link to spell + case ACHIEVEMENT_CRITERIA_TYPE_BE_SPELL_TARGET: + case ACHIEVEMENT_CRITERIA_TYPE_BE_SPELL_TARGET2: + case ACHIEVEMENT_CRITERIA_TYPE_CAST_SPELL: + case ACHIEVEMENT_CRITERIA_TYPE_LEARN_SPELL: + case ACHIEVEMENT_CRITERIA_TYPE_CAST_SPELL2: + $crtIcon = new IconElement(Type::SPELL, $obj, $crtName ?: SpellList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + $this->extendGlobalIds(Type::SPELL, $obj); + break; + // link to item + case ACHIEVEMENT_CRITERIA_TYPE_OWN_ITEM: + case ACHIEVEMENT_CRITERIA_TYPE_USE_ITEM: + case ACHIEVEMENT_CRITERIA_TYPE_LOOT_ITEM: + case ACHIEVEMENT_CRITERIA_TYPE_EQUIP_ITEM: + $item = new ItemList([['id', $obj]]); + $crtIcon = new IconElement(Type::ITEM, $obj, $crtName ?: $item->getField('name', true), quality: $item->getField('quality'), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + $this->extendGlobalData($item->getJSGlobals()); + break; + // link to faction (/w target reputation) + case ACHIEVEMENT_CRITERIA_TYPE_GAIN_REPUTATION: + $crtIcon = new IconElement(Type::FACTION, $obj, $crtName ?: FactionList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon', extraText: '('.Lang::getReputationLevelForPoints($qty).')'); + break; + // link to GObject + case ACHIEVEMENT_CRITERIA_TYPE_USE_GAMEOBJECT: + case ACHIEVEMENT_CRITERIA_TYPE_FISH_IN_GAMEOBJECT: + $crtIcon = new IconElement(Type::OBJECT, $obj, $crtName ?: GameObjectList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + // link to emote + case ACHIEVEMENT_CRITERIA_TYPE_DO_EMOTE: + $crtIcon = new IconElement(Type::EMOTE, $obj, $crtName ?: EmoteList::getName($obj), size: IconElement::SIZE_SMALL, element: 'iconlist-icon'); + break; + default: + // Add a gold coin icon if required + if ($crt['completionFlags'] & ACHIEVEMENT_CRITERIA_FLAG_MONEY_COUNTER ) + $crtIcon = new IconElement(0, 0, '', extraText: Util::formatMoney($qty)); + else + $crtIcon = new IconElement(0, 0, $crtName); + break; + } + + if (User::isInGroup(U_GROUP_STAFF)) + $crtIcon->extraText .= ' [CriteriaId: '.$crt['id'].']'; + + $extraData = []; + foreach ($crtExtraData[$crt['id']] ?? [] as $xType => $xData) + { + switch ($xType) + { + case ACHIEVEMENT_CRITERIA_DATA_TYPE_T_CREATURE: + $extraData[] = CreatureList::makeLink($xData['value1']); + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_T_PLAYER_CLASS_RACE: + case ACHIEVEMENT_CRITERIA_DATA_TYPE_S_PLAYER_CLASS_RACE: + if ($xData['value1']) + $extraData[] = CharClassList::makeLink($xData['value1']); + + if ($xData['value2']) + $extraData[] = CharRaceList::makeLink($xData['value2']); + + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_S_AURA: + case ACHIEVEMENT_CRITERIA_DATA_TYPE_T_AURA: + $extraData[] = SpellList::makeLink($xData['value1']); + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_S_AREA: + $extraData[] = ZoneList::makeLink($xData['value1']); + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_SCRIPT: + if ($xData['ScriptName'] && User::isInGroup(U_GROUP_STAFF)) + $extraData[] = 'Script '.$xData['ScriptName']; + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_HOLIDAY: + if ($we = new WorldEventList(array(['holidayId', $xData['value1']]))) + $extraData[] = ''.$we->getField('name', true).''; + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_MAP_ID: + $extraData[] = match((int)$xData['value1']) + { + 0 => Lang::maps('EasternKingdoms'), + 1 => Lang::maps('Kalimdor'), + 530 => Lang::maps('Outland'), + 571 => Lang::maps('Northrend'), + default => (function(int $mapId) { + $z = new ZoneList(array(['mapId', $mapId])); + return ''.$z->getField('name', true).''; + })($xData['value1']) + }; + break; + case ACHIEVEMENT_CRITERIA_DATA_TYPE_S_KNOWN_TITLE: + $extraData[] = TitleList::makeLink($xData['value1']); + break; + default: + if (User::isInGroup(U_GROUP_STAFF)) + $extraData[] = 'has extra criteria data'; + } + } + + if ($extraData) + $crtIcon->extraText .= '
('.implode(', ', $extraData).')'; + + $this->criteria[] = $crtIcon; + } + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: see also + $conditions = array( + ['name_loc'.Lang::getLocale()->value, $this->subject->getField('name', true)], + ['id', $this->typeId, '!'] + ); + $saList = new AchievementList($conditions); + if (!$saList->error) + { + $this->extendGlobalData($saList->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $saList->getListviewData(), + 'id' => 'see-also', + 'name' => '$LANG.tab_seealso', + 'visibleCols' => ['category'] + ), AchievementList::$brickFile)); + } + + // tab: criteria of + $refs = DB::Aowow()->SelectCol('SELECT `refAchievementId` FROM ::achievementcriteria WHERE `type` = %i AND `value1` = %i', + ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_ACHIEVEMENT, + $this->typeId + ); + + if (!empty($refs)) + { + $coList = new AchievementList(array(['id', $refs])); + if (!$coList->error) + { + $this->extendGlobalData($coList->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $coList->getListviewData(), + 'id' => 'criteria-of', + 'name' => '$LANG.tab_criteriaof', + 'visibleCols' => ['category'] + ), AchievementList::$brickFile)); + } + } + + // tab: condition for + $cnd = new Conditions(); + $cnd->getByCondition(Type::ACHIEVEMENT, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + + if ($this->subject->getField('flags') & ACHIEVEMENT_FLAG_REALM_FIRST) + $this->result->registerDisplayHook('infobox', [self::class, 'infoboxHook']); + } + + private function createMail() : bool + { + if ($_ = $this->subject->getField('mailTemplate')) + { + $letter = DB::Aowow()->selectRow('SELECT * FROM ::mails WHERE `id` = %i', $_); + if (!$letter) + return false; + + $this->mail = array( + 'attachments' => [], + 'subject' => Util::parseHtmlText(Util::localizedString($letter, 'subject', true)), + 'text' => Util::parseHtmlText(Util::localizedString($letter, 'text', true)), + 'header' => [$_, null, null] + ); + } + else if ($_ = Util::parseHtmlText($this->subject->getField('text', true, true))) + { + $this->mail = array( + 'attachments' => [], + 'subject' => Util::parseHtmlText($this->subject->getField('subject', true, true)), + 'text' => $_, + 'header' => [-$this->typeId, null, null] + ); + } + else + return false; + + if ($senderId = $this->subject->getField('sender')) + if ($senderName = CreatureList::getName($senderId)) + $this->mail['header'][1] = Lang::mail('mailBy', [$senderId, $senderName]); + + return true; + } + + /* finalize infobox */ + public static function infoboxHook(Template\PageTemplate &$pt, ?InfoboxMarkup &$markup) : void + { + // realm first still available? + if (!DB::isConnectable(DB_AUTH)) + return; + + $avlb = []; + foreach (Profiler::getRealms() AS $rId => $rData) + if (!DB::Characters($rId)->selectCell('SELECT 1 FROM character_achievement WHERE `achievement` = %i', $pt->typeId)) + $avlb[] = Util::ucWords($rData['name']); + + if (!$avlb) + return; + + $addRow = Lang::achievement('rfAvailable').implode(', ', $avlb); + + if (!$markup) + $markup = new InfoboxMarkup([$addRow], ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + else + $markup->addItem($addRow); + } +} + +?> diff --git a/endpoints/achievement/achievement_power.php b/endpoints/achievement/achievement_power.php new file mode 100644 index 00000000..05e78fc6 --- /dev/null +++ b/endpoints/achievement/achievement_power.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct($id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $achievement = new AchievementList(array(['id', $this->typeId])); + if ($achievement->error) + $this->cacheType = CACHE_TYPE_NONE; + else + $opts = array( + 'name' => $achievement->getField('name', true), + 'tooltip' => $achievement->renderTooltip(), + 'icon' => $achievement->getField('iconString') + ); + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/achievements/achievements.php b/endpoints/achievements/achievements.php new file mode 100644 index 00000000..6597306f --- /dev/null +++ b/endpoints/achievements/achievements.php @@ -0,0 +1,170 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = array( + 92 => true, + 96 => [14861, 14862, 14863], + 97 => [14777, 14778, 14779, 14780], + 95 => [165, 14801, 14802, 14803, 14804, 14881, 14901, 15003], + 168 => [14808, 14805, 14806, 14921, 14922, 14923, 14961, 14962, 15001, 15002, 15041, 15042], + 169 => [170, 171, 172], + 201 => [14864, 14865, 14866], + 155 => [160, 187, 159, 163, 161, 162, 158, 14981, 156, 14941], + 81 => true, + 1 => array ( + 130 => [140, 145, 147, 191], + 141 => true, + 128 => [135, 136, 137], + 122 => [123, 124, 125, 126, 127], + 133 => true, + 14807 => [14821, 14822, 14823, 14963, 15021, 15062], + 132 => [178, 173], + 134 => true, + 131 => true, + 21 => [152, 153, 154] + ) + ); + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new AchievementListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('achievements')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + // include child categories if current category is empty + if ($this->category) + $conditions[] = ['category', end($this->category)]; + + if ($fiCnd = $this->filter->getConditions()) + $conditions[] = $fiCnd; + + + /*************/ + /* Menu Path */ + /*************/ + + foreach ($this->category as $cat) + $this->breadcrumb[] = $cat; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, Util::ucFirst(Lang::game('achievements'))); + if ($this->category) + array_unshift($this->title, Lang::achievement('cat', end($this->category))); + + + /****************/ + /* Main Content */ + /****************/ + + // fix modern client achievement category structure: top catg [1:char, 2:statistic, 3:guild] + if ($this->category && $this->category[0] != 1) + $link = '=1.'.implode('.', $this->category); + else if ($this->category) + $link = '=2'.(count($this->category) > 1 ? '.'.implode('.', array_slice($this->category, 1)) : ''); + else + $link = ''; + + $this->redButtons[BUTTON_WOWHEAD] = true; + $this->wowheadLink = sprintf(WOWHEAD_LINK, Lang::getLocale()->domain(), $this->pageName, $link); + + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + $acvList = new AchievementList($conditions, ['calcTotal' => true]); + if (!$acvList->getMatches() && $this->category) + { + // ToDo - we also branch into here if the filter prohibits results. That should be skipped. + $conditions = [Listview::DEFAULT_SIZE]; + if ($fiCnd) + $conditions[] = $fiCnd; + if ($catList = DB::Aowow()->SelectCol('SELECT `id` FROM ::achievementcategory WHERE `parentCat` IN %in OR `parentCat2` IN %in ', $this->category, $this->category)) + $conditions[] = ['category', $catList]; + + $acvList = new AchievementList($conditions, ['calcTotal' => true]); + } + + $tabData = []; + if (!$acvList->error) + { + $tabData['data'] = $acvList->getListviewData(); + + // fill g_items, g_titles, g_achievements + $this->extendGlobalData($acvList->getJSGlobals()); + + // if we are have different cats display field + if ($acvList->hasDiffFields('category')) + $tabData['visibleCols'] = ['category']; + + if ($this->filter->fiExtraCols) + $tabData['extraCols'] = '$fi_getExtraCols(fi_extraCols, 0, 0)'; + + // create note if search limit was exceeded + if ($acvList->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_achievementsfound', $acvList->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + } + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, AchievementList::$brickFile)); + + parent::generate(); + + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay']); + } + + public static function onBeforeDisplay() + { + // sort for dropdown-menus in filter + Lang::sort('game', 'si'); + } +} + +?> diff --git a/endpoints/admin/announcements.php b/endpoints/admin/announcements.php new file mode 100644 index 00000000..b1fdc774 --- /dev/null +++ b/endpoints/admin/announcements.php @@ -0,0 +1,68 @@ + Content > Announcements + + protected array $expectedGET = array( + 'id' => ['filter' => FILTER_VALIDATE_INT ], + 'edit' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ], + 'status' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 0, 'max_range' => 2]] + ); + + protected function generate() : void + { + if ($this->_get['id'] && isset($this->_get['status'])) + { + $this->updateStatus(); + $this->forward($_SERVER['HTTP_REFERER'] ?? '.'); + } + else if ($this->_get['edit']) + $this->displayEditor(); + else + $this->displayListing(); + + parent::generate(); + } + + private function updateStatus() : void + { + if (!$this->assertGET('status', 'id')) + { + trigger_error('AdminAnnouncementsResponse::updateStatus - error in _GET id/status'); + return; + } + + if (!DB::Aowow()->selectCell('SELECT 1 FROM ::announcements WHERE `id` = %i', $this->_get['id'])) + { + trigger_error('AdminAnnouncementsResponse::updateStatus - announcement does not exist'); + return; + } + + DB::Aowow()->qry('UPDATE ::announcements SET `status` = %i WHERE `id` = %i', $this->_get['status'], $this->_get['id']); + } + + private function displayEditor() : void + { + // TBD + $this->extraHTML = 'TODO - editor'; + } + + private function displayListing() : void + { + // TBD + // some form of listview with [NEW] button somewhere near the head i guess + $this->extraHTML = 'TODO - announcements listing'; + } +} diff --git a/endpoints/admin/comment.php b/endpoints/admin/comment.php new file mode 100644 index 00000000..26b78e29 --- /dev/null +++ b/endpoints/admin/comment.php @@ -0,0 +1,51 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'status' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 0, 'max_range' => 1]] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id', 'status')) + { + trigger_error('AdminCommentResponse - malformed request received', E_USER_ERROR); + $this->result = self::ERR_MISCELLANEOUS; + return; + } + + // check if is marked as outdated CC_FLAG_OUTDATED? + + $ok = false; + if ($this->_post['status']) // outdated, mark as deleted and clear other flags (sticky + outdated) + { + if ($ok = DB::Aowow()->qry('UPDATE ::comments SET `flags` = %i, `deleteUserId` = %i, `deleteDate` = %i WHERE `id` = %i', CC_FLAG_DELETED, User::$id, time(), $this->_post['id'])) + if ($rep = new Report(Report::MODE_COMMENT, Report::CO_OUT_OF_DATE, $this->_post['id'])) + $rep->close(Report::STATUS_CLOSED_SOLVED); + } + else // up to date + { + if ($ok = DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` & ~%i WHERE `id` = %i', CC_FLAG_OUTDATED, $this->_post['id'])) + if ($rep = new Report(Report::MODE_COMMENT, Report::CO_OUT_OF_DATE, $this->_post['id'])) + $rep->close(Report::STATUS_CLOSED_WONTFIX); + } + + $this->result = $ok ? self::ERR_NONE : self::ERR_WRITE_DB; + } +} + +?> diff --git a/endpoints/admin/guide.php b/endpoints/admin/guide.php new file mode 100644 index 00000000..90ee553e --- /dev/null +++ b/endpoints/admin/guide.php @@ -0,0 +1,81 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'status' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => GuideMgr::STATUS_APPROVED, 'max_range' => GuideMgr::STATUS_REJECTED]], + 'msg' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id', 'status')) + { + trigger_error('AdminGuideResponse - malformed request received', E_USER_ERROR); + $this->result = self::ERR_MISCELLANEOUS; + return; + } + + $guide = DB::Aowow()->selectRow('SELECT `userId`, `status` FROM ::guides WHERE `id` = %i', $this->_post['id']); + if (!$guide) + { + trigger_error('AdminGuideResponse - guide #'.$this->_post['id'].' not found', E_USER_ERROR); + $this->result = self::ERR_GUIDE; + return; + } + + if ($this->_post['status'] == $guide['status']) + { + trigger_error('AdminGuideResponse - guide #'.$this->_post['id'].' already has status #'.$this->_post['status'], E_USER_ERROR); + $this->result = self::ERR_STATUS; + return; + } + + // status can only be APPROVED or REJECTED due to input validation + if (!$this->update($this->_post['id'], $this->_post['status'], $this->_post['msg'])) + { + trigger_error('AdminGuideResponse - write to db failed for guide #'.$this->_post['id'], E_USER_ERROR); + $this->result = self::ERR_WRITE_DB; + return; + } + + if ($this->_post['status'] == GuideMgr::STATUS_APPROVED) + Util::gainSiteReputation($guide['userId'], SITEREP_ACTION_ARTICLE, ['id' => $this->_post['id']]); + + $this->result = self::ERR_NONE; + } + + private function update(int $id, int $status, ?string $msg = null) : bool + { + if ($status == GuideMgr::STATUS_APPROVED) // set display rev to latest + $ok = DB::Aowow()->qry('UPDATE ::guides SET `status` = %i, `rev` = (SELECT `rev` FROM ::articles WHERE `type` = %i AND `typeId` = %i ORDER BY `rev` DESC LIMIT 1), `approveUserId` = %i, `approveDate` = %i WHERE `id` = %i', $status, Type::GUIDE, $id, User::$id, time(), $id); + else + $ok = DB::Aowow()->qry('UPDATE ::guides SET `status` = %i WHERE `id` = %i', $status, $id); + + if (!$ok) + return false; + + DB::Aowow()->qry('INSERT INTO ::guides_changelog (`id`, `date`, `userId`, `status`) VALUES (%i, %i, %i, %i)', $id, time(), User::$id, $status); + if ($msg) + DB::Aowow()->qry('INSERT INTO ::guides_changelog (`id`, `date`, `userId`, `msg`) VALUES (%i, %i, %i, %s)', $id, time(), User::$id, $msg); + + return true; + } +} + +?> diff --git a/endpoints/admin/guides.php b/endpoints/admin/guides.php new file mode 100644 index 00000000..26d6f844 --- /dev/null +++ b/endpoints/admin/guides.php @@ -0,0 +1,46 @@ + Content > Guides Awaiting Approval + + protected function generate() : void + { + $this->h1 = 'Pending Guides'; + array_unshift($this->title, $this->h1); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + parent::generate(); + + $pending = new GuideList([['status', GuideMgr::STATUS_REVIEW]]); + if ($pending->error) + $data = []; + else + { + $data = $pending->getListviewData(); + $latest = DB::Aowow()->selectCol('SELECT `typeId` AS ARRAY_KEY, MAX(`rev`) FROM ::articles WHERE `type` = %i AND `typeId` IN %in GROUP BY `rev`', Type::GUIDE, $pending->getFoundIDs()); + foreach ($latest as $id => $rev) + $data[$id]['rev'] = $rev; + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => array_values($data), + 'hiddenCols' => ['patch', 'comments', 'views', 'rating'], + 'extraCols' => '$_' + ), GuideList::$brickFile, 'guideAdminCol')); + } +} + +?> diff --git a/endpoints/admin/out-of-date.php b/endpoints/admin/out-of-date.php new file mode 100644 index 00000000..4f0f1b00 --- /dev/null +++ b/endpoints/admin/out-of-date.php @@ -0,0 +1,34 @@ + Content > Out of Date Comments + + protected function generate() : void + { + $this->h1 = 'Out of Date Comments'; + array_unshift($this->title, $this->h1); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + parent::generate(); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => CommunityContent::getCommentPreviews(['flags' => CC_FLAG_OUTDATED]), + 'extraCols' => '$_' + ), 'commentpreview', 'commentAdminCol')); + } +} + +?> diff --git a/endpoints/admin/phpinfo.php b/endpoints/admin/phpinfo.php new file mode 100644 index 00000000..1fd04b8c --- /dev/null +++ b/endpoints/admin/phpinfo.php @@ -0,0 +1,80 @@ + Development > PHP Information + + protected function generate() : void + { + $this->h1 = 'PHP Information'; + array_unshift($this->title, $this->h1); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + parent::generate(); + + $this->addScript([SC_CSS_STRING, << $b) + { + ob_start(); + phpinfo($b); + $buff = ob_get_contents(); + ob_end_clean(); + + $buff = explode('
', $buff)[1]; + $buff = explode('
', $buff); + array_pop($buff); // remove last from stack + $buff = implode('', $buff); // sew it together + + if (strpos($buff, '

')) + $buff = explode('

', $buff)[1]; + + if (strpos($buff, '

')) + { + $parts = explode('

', $buff); + foreach ($parts as $p) + { + if (!preg_match('/\w/i', $p)) + continue; + + $p = explode('

', $p); + $name = $names[$i] ? $names[$i].': ' : ''; + if (preg_match('/]*>([\w\s\d]+)<\/a>/i', $p[0], $m)) + $name .= $m[1]; + else + $name .= $p[0]; + + $this->lvTabs->addDataTab(strtolower(strtr($name, [' ' => ''])), $name, $p[1]); + } + } + else + $this->lvTabs->addDataTab(strtolower($names[$i]), $names[$i], $buff); + } + } +} + +?> diff --git a/endpoints/admin/reports.php b/endpoints/admin/reports.php new file mode 100644 index 00000000..a64c8df8 --- /dev/null +++ b/endpoints/admin/reports.php @@ -0,0 +1,29 @@ + Reports + + protected function generate() : void + { + $this->h1 = 'Reports'; + array_unshift($this->title, $this->h1); + + $this->extraHTML = 'NYI'; + + parent::generate(); + } +} + +?> diff --git a/endpoints/admin/screenshots.php b/endpoints/admin/screenshots.php new file mode 100644 index 00000000..c8b87ba8 --- /dev/null +++ b/endpoints/admin/screenshots.php @@ -0,0 +1,68 @@ + Content > Screenshots + + protected array $scripts = array( + [SC_JS_FILE, 'js/screenshot.js'], + [SC_CSS_STRING, '.layout {margin: 0px 25px; max-width: inherit; min-width: 1200px; }'], + [SC_CSS_STRING, '#highlightedRow { background-color: #322C1C; }'] + ); + protected array $expectedGET = array( + 'action' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']], + 'all' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']], + 'type' => ['filter' => FILTER_VALIDATE_INT ], + 'typeid' => ['filter' => FILTER_VALIDATE_INT ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => 'urldecode' ] + ); + + public ?bool $getAll = null; + public array $ssPages = []; + public array $ssData = []; + public int $ssNFound = 0; + public array $pageTypes = []; + + protected function generate() : void + { + $this->h1 = 'Screenshot Manager'; + + // types that can have screenshots + foreach (Type::getClassesFor(0, 'contribute', CONTRIBUTE_SS) as $type => $obj) + $this->pageTypes[$type] = Util::ucWords(Lang::game(Type::getFileString($type))); + + $ssGetAll = $this->_get['all']; + $ssPages = []; + $ssData = []; + $nMatches = 0; + + if ($this->_get['type'] && $this->_get['typeid']) + $ssData = ScreenshotMgr::getScreenshots($this->_get['type'], $this->_get['typeid'], nFound: $nMatches); + else if ($this->_get['user']) + { + if (mb_strlen($this->_get['user']) >= 3) + if ($uId = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user'])) + $ssData = ScreenshotMgr::getScreenshots(userId: $uId, nFound: $nMatches); + } + else + $ssPages = ScreenshotMgr::getPages($ssGetAll, $nMatches); + + $this->getAll = $ssGetAll; + $this->ssPages = $ssPages; + $this->ssData = $ssData; + $this->ssNFound = $nMatches; // ssm_numPagesFound + + parent::generate(); + } +} diff --git a/endpoints/admin/screenshots_approve.php b/endpoints/admin/screenshots_approve.php new file mode 100644 index 00000000..5bb528c2 --- /dev/null +++ b/endpoints/admin/screenshots_approve.php @@ -0,0 +1,60 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('AdminScreenshotsActionApproveResponse - screenshotId empty', E_USER_ERROR); + return; + } + + ScreenshotMgr::init(); + + // create resized and thumb version of screenshot + $ssEntries = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `userIdOwner`, `date`, `type`, `typeId` FROM ::screenshots WHERE (`status` & %i) = 0 AND `id` IN %in', CC_FLAG_APPROVED, $this->_get['id']); + foreach ($ssEntries as $id => $ssData) + { + if (!ScreenshotMgr::loadFile(ScreenshotMgr::PATH_PENDING, $id)) + continue; + + if (!ScreenshotMgr::createResized($id)) + continue; + + if (!ScreenshotMgr::createThumbnail($id)) + continue; + + // move pending > normal + if (!rename(sprintf(ScreenshotMgr::PATH_PENDING, $id), sprintf(ScreenshotMgr::PATH_NORMAL, $id))) + continue; + + // set as approved in DB + DB::Aowow()->qry('UPDATE ::screenshots SET `status` = %i, `userIdApprove` = %i WHERE `id` = %i', CC_FLAG_APPROVED, User::$id, $id); + + // gain siterep + Util::gainSiteReputation($ssData['userIdOwner'], SITEREP_ACTION_SUBMIT_SCREENSHOT, ['id' => $id, 'what' => 1, 'date' => $ssData['date']]); + + // flag DB entry as having screenshots + if ($tbl = Type::getClassAttrib($ssData['type'], 'dataTable')) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_SCREENSHOT, $ssData['typeId']); + + unset($ssEntries[$id]); + } + + if (!$ssEntries) + trigger_error('AdminScreenshotsActionApproveResponse - screenshot(s) # '.implode(', ', array_keys($ssEntries)).' not in db or already approved', E_USER_WARNING); + } +} diff --git a/endpoints/admin/screenshots_delete.php b/endpoints/admin/screenshots_delete.php new file mode 100644 index 00000000..bd61a07c --- /dev/null +++ b/endpoints/admin/screenshots_delete.php @@ -0,0 +1,62 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + + // 2 steps: 1) remove from sight, 2) remove from disk + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('AdminScreenshotsActionDeleteResponse - screenshotId empty', E_USER_ERROR); + return; + } + + foreach ($this->_get['id'] as $id) + { + // irrevocably purge files already flagged as deleted (should only exist as pending) + if (User::isInGroup(U_GROUP_ADMIN) && DB::Aowow()->selectCell('SELECT 1 FROM ::screenshots WHERE `status` & %i AND `id` = %i', CC_FLAG_DELETED, $id)) + { + DB::Aowow()->qry('DELETE FROM ::screenshots WHERE `id` = %i', $id); + if (file_exists(sprintf(ScreenshotMgr::PATH_PENDING, $id))) + unlink(sprintf(ScreenshotMgr::PATH_PENDING, $id)); + + continue; + } + + // move normal to pending and remove resized and thumb + if (file_exists(sprintf(ScreenshotMgr::PATH_NORMAL, $id))) + rename(sprintf(ScreenshotMgr::PATH_NORMAL, $id), sprintf(ScreenshotMgr::PATH_PENDING, $id)); + + if (file_exists(sprintf(ScreenshotMgr::PATH_THUMB, $id))) + unlink(sprintf(ScreenshotMgr::PATH_THUMB, $id)); + + if (file_exists(sprintf(ScreenshotMgr::PATH_RESIZED, $id))) + unlink(sprintf(ScreenshotMgr::PATH_RESIZED, $id)); + } + + // flag as deleted if not aready + $oldEntries = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, GROUP_CONCAT(`typeId`) FROM ::screenshots WHERE `id` IN %in GROUP BY `type`', $this->_get['id']); + DB::Aowow()->qry('UPDATE ::screenshots SET `status` = %i, `userIdDelete` = %i WHERE `id` IN %in', CC_FLAG_DELETED, User::$id, $this->_get['id']); + + // deflag db entry as having screenshots + foreach ($oldEntries as $type => $typeIds) + { + $typeIds = explode(',', $typeIds); + $toUnflag = DB::Aowow()->selectCol('SELECT `typeId` AS ARRAY_KEY, IF(BIT_OR(`status`) & %i, 1, 0) AS "hasMore" FROM ::screenshots WHERE `type` = %i AND `typeId` IN %in GROUP BY `typeId` HAVING `hasMore` = 0', CC_FLAG_APPROVED, $type, $typeIds); + if ($toUnflag && ($tbl = Type::getClassAttrib($type, 'dataTable'))) + DB::Aowow()->qry('UPDATE %n SET cuFlags = cuFlags & ~%i WHERE id IN %in', $tbl, CUSTOM_HAS_SCREENSHOT, array_keys($toUnflag)); + } + } +} diff --git a/endpoints/admin/screenshots_editalt.php b/endpoints/admin/screenshots_editalt.php new file mode 100644 index 00000000..2dca89cc --- /dev/null +++ b/endpoints/admin/screenshots_editalt.php @@ -0,0 +1,32 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + protected array $expectedPOST = array( + 'alt' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + return; + + DB::Aowow()->qry('UPDATE ::screenshots SET `caption` = %s WHERE `id` = %i', + $this->handleCaption($this->_post['alt']), + $this->_get['id'] + ); + } +} diff --git a/endpoints/admin/screenshots_list.php b/endpoints/admin/screenshots_list.php new file mode 100644 index 00000000..bd884d42 --- /dev/null +++ b/endpoints/admin/screenshots_list.php @@ -0,0 +1,23 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + protected function generate() : void + { + $pages = ScreenshotMgr::getPages($this->_get['all'], $nPages); + $this->result = 'ssm_screenshotPages = '.Util::toJSON($pages).";\n"; + $this->result .= 'ssm_numPagesFound = '.$nPages.';'; + } +} diff --git a/endpoints/admin/screenshots_manage.php b/endpoints/admin/screenshots_manage.php new file mode 100644 index 00000000..752469d1 --- /dev/null +++ b/endpoints/admin/screenshots_manage.php @@ -0,0 +1,31 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'typeid' => ['filter' => FILTER_VALIDATE_INT ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => 'urldecode'] + ); + + protected function generate() : void + { + $res = []; + + if ($this->_get['type'] && $this->_get['typeid']) + $res = ScreenshotMgr::getScreenshots($this->_get['type'], $this->_get['typeid']); + else if ($this->_get['user']) + if ($uId = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user'])) + $res = ScreenshotMgr::getScreenshots(userId: $uId); + + $this->result = 'ssm_screenshotData = '.Util::toJSON($res); + } +} diff --git a/endpoints/admin/screenshots_relocate.php b/endpoints/admin/screenshots_relocate.php new file mode 100644 index 00000000..03fd0d5d --- /dev/null +++ b/endpoints/admin/screenshots_relocate.php @@ -0,0 +1,48 @@ + ['filter' => FILTER_VALIDATE_INT], + 'typeid' => ['filter' => FILTER_VALIDATE_INT] + // (but not type..?) + ); + + protected function generate() : void + { + if (!$this->assertGET('id', 'typeid')) + { + trigger_error('AdminScreenshotsActionRelocateResponse - screenshotId or typeId empty', E_USER_ERROR); + return; + } + + [$type, $oldTypeId] = array_values(DB::Aowow()->selectRow('SELECT `type`, `typeId` FROM ::screenshots WHERE `id` = %i', $this->_get['id'])); + $typeId = $this->_get['typeid']; + + if (Type::validateIds($type, $typeId)) + { + $tbl = Type::getClassAttrib($type, 'dataTable'); + + // move screenshot + DB::Aowow()->qry('UPDATE ::screenshots SET `typeId` = %i WHERE `id` = %i', $typeId, $this->_get['id']); + + // flag target as having screenshot + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_SCREENSHOT, $typeId); + + // deflag source for having had screenshots (maybe) + $ssInfo = DB::Aowow()->selectRow('SELECT IF(BIT_OR(~`status`) & %i, 1, 0) AS "hasMore" FROM ::screenshots WHERE `status`& %i AND `type` = %i AND `typeId` = %i', CC_FLAG_DELETED, CC_FLAG_APPROVED, $type, $oldTypeId); + if ($ssInfo || !$ssInfo['hasMore']) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` & ~%i WHERE `id` = %i', $tbl, CUSTOM_HAS_SCREENSHOT, $oldTypeId); + } + else + trigger_error('AdminScreenshotsActionRelocateResponse - invalid typeId #'.$typeId.' for type #'.$type, E_USER_ERROR); + } +} diff --git a/endpoints/admin/screenshots_sticky.php b/endpoints/admin/screenshots_sticky.php new file mode 100644 index 00000000..d95ac874 --- /dev/null +++ b/endpoints/admin/screenshots_sticky.php @@ -0,0 +1,72 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('AdminScreenshotsActionStickyResponse - screenshotId empty', E_USER_ERROR); + return; + } + + // this one is a bit strange: as far as i've seen, the only thing a 'sticky' screenshot does is show up in the infobox + // this also means, that only one screenshot per page should be sticky + // so, handle it one by one and the last one affecting one particular type/typId-key gets the cake + $ssEntries = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `userIdOwner`, `date`, `type`, `typeId`, `status` FROM ::screenshots WHERE (`status` & %i) = 0 AND `id` IN %in', CC_FLAG_DELETED, $this->_get['id']); + foreach ($ssEntries as $id => $ssData) + { + // approve yet unapproved screenshots + if (!($ssData['status'] & CC_FLAG_APPROVED)) + { + ScreenshotMgr::init(); + + if (!ScreenshotMgr::loadFile(ScreenshotMgr::PATH_PENDING, $id)) + continue; + + if (!ScreenshotMgr::createResized($id)) + continue; + + if (!ScreenshotMgr::createThumbnail($id)) + continue; + + // move pending > normal + if (!rename(sprintf(ScreenshotMgr::PATH_PENDING, $id), sprintf(ScreenshotMgr::PATH_NORMAL, $id))) + continue; + + // set as approved in DB + DB::Aowow()->qry('UPDATE ::screenshots SET `status` = %i, `userIdApprove` = %i WHERE `id` = %i', CC_FLAG_APPROVED, User::$id, $id); + + // gain siterep + Util::gainSiteReputation($ssData['userIdOwner'], SITEREP_ACTION_SUBMIT_SCREENSHOT, ['id' => $id, 'what' => 1, 'date' => $ssData['date']]); + + // flag DB entry as having screenshots + if ($tbl = Type::getClassAttrib($ssData['type'], 'dataTable')) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_SCREENSHOT, $ssData['typeId']); + } + + // reset all others + DB::Aowow()->qry('UPDATE ::screenshots a, ::screenshots b SET a.`status` = a.`status` & ~%i WHERE a.`type` = b.`type` AND a.`typeId` = b.`typeId` AND a.`id` <> b.`id` AND b.`id` = %i', CC_FLAG_STICKY, $id); + + // toggle sticky status + DB::Aowow()->qry('UPDATE ::screenshots SET `status` = IF(`status` & %i, `status` & ~%i, `status` | %i) WHERE `id` = %i AND `status` & %i', CC_FLAG_STICKY, CC_FLAG_STICKY, CC_FLAG_STICKY, $id, CC_FLAG_APPROVED); + + unset($ssEntries[$id]); + } + + if ($ssEntries) + trigger_error('AdminScreenshotsActionStickyResponse - screenshot(s) # '.implode(', ', array_keys($ssEntries)).' not in db or flagged as deleted', E_USER_WARNING); + } +} diff --git a/endpoints/admin/siteconfig.php b/endpoints/admin/siteconfig.php new file mode 100644 index 00000000..16db9400 --- /dev/null +++ b/endpoints/admin/siteconfig.php @@ -0,0 +1,113 @@ + Development > Site Configuration + + protected function generate() : void + { + $this->h1 = 'Site Configuration'; + array_unshift($this->title, $this->h1); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + parent::generate(); + + $this->addScript([SC_CSS_STRING, << $catName) + { + $rows = ''; + foreach (Cfg::forCategory($idx) as $key => [$value, $flags, , $default, $comment]) + $rows .= $this->buildRow($key, $value, $flags, $default, $comment); + + if ($idx == Cfg::CAT_MISCELLANEOUS) + $rows .= 'new configuration'; + + if (!$rows) + continue; + + $this->lvTabs->addDataTab(Profiler::urlize($catName), $catName, '' . $head . $rows . '
'); + } + } + + private function buildRow(string $key, string $value, int $flags, ?string $default, string $comment) : string + { + $buff = ''; + $info = explode(' - ', $comment); + $key = $flags & Cfg::FLAG_PHP ? strtolower($key) : strtoupper($key); + + // name + if (!empty($info[0])) + $buff .= ''.sprintf(Util::$dfnString, $info[0], $key).''; + else + $buff .= ''.$key.''; + + // value + if ($flags & Cfg::FLAG_TYPE_BOOL) + $buff .= '
'; + else if ($flags & Cfg::FLAG_OPT_LIST && !empty($info[1])) + { + $buff .= ''; + } + else if ($flags & Cfg::FLAG_BITMASK && !empty($info[1])) + { + $buff .= '
'; + foreach (explode(', ', $info[1]) as $option) + { + [$idx, $name] = explode(':', $option); + $buff .= ''; + } + $buff .= '
'; + } + else + $buff .= ''; + + // actions + $buff .= ''; + + $buff .= ''; + + if ($default) + $buff .= '|'; + else + $buff .= '|'; + + if (!($flags & Cfg::FLAG_PERSISTENT)) + $buff .= '|'; + + $buff .= ''; + + return $buff; + } +} + +?> diff --git a/endpoints/admin/siteconfig_add.php b/endpoints/admin/siteconfig_add.php new file mode 100644 index 00000000..a99e77a2 --- /dev/null +++ b/endpoints/admin/siteconfig_add.php @@ -0,0 +1,34 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Cfg::PATTERN_CONF_KEY_FULL]], + 'val' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ] + ); + + protected function generate() : void + { + if (!$this->assertGET('key', 'val')) + { + trigger_error('AdminSiteconfigActionAddResponse - malformed request received', E_USER_ERROR); + $this->result = Lang::main('intError'); + return; + } + + $key = trim($this->_get['key']); + $val = trim(urldecode($this->_get['val'])); + + $this->result = Cfg::add($key, $val); + } +} + +?> diff --git a/endpoints/admin/siteconfig_remove.php b/endpoints/admin/siteconfig_remove.php new file mode 100644 index 00000000..cef906d0 --- /dev/null +++ b/endpoints/admin/siteconfig_remove.php @@ -0,0 +1,30 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Cfg::PATTERN_CONF_KEY_FULL]] + ); + + protected function generate() : void + { + if (!$this->assertGET('key')) + { + trigger_error('AdminSiteconfigActionRemoveResponse - malformed request received', E_USER_ERROR); + $this->result = Lang::main('intError'); + return; + } + + $this->result = Cfg::delete($this->_get['key']); + } +} + +?> diff --git a/endpoints/admin/siteconfig_update.php b/endpoints/admin/siteconfig_update.php new file mode 100644 index 00000000..5afe0bec --- /dev/null +++ b/endpoints/admin/siteconfig_update.php @@ -0,0 +1,34 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Cfg::PATTERN_CONF_KEY_FULL]], + 'val' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ] + ); + + protected function generate() : void + { + if (!$this->assertGET('key', 'val')) + { + trigger_error('AdminSiteconfigActionUpdateResponse - malformed request received', E_USER_ERROR); + $this->result = Lang::main('intError'); + return; + } + + $key = trim($this->_get['key']); + $val = trim(urldecode($this->_get['val'])); + + $this->result = Cfg::set($key, $val); + } +} + +?> diff --git a/endpoints/admin/spawn-override.php b/endpoints/admin/spawn-override.php new file mode 100644 index 00000000..8b6931ab --- /dev/null +++ b/endpoints/admin/spawn-override.php @@ -0,0 +1,105 @@ + ['filter' => FILTER_VALIDATE_INT], + 'guid' => ['filter' => FILTER_VALIDATE_INT], + 'area' => ['filter' => FILTER_VALIDATE_INT], + 'floor' => ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertGET('type', 'guid', 'area', 'floor')) + { + trigger_error('AdminSpawnoverrideResponse - malformed request received', E_USER_ERROR); + $this->result = self::ERR_MISCELLANEOUS; + return; + } + + $guid = $this->_get['guid']; + $type = $this->_get['type']; + $area = $this->_get['area']; + $floor = $this->_get['floor']; + + if (!in_array($type, [Type::NPC, Type::OBJECT, Type::SOUND, Type::AREATRIGGER, Type::ZONE])) + { + trigger_error('AdminSpawnoverrideResponse - can\'t move pip of type '.Type::getFileString($type), E_USER_ERROR); + $this->result = self::ERR_WRONG_TYPE; + return; + } + + DB::Aowow()->qry('REPLACE INTO ::spawns_override (`type`, `typeGuid`, `areaId`, `floor`, `revision`) VALUES (%i, %i, %i, %i, %i)', $type, $guid, $area, $floor, AOWOW_REVISION); + + $wPos = WorldPosition::getForGUID($type, $guid); + if (!$wPos) + { + $this->result = self::ERR_WORLD_POS; + return; + } + + $point = WorldPosition::toZonePos($wPos[$guid]['mapId'], $wPos[$guid]['posX'], $wPos[$guid]['posY'], $area, $floor); + if (!$point) + { + $this->result = self::ERR_NO_POINTS; + return; + } + + $updGUIDs = [$guid]; + $newPos = array( + 'posX' => $point[0]['posX'], + 'posY' => $point[0]['posY'], + 'areaId' => $point[0]['areaId'], + 'floor' => $point[0]['floor'] + ); + + // if creature try for waypoints + if ($type == Type::NPC) + { + if ($swp = DB::World()->selectAssoc('SELECT -w.`id` AS "entry", w.`point` AS "pointId", w.`position_x` AS "posX", w.`position_y` AS "posY" FROM creature_addon ca JOIN waypoint_data w ON w.`id` = ca.`path_id` WHERE ca.`guid` = %i AND ca.`path_id` <> 0', $guid)) + { + foreach ($swp as $w) + { + if ($point = WorldPosition::toZonePos($wPos[$guid]['mapId'], $w['posX'], $w['posY'], $area, $floor)) + { + $p = array( + 'posX' => $point[0]['posX'], + 'posY' => $point[0]['posY'], + 'areaId' => $point[0]['areaId'], + 'floor' => $point[0]['floor'] + ); + + DB::Aowow()->qry('UPDATE ::creature_waypoints SET %a WHERE `creatureOrPath` = %i AND `point` = %i', $p, $w['entry'], $w['pointId']); + } + } + } + + // also move linked vehicle accessories (on the very same position) + $updGUIDs = array_merge($updGUIDs, DB::Aowow()->selectCol('SELECT s2.`guid` FROM ::spawns s1 JOIN ::spawns s2 ON s1.`posX` = s2.`posX` AND s1.`posY` = s2.`posY` AND + s1.`areaId` = s2.`areaId` AND s1.`floor` = s2.`floor` AND s2.`guid` < 0 WHERE s1.`guid` = %i', $guid)); + } + + if (DB::Aowow()->qry('UPDATE ::spawns SET %a WHERE `type` = %i AND `guid` IN %in', $newPos, $type, $updGUIDs)) + $this->result = self::ERR_NONE; + else + $this->result = self::ERR_WRITE_DB; + } +} + +?> diff --git a/endpoints/admin/videos.php b/endpoints/admin/videos.php new file mode 100644 index 00000000..04956306 --- /dev/null +++ b/endpoints/admin/videos.php @@ -0,0 +1,68 @@ + Content > Videos + + protected array $scripts = array( + [SC_JS_FILE, 'js/video.js'], + [SC_CSS_STRING, '.layout {margin: 0px 25px; max-width: inherit; min-width: 1200px; }'], + [SC_CSS_STRING, '#highlightedRow { background-color: #322C1C; }'] + ); + protected array $expectedGET = array( + 'action' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']], + 'all' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']], + 'type' => ['filter' => FILTER_VALIDATE_INT ], + 'typeid' => ['filter' => FILTER_VALIDATE_INT ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => 'urldecode' ] + ); + + public ?bool $getAll = null; + public array $viPages = []; + public array $viData = []; + public int $viNFound = 0; + public array $pageTypes = []; + + protected function generate() : void + { + $this->h1 = 'Video Manager'; + + // types that can have videos + foreach (Type::getClassesFor(0, 'contribute', CONTRIBUTE_SS) as $type => $obj) + $this->pageTypes[$type] = Util::ucWords(Lang::game(Type::getFileString($type))); + + $viGetAll = $this->_get['all']; + $viPages = []; + $viData = []; + $nMatches = 0; + + if ($this->_get['type'] && $this->_get['typeid']) + $viData = VideoMgr::getVideos($this->_get['type'], $this->_get['typeid'], nFound: $nMatches); + else if ($this->_get['user']) + { + if (mb_strlen($this->_get['user']) >= 3) + if ($uId = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user'])) + $viData = VideoMgr::getVideos(userId: $uId, nFound: $nMatches); + } + else + $viPages = VideoMgr::getPages($viGetAll, $nMatches); + + $this->getAll = $viGetAll; + $this->viPages = $viPages; + $this->viData = $viData; + $this->viNFound = $nMatches; // ssm_numPagesFound + + parent::generate(); + } +} diff --git a/endpoints/admin/videos_approve.php b/endpoints/admin/videos_approve.php new file mode 100644 index 00000000..efa12210 --- /dev/null +++ b/endpoints/admin/videos_approve.php @@ -0,0 +1,44 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('AdminVideosActionApproveResponse - videoId empty', E_USER_ERROR); + return; + } + + $viEntries = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `userIdOwner`, `date`, `type`, `typeId` FROM ::videos WHERE (`status` & %i) = 0 AND `id` IN %in', CC_FLAG_APPROVED, $this->_get['id']); + foreach ($viEntries as $id => $viData) + { + // set as approved in DB + DB::Aowow()->qry('UPDATE ::videos SET `status` = %i, `userIdApprove` = %i WHERE `id` = %i', CC_FLAG_APPROVED, User::$id, $id); + + // gain siterep + Util::gainSiteReputation($viData['userIdOwner'], SITEREP_ACTION_SUGGEST_VIDEO, ['id' => $id, 'what' => 1, 'date' => $viData['date']]); + + // flag DB entry as having videos + if ($tbl = Type::getClassAttrib($viData['type'], 'dataTable')) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_VIDEO, $viData['typeId']); + + unset($viEntries[$id]); + } + + if (!$viEntries) + trigger_error('AdminVideosActionApproveResponse - video(s) # '.implode(', ', array_keys($viEntries)).' not in db or already approved', E_USER_WARNING); + } +} diff --git a/endpoints/admin/videos_delete.php b/endpoints/admin/videos_delete.php new file mode 100644 index 00000000..7a404124 --- /dev/null +++ b/endpoints/admin/videos_delete.php @@ -0,0 +1,43 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + // 2 steps: 1) remove from sight, 2) remove from disk + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('AdminVideosActionDeleteResponse - videoId empty', E_USER_ERROR); + return; + } + + // irrevocably purge files already flagged as deleted (should only exist as pending) + if (User::isInGroup(U_GROUP_ADMIN)) + DB::Aowow()->selectCell('SELECT 1 FROM ::videos WHERE `status` & %i AND `id` IN %in', CC_FLAG_DELETED, $this->_get['id']); + + // flag as deleted if not aready + $oldEntries = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, GROUP_CONCAT(`typeId`) FROM ::videos WHERE `id` IN %in GROUP BY `type`', $this->_get['id']); + DB::Aowow()->qry('UPDATE ::videos SET `status` = %i, `userIdDelete` = %i WHERE (`status` & %i) = 0 AND `id` IN %in', CC_FLAG_DELETED, User::$id, CC_FLAG_DELETED, $this->_get['id']); + + // deflag db entry as having videos + foreach ($oldEntries as $type => $typeIds) + { + $typeIds = explode(',', $typeIds); + $toUnflag = DB::Aowow()->selectCol('SELECT `typeId` AS ARRAY_KEY, IF(BIT_OR(`status`) & %i, 1, 0) AS "hasMore" FROM ::videos WHERE `type` = %i AND `typeId` IN %in GROUP BY `typeId` HAVING `hasMore` = 0', CC_FLAG_APPROVED, $type, $typeIds); + if ($toUnflag && ($tbl = Type::getClassAttrib($type, 'dataTable'))) + DB::Aowow()->qry('UPDATE %n SET cuFlags = cuFlags & ~%i WHERE id IN %in', $tbl, CUSTOM_HAS_VIDEO, array_keys($toUnflag)); + } + } +} diff --git a/endpoints/admin/videos_edittitle.php b/endpoints/admin/videos_edittitle.php new file mode 100644 index 00000000..d6523f6d --- /dev/null +++ b/endpoints/admin/videos_edittitle.php @@ -0,0 +1,31 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + protected array $expectedPOST = array( + 'title' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + return; + + $caption = $this->handleCaption($this->_post['title']); + + DB::Aowow()->qry('UPDATE ::videos SET `caption` = %s WHERE `id` = %i', $caption, $this->_get['id'][0]); + } +} diff --git a/endpoints/admin/videos_list.php b/endpoints/admin/videos_list.php new file mode 100644 index 00000000..7b02d12b --- /dev/null +++ b/endpoints/admin/videos_list.php @@ -0,0 +1,23 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + protected function generate() : void + { + $pages = VideoMgr::getPages($this->_get['all'], $nPages); + $this->result = 'vim_videoPages = '.Util::toJSON($pages).";\n"; + $this->result .= 'vim_numPagesFound = '.$nPages.';'; + } +} diff --git a/endpoints/admin/videos_manage.php b/endpoints/admin/videos_manage.php new file mode 100644 index 00000000..fb5180f7 --- /dev/null +++ b/endpoints/admin/videos_manage.php @@ -0,0 +1,31 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'typeid' => ['filter' => FILTER_VALIDATE_INT ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => 'urldecode'] + ); + + protected function generate() : void + { + $res = []; + + if ($this->_get['type'] && $this->_get['typeid']) + $res = VideoMgr::getVideos($this->_get['type'], $this->_get['typeid']); + else if ($this->_get['user']) + if ($uId = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user'])) + $res = VideoMgr::getVideos(userId: $uId); + + $this->result = 'vim_videoData = '.Util::toJSON($res); + } +} diff --git a/endpoints/admin/videos_order.php b/endpoints/admin/videos_order.php new file mode 100644 index 00000000..05fd749e --- /dev/null +++ b/endpoints/admin/videos_order.php @@ -0,0 +1,57 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned'] ], + 'move' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => -1, 'max_range' => 1]] // -1 = up, 1 = down + ); + + protected function generate() : void + { + if (!$this->assertGET('id', 'move') || $this->_get['move'] === 0) + { + trigger_error('AdminVideosActionOrderResponse - id or move empty', E_USER_ERROR); + return; + } + + $id = $this->_get['id'][0]; + + $videos = DB::Aowow()->selectCol('SELECT a.`id` AS ARRAY_KEY, a.`pos` FROM ::videos a, ::videos b WHERE a.`type` = b.`type` AND a.`typeId` = b.`typeId` AND (a.`status` & %i) = 0 AND b.`id` = %i ORDER BY a.`pos` ASC', CC_FLAG_DELETED, $id); + if (!$videos || count($videos) == 1) + { + trigger_error('AdminVideosActionOrderResponse - not enough videos to sort', E_USER_WARNING); + return; + } + + $dir = $this->_get['move']; + $curPos = $videos[$id]; + + if ($dir == -1 && $curPos == 0) + { + trigger_error('AdminVideosActionOrderResponse - video #'.$id.' already in top position', E_USER_WARNING); + return; + } + + if ($dir == 1 && $curPos + 1 == count($videos)) + { + trigger_error('AdminVideosActionOrderResponse - video #'.$id.' already in bottom position', E_USER_WARNING); + return; + } + + $oldKey = array_search($curPos + $dir, $videos); + $videos[$oldKey] -= $dir; + $videos[$id] += $dir; + + foreach ($videos as $id => $pos) + DB::Aowow()->qry('UPDATE ::videos SET `pos` = %i WHERE `id` = %i', $pos, $id); + } +} diff --git a/endpoints/admin/videos_relocate.php b/endpoints/admin/videos_relocate.php new file mode 100644 index 00000000..12230906 --- /dev/null +++ b/endpoints/admin/videos_relocate.php @@ -0,0 +1,49 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']], + 'typeid' => ['filter' => FILTER_VALIDATE_INT ] + // (but not type..?) + ); + + protected function generate() : void + { + if (!$this->assertGET('id', 'typeid')) + { + trigger_error('AdminVideosActionRelocateResponse - videoId or typeId empty', E_USER_ERROR); + return; + } + + $id = $this->_get['id'][0]; + [$type, $oldTypeId] = array_values(DB::Aowow()->selectRow('SELECT `type`, `typeId` FROM ::videos WHERE `id` = %i', $id)); + $typeId = $this->_get['typeid']; + + if (Type::validateIds($type, $typeId)) + { + $tbl = Type::getClassAttrib($type, 'dataTable'); + + // move video + DB::Aowow()->qry('UPDATE ::videos SET `typeId` = %i WHERE `id` = %i', $typeId, $id); + + // flag target as having video + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_VIDEO, $typeId); + + // deflag source for having had videos (maybe) + $viInfo = DB::Aowow()->selectRow('SELECT IF(BIT_OR(~`status`) & %i, 1, 0) AS "hasMore" FROM ::videos WHERE `status`& %i AND `type` = %i AND `typeId` = %i', CC_FLAG_DELETED, CC_FLAG_APPROVED, $type, $oldTypeId); + if ($viInfo || !$viInfo['hasMore']) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` & ~%i WHERE `id` = %i', $tbl, CUSTOM_HAS_VIDEO, $oldTypeId); + } + else + trigger_error('AdminVideosActionRelocateResponse - invalid typeId #'.$typeId.' for type #'.$type, E_USER_ERROR); + } +} diff --git a/endpoints/admin/videos_sticky.php b/endpoints/admin/videos_sticky.php new file mode 100644 index 00000000..40c537af --- /dev/null +++ b/endpoints/admin/videos_sticky.php @@ -0,0 +1,56 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('AdminVideosActionStickyResponse - videoId empty', E_USER_ERROR); + return; + } + + // this one is a bit strange: as far as i've seen, the only thing a 'sticky' video does is show up in the infobox + // this also means, that only one video per page should be sticky + // so, handle it one by one and the last one affecting one particular type/typId-key gets the cake + $viEntries = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `userIdOwner`, `date`, `type`, `typeId`, `status` FROM ::videos WHERE (`status` & %i) = 0 AND `id` IN %in', CC_FLAG_DELETED, $this->_get['id']); + foreach ($viEntries as $id => $viData) + { + // approve yet unapproved videos + if (!($viData['status'] & CC_FLAG_APPROVED)) + { + // set as approved in DB + DB::Aowow()->qry('UPDATE ::videos SET `status` = %i, `userIdApprove` = %i WHERE `id` = %i', CC_FLAG_APPROVED, User::$id, $id); + + // gain siterep + Util::gainSiteReputation($viData['userIdOwner'], SITEREP_ACTION_SUGGEST_VIDEO, ['id' => $id, 'what' => 1, 'date' => $viData['date']]); + + // flag DB entry as having videos + if ($tbl = Type::getClassAttrib($viData['type'], 'dataTable')) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_VIDEO, $viData['typeId']); + } + + // reset all others + DB::Aowow()->qry('UPDATE ::videos a, ::videos b SET a.`status` = a.`status` & ~%i WHERE a.`type` = b.`type` AND a.`typeId` = b.`typeId` AND a.`id` <> b.`id` AND b.`id` = %i', CC_FLAG_STICKY, $id); + + // toggle sticky status + DB::Aowow()->qry('UPDATE ::videos SET `status` = IF(`status` & %i, `status` & ~%i, `status` | %i) WHERE `id` = %i AND `status` & %i', CC_FLAG_STICKY, CC_FLAG_STICKY, CC_FLAG_STICKY, $id, CC_FLAG_APPROVED); + + unset($viEntries[$id]); + } + + if ($viEntries) + trigger_error('AdminVideosActionStickyResponse - video(s) # '.implode(', ', array_keys($viEntries)).' not in db or flagged as deleted', E_USER_WARNING); + } +} diff --git a/endpoints/admin/weight-presets.php b/endpoints/admin/weight-presets.php new file mode 100644 index 00000000..3fa4a0ab --- /dev/null +++ b/endpoints/admin/weight-presets.php @@ -0,0 +1,54 @@ + Development > Weight Presets + + protected array $scripts = array( + [SC_JS_FILE, 'js/filters.js'], + [SC_CSS_STRING, '.wt-edit {display:inline-block; vertical-align:top; width:350px;}'] + ); + + protected function generate() : void + { + $this->h1 = 'Weight Presets'; + array_unshift($this->title, $this->h1); + + $head = $body = ''; + + $scales = DB::Aowow()->selectAssoc('SELECT `class` AS ARRAY_KEY, `id` AS ARRAY_KEY2, `name`, `icon` FROM ::account_weightscales WHERE `userId` = 0'); + $weights = DB::Aowow()->selectCol('SELECT awd.`id` AS ARRAY_KEY, awd.`field` AS ARRAY_KEY2, awd.`val` FROM ::account_weightscale_data awd JOIN ::account_weightscales ad ON awd.`id` = ad.`id` WHERE ad.`userId` = 0'); + foreach ($scales as $cl => $data) + { + $ul = ''; + foreach ($data as $id => $s) + { + $weights[$id]['__icon'] = $s['icon']; + $ul .= '[url=# onclick="loadScale.bind(this, '.$id.')();"]'.$s['name'].'[/url][br]'; + } + + $head .= '[td=header][class='.$cl.'][/td]'; + $body .= '[td valign=top]'.$ul.'[/td]'; + $this->extendGlobalIds(Type::CHR_CLASS, $cl); + } + + $this->extraText = new Markup('[table class=grid][tr]'.$head.'[/tr][tr]'.$body.'[/tr][/table]', ['allow' => Markup::CLASS_ADMIN], 'text-generic'); + + $this->extraHTML = '\n\n"; + + parent::generate(); + } +} + +?> diff --git a/endpoints/admin/weight-presets_save.php b/endpoints/admin/weight-presets_save.php new file mode 100644 index 00000000..c73c17ff --- /dev/null +++ b/endpoints/admin/weight-presets_save.php @@ -0,0 +1,74 @@ + ['filter' => FILTER_VALIDATE_INT ], + '__icon' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Cfg::PATTERN_CONF_KEY_FULL]], + 'scale' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkScale'] ] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id', '__icon', 'scale')) + { + trigger_error('AdminWeightpresetsActionSaveResponse - malformed request received', E_USER_ERROR); + $this->result = self::ERR_MISCELLANEOUS; + return; + } + + // save to db + DB::Aowow()->qry('DELETE FROM ::account_weightscale_data WHERE `id` = %i', $this->_post['id']); + DB::Aowow()->qry('UPDATE ::account_weightscales SET `icon`= %s WHERE `id` = %i', $this->_post['__icon'], $this->_post['id']); + + foreach (explode(',', $this->_post['scale']) as $s) + { + [$k, $v] = explode(':', $s); + + if (!Stat::getWeightJson($k) || $v < 1) + continue; + + if (DB::Aowow()->qry('INSERT INTO ::account_weightscale_data VALUES (%i, %s, %i)', $this->_post['id'], $k, $v) === null) + { + trigger_error('AdminWeightpresetsActionSaveResponse - failed to write to database', E_USER_ERROR); + $this->result = self::ERR_WRITE_DB; + return; + } + } + + // write dataset + exec('php aowow --build=weightPresets', $out); + foreach ($out as $o) + if (strstr($o, 'ERR')) + { + trigger_error('AdminWeightpresetsActionSaveResponse - failed to write dataset' . $o, E_USER_ERROR); + $this->result = self::ERR_WRITE_FILE; + return; + } + + // all done + $this->result = self::ERR_NONE; + } + + protected static function checkScale(string $val) : string + { + if (preg_match('/^((\w+:\d+)(,\w+:\d+)*)$/', $val)) + return $val; + + return ''; + } +} + +?> diff --git a/endpoints/areatrigger/areatrigger.php b/endpoints/areatrigger/areatrigger.php new file mode 100644 index 00000000..c8a02e54 --- /dev/null +++ b/endpoints/areatrigger/areatrigger.php @@ -0,0 +1,141 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new AreaTriggerList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('areatrigger'), Lang::areatrigger('notFound')); + + $this->h1 = $this->subject->getField('name') ?: 'Areatrigger #'.$this->typeId; + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('type'); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('areatrigger'))); + + + /****************/ + /* Main Content */ + /****************/ + + $_type = $this->subject->getField('type'); + + // get spawns + if ($spawns = $this->subject->getSpawns(SPAWNINFO_FULL)) + { + $this->addDataLoader('zones'); + $this->map = array( + ['parent' => 'mapper-generic'], // Mapper + $spawns, // mapperData + null, // ShowOnMap + [Lang::areatrigger('foundIn')] // foundIn + ); + foreach ($spawns as $areaId => $_) + $this->map[3][$areaId] = ZoneList::getName($areaId); + } + + // Smart AI + if ($_type == AT_TYPE_SMART) + { + $sai = new SmartAI(SmartAI::SRC_TYPE_AREATRIGGER, $this->typeId, ['teleportTargetArea' => $this->subject->getField('areaId')]); + if ($sai->prepare()) + { + $this->extendGlobalData($sai->getJSGlobals()); + $this->smartAI = $sai->getMarkup(); + } + } + + $this->redButtons = array( + BUTTON_LINKS => false, + BUTTON_WOWHEAD => false + ); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: conditions + $cnd = new Conditions(); + $cnd->getBySource(Conditions::SRC_AREATRIGGER_CLIENT, entry: $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab()) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + if ($_type == AT_TYPE_OBJECTIVE) + { + $relQuest = new QuestList(array(['id', $this->subject->getField('quest')])); + if (!$relQuest->error) + { + $this->extendGlobalData($relQuest->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); + $this->lvTabs->addListviewTab(new Listview(['data' => $relQuest->getListviewData()], QuestList::$brickFile)); + } + } + else if ($_type == AT_TYPE_TELEPORT) + { + $relZone = new ZoneList(array(['id', $this->subject->getField('areaId')])); + if (!$relZone->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $relZone->getListviewData()], ZoneList::$brickFile)); + } + else if ($_type == AT_TYPE_SCRIPT) + { + $relTrigger = new AreaTriggerList(array(['id', $this->typeId, '!'], ['name', $this->subject->getField('name')])); + if (!$relTrigger->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $relTrigger->getListviewData(), 'name' => Util::ucFirst(Lang::game('areatrigger'))]), AreaTriggerList::$brickFile, 'areatrigger'); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/areatriggers/areatriggers.php b/endpoints/areatriggers/areatriggers.php new file mode 100644 index 00000000..0d681c95 --- /dev/null +++ b/endpoints/areatriggers/areatriggers.php @@ -0,0 +1,102 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]]]; + protected array $validCats = [0, 1, 2, 3, 4, 5]; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + if (isset($this->category[0])) + $this->forward('?areatriggers&filter=ty='.$this->category[0]); + + parent::__construct($rawParam); + + $this->filter = new AreaTriggerListFilter($this->_get['filter'] ?? ''); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('areatriggers')); + + $fiForm = $this->filter->values; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + + if (count($fiForm['ty']) == 1) + array_unshift($this->title, Lang::areatrigger('types', $fiForm['ty'][0])); + + + /*************/ + /* Menu Path */ + /*************/ + + if (count($fiForm['ty']) == 1) + $this->breadcrumb[] = $fiForm['ty']; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = false; + + $conditions = [Listview::DEFAULT_SIZE]; + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + $tabData = []; + $trigger = new AreaTriggerList($conditions, ['calcTotal' => true]); + if (!$trigger->error) + { + $tabData['data'] = $trigger->getListviewData(); + + // create note if search limit was exceeded; overwriting 'note' is intentional + if ($trigger->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringEntityString, $trigger->getMatches(), '"'.Lang::game('areatriggers').'"', Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, AreaTriggerList::$brickFile, 'areatrigger')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/arena-team/arena-team.php b/endpoints/arena-team/arena-team.php new file mode 100644 index 00000000..fc1e1a52 --- /dev/null +++ b/endpoints/arena-team/arena-team.php @@ -0,0 +1,148 @@ + Profiler > Arena Team + + protected array $dataLoader = ['realms', 'weight-presets']; + protected array $scripts = array( + [SC_JS_FILE, 'js/profile_all.js'], + [SC_JS_FILE, 'js/profile.js'], + [SC_CSS_FILE, 'css/Profiler.css'] + ); + + public int $type = Type::ARENA_TEAM; + + public function __construct(string $idOrProfile) + { + parent::__construct($idOrProfile); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generateError(); + + if (!$idOrProfile) + $this->generateError(); + + $this->getSubjectFromUrl($idOrProfile); + + // we have an ID > ok + if ($this->typeId) + return; + + // param was incomplete profile > error + if (!$this->subjectName) + $this->generateError(); + + // 3 possibilities + // 1) already synced to aowow + if ($subject = DB::Aowow()->selectRow('SELECT `id`, `realmGUID`, `stub` FROM ::profiler_arena_team WHERE `realm` = %i AND `nameUrl` = %s', $this->realmId, Profiler::urlize($this->subjectName))) + { + $this->typeId = $subject['id']; + + if ($subject['stub']) + $this->handleIncompleteData(Type::ARENA_TEAM, $subject['realmGUID']); + + return; + } + + // 2) not yet synced but exists on realm (wont work if we get passed an urlized name, but there is nothing we can do about it) + $subjects = DB::Characters($this->realmId)->selectAssoc('SELECT at.`arenaTeamId` AS "realmGUID", at.`name`, at.`type` FROM arena_team at WHERE at.`name` = %s', $this->subjectName); + if ($subject = array_find($subjects ?: [], fn($x) => Util::lower($x['name']) === Util::lower($this->subjectName))) + { + $subject['realm'] = $this->realmId; + $subject['stub'] = 1; + $subject['nameUrl'] = Profiler::urlize($subject['name']); + + // create entry from realm with basic info + DB::Aowow()->qry('INSERT IGNORE INTO ::profiler_arena_team %v', $subject); + + $this->handleIncompleteData(Type::ARENA_TEAM, $subject['realmGUID']); + return; + } + + // 3) does not exist at all + $this->notFound(); + } + + protected function generate() : void + { + if ($this->doResync) + { + parent::generate(); + return; + } + + $subject = new LocalArenaTeamList(array(['at.id', $this->typeId])); + if ($subject->error) + $this->notFound(); + + // arena team accessed by id + if (!$this->subjectName) + $this->forward($subject->getProfileUrl()); + + $this->h1 = Lang::profiler('arenaRoster', [$subject->getField('name')]); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->followBreadcrumbPath(); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift( + $this->title, + $subject->getField('name').' ('.$this->realm.' - '.Lang::profiler('regions', $this->region).')', + Util::ucFirst(Lang::profiler('profiler')) + ); + + + /****************/ + /* Main Content */ + /****************/ + + parent::generate(); + + $this->redButtons[BUTTON_RESYNC] = [$this->typeId, 'arena-team']; + + // statistic calculations here + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated'); + + // tab: members + $member = new LocalProfileList(array(['atm.arenaTeamId', $this->typeId])); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $member->getListviewData(PROFILEINFO_CHARACTER | PROFILEINFO_ARENA), + 'sort' => [-15], + 'visibleCols' => ['race', 'classs', 'level', 'talents', 'gearscore', 'rating', 'wins', 'losses'], + 'hiddenCols' => ['guild', 'location'] + ), ProfileList::$brickFile)); + } + + private function notFound() : never + { + parent::generateNotFound(Lang::game('arenateam'), Lang::profiler('notFound', 'arenateam')); + } +} + +?> diff --git a/endpoints/arena-team/resync.php b/endpoints/arena-team/resync.php new file mode 100644 index 00000000..ac57929d --- /dev/null +++ b/endpoints/arena-team/resync.php @@ -0,0 +1,48 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'profile' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional, not used] + profile: [optional, also get related chars] + return: 1 + */ + protected function generate() : void + { + if (!$this->assertGET('id')) + return; + + if ($teams = DB::Aowow()->selectAssoc('SELECT `realm`, `realmGUID` FROM ::profiler_arena_team WHERE `id` IN %in', $this->_get['id'])) + foreach ($teams as $t) + Profiler::scheduleResync(Type::ARENA_TEAM, $t['realm'], $t['realmGUID']); + + if ($this->_get['profile']) + if ($chars = DB::Aowow()->selectAssoc('SELECT `realm`, `realmGUID` FROM ::profiler_profiles p JOIN ::profiler_arena_team_member atm ON atm.`profileId` = p.`id` WHERE atm.`arenaTeamId` IN %in', $this->_get['id'])) + foreach ($chars as $c) + Profiler::scheduleResync(Type::PROFILE, $c['realm'], $c['realmGUID']); + + $this->result = 1; // as string? + } +} + +?> diff --git a/endpoints/arena-team/status.php b/endpoints/arena-team/status.php new file mode 100644 index 00000000..ad89f016 --- /dev/null +++ b/endpoints/arena-team/status.php @@ -0,0 +1,29 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + protected function generate() : void + { + $this->result = Profiler::resyncStatus(Type::ARENA_TEAM, $this->_get['id']); + } +} + +?> diff --git a/endpoints/arena-teams/arena-teams.php b/endpoints/arena-teams/arena-teams.php new file mode 100644 index 00000000..9983422d --- /dev/null +++ b/endpoints/arena-teams/arena-teams.php @@ -0,0 +1,160 @@ + Profiler > Arena Teams + + protected array $dataLoader = ['realms']; + protected array $scripts = array( + [SC_JS_FILE, 'js/filters.js'], + [SC_JS_FILE, 'js/profile_all.js'], + [SC_JS_FILE, 'js/profile.js'] + ); + protected array $expectedGET = array( + 'filter' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + + public int $type = Type::ARENA_TEAM; + + private int $sumSubjects = 0; + + public function __construct(string $rawParam) + { + if (!Cfg::get('PROFILER_ENABLE')) + $this->generateError(); + + $this->getSubjectFromUrl($rawParam); + + parent::__construct($rawParam); + + $realms = []; + foreach (Profiler::getRealms() as $idx => $r) + { + if ($this->region && $r['region'] != $this->region) + continue; + + if ($this->realm && $r['name'] != $this->realm) + continue; + + $this->sumSubjects += DB::Characters($idx)->selectCell('SELECT count(*) FROM arena_team'); + $realms[] = $idx; + } + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new ArenaTeamListFilter($this->_get['filter'] ?? '', ['realms' => $realms]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Lang::game('arenateams'); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->followBreadcrumbPath(); + + + /**************/ + /* Page Title */ + /**************/ + + if ($this->realm) + array_unshift($this->title, $this->realm,/* Cfg::get('BATTLEGROUP'),*/ Lang::profiler('regions', $this->region), Lang::game('arenateams')); + else if ($this->region) + array_unshift($this->title, Lang::profiler('regions', $this->region), Lang::game('arenateams')); + else + array_unshift($this->title, Lang::game('arenateams')); + + + /****************/ + /* Main Content */ + /****************/ + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = ['at.seasonGames', 0, '>']; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + $this->getRegions(); + + $tabData = array( + 'id' => 'arena-teams', + 'data' => [], + 'hideCount' => 1, + 'sort' => [-16], + 'extraCols' => ['$Listview.extraCols.members'], + 'visibleCols' => ['rank', 'wins', 'losses', 'rating'], + 'hiddenCols' => ['arenateam', 'guild'] + ); + + if (!$this->filter->values['sz']) + $tabData['visibleCols'][] = 'size'; + + if ($this->filter->values['si']) + $tabData['hiddenCols'][] = 'faction'; + + $miscParams = ['calcTotal' => true]; + if ($this->realm) + $miscParams['sv'] = $this->realm; + if ($this->region) + $miscParams['rg'] = $this->region; + + $teams = new RemoteArenaTeamList($conditions, $miscParams); + if (!$teams->error) + { + $teams->initializeLocalEntries(); + + $tabData['data'] = $teams->getListviewData(); + + // create note if search limit was exceeded + if ($this->filter->query && $teams->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_arenateamsfound2', $this->sumSubjects, $teams->getMatches()); + $tabData['_truncated'] = 1; + } + else if ($teams->getMatches() > Listview::DEFAULT_SIZE) + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_arenateamsfound', $this->sumSubjects, 0); + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated'); + + $this->lvTabs->addListviewTab(new Listview($tabData, ArenaTeamList::$brickFile, 'membersCol')); + + parent::generate(); + + $this->result->registerDisplayHook('filter', [self::class, 'filterFormHook']); + } + + public static function filterFormHook(Template\PageTemplate &$pt, ArenaTeamListFilter $filter) : void + { + // sort for dropdown-menus + Lang::sort('game', 'cl'); + Lang::sort('game', 'ra'); + } +} + +?> diff --git a/endpoints/class/class.php b/endpoints/class/class.php new file mode 100644 index 00000000..47be4379 --- /dev/null +++ b/endpoints/class/class.php @@ -0,0 +1,304 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new CharClassList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('class'), Lang::chrClass('notFound')); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->typeId; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('class'))); + + + /***********/ + /* Infobox */ + /***********/ + + $cl = ChrClass::from($this->typeId); + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // hero class + if ($this->subject->getField('flags') & 0x40) + $infobox[] = '[tooltip=tooltip_heroclass]'.Lang::game('heroClass').'[/tooltip]'; + + // resource + if ($cl == ChrClass::DRUID) // special Druid case + $infobox[] = Lang::game('resources'). + '[tooltip name=powertype1]'.Lang::game('st', 0).', '.Lang::game('st', 31).', '.Lang::game('st', 2).'[/tooltip][span class=tip tooltip=powertype1]'.Util::ucFirst(Lang::spell('powerTypes', POWER_MANA)).'[/span], '. + '[tooltip name=powertype2]'.Lang::game('st', 5).', '.Lang::game('st', 8).'[/tooltip][span class=tip tooltip=powertype2]'.Util::ucFirst(Lang::spell('powerTypes', POWER_RAGE)).'[/span], '. + '[tooltip name=powertype8]'.Lang::game('st', 1).'[/tooltip][span class=tip tooltip=powertype8]'.Util::ucFirst(Lang::spell('powerTypes', POWER_ENERGY)).'[/span]'; + else if ($cl == ChrClass::DEATHKNIGHT) // special DK case + $infobox[] = Lang::game('resources').'[span]'.Util::ucFirst(Lang::spell('powerTypes', POWER_RUNE)).', '.Util::ucFirst(Lang::spell('powerTypes', $this->subject->getField('powerType'))).'[/span]'; + else // regular case + $infobox[] = Lang::game('resource').'[span]'.Util::ucFirst(Lang::spell('powerTypes', $this->subject->getField('powerType'))).'[/span]'; + + // roles + $roles = []; + for ($i = 0; $i < 4; $i++) + if ($this->subject->getField('roles') & (1 << $i)) + $roles[] = (count($roles) == 2 ? "[br]" : '').Lang::game('_roles', $i); + + if ($roles) + $infobox[] = (count($roles) > 1 ? Lang::game('roles') : Lang::game('role')).implode(', ', $roles); + + // specs + $specList = []; + $skills = new SkillList(array(['id', $this->subject->getField('skills')])); + foreach ($skills->iterate() as $k => $__) + $specList[$k] = '[icon name='.$skills->getField('iconString').'][url=?spells=7.'.$this->typeId.'.'.$k.']'.$skills->getField('name', true).'[/url][/icon]'; + + if ($specList) + $infobox[] = Lang::game('specs').'[ul][li]'.implode('[/li][li]', $specList).'[/li][/ul]'; + + // id + $infobox[] = Lang::chrClass('id') . $this->typeId; + + // icon + if ($_ = $this->subject->getField('iconId')) + { + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $this->extendGlobalIds(Type::ICON, $_); + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $this->expansion = Util::$expansionString[$this->subject->getField('expansion')]; + $this->redButtons = array( + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_WOWHEAD => true, + BUTTON_TALENT => ['href' => '?talent#'.Util::$tcEncoding[self::TC_CLASS_IDS[$this->typeId] * 3], 'pet' => false], + BUTTON_FORUM => false // todo (low): Cfg::get('BOARD_URL') + X + ); + + if ($_ = $this->subject->getField('iconString')) + $this->headIcons[] = $_; + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: spells (grouped) + // '$LANG.tab_armorproficiencies', + // '$LANG.tab_weaponskills', + // '$LANG.tab_glyphs', + // '$LANG.tab_abilities', + // '$LANG.tab_talents', + $conditions = array( + ['s.typeCat', [-13, -11, -2, 7]], + [['s.cuFlags', (SPELL_CU_TRIGGERED | CUSTOM_EXCLUDE_FOR_LISTVIEW), '&'], 0], + [ + DB::OR, + // Glyphs, Proficiencies + ['s.reqClassMask', $cl->toMask(), '&'], + // Abilities / Talents + ['s.skillLine1', $this->subject->getField('skills')], + [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->subject->getField('skills')]] + ], + [ // last rank or unranked + DB::OR, + ['s.cuFlags', SPELL_CU_LAST_RANK, '&'], + ['s.rankNo', 0] + ] + ); + + $genSpells = new SpellList($conditions); + if (!$genSpells->error) + { + $this->extendGlobalData($genSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $genSpells->getListviewData(), + 'id' => 'spells', + 'name' => '$LANG.tab_spells', + 'visibleCols' => ['level', 'schools', 'type', 'classes'], + 'hiddenCols' => ['reagents', 'skill'], + 'sort' => ['-level', 'type', 'name'], + 'computeDataFunc' => '$Listview.funcBox.initSpellFilter', + 'onAfterCreate' => '$Listview.funcBox.addSpellIndicator' + ), SpellList::$brickFile)); + } + + // tab: items (grouped) + $conditions = array( + ['requiredClass', $cl->toMask(), '&'], + ['itemset', 0] + ); + + $items = new ItemList($conditions); + if (!$items->error) + { + $this->extendGlobalData($items->getJSGlobals()); + + $hiddenCols = null; + if ($items->hasDiffFields('requiredRace')) + $hiddenCols = ['side']; + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $items->getListviewData(), + 'id' => 'items', + 'name' => '$LANG.tab_items', + 'visibleCols' => ['dps', 'armor', 'slot'], + 'hiddenCols' => $hiddenCols, + 'computeDataFunc' => '$Listview.funcBox.initSubclassFilter', + 'onAfterCreate' => '$Listview.funcBox.addSubclassIndicator', + 'note' => sprintf(Util::$filterResultString, '?items&filter=cr=152;crs='.$this->typeId.';crv=0'), + '_truncated' => 1 + ), ItemList::$brickFile)); + } + + // tab: quests + $conditions = array( + ['reqClassMask', $cl->toMask(), '&'], + [['reqClassMask', ChrClass::MASK_ALL, '&'], ChrClass::MASK_ALL, '!'] + ); + + $quests = new QuestList($conditions); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $quests->getListviewData(), + 'sort' => ['reqlevel', 'name'] + ), QuestList::$brickFile)); + } + + // tab: itemsets + $sets = new ItemsetList(array(['classMask', $cl->toMask(), '&'])); + if (!$sets->error) + { + $this->extendGlobalData($sets->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sets->getListviewData(), + 'note' => sprintf(Util::$filterResultString, '?itemsets&filter=cl='.$this->typeId), + 'hiddenCols' => ['classes'], + 'sort' => ['-level', 'name'] + ), ItemsetList::$brickFile)); + } + + // tab: trainers + $conditions = array( + ['npcflag', NPC_FLAG_TRAINER | NPC_FLAG_CLASS_TRAINER, '&'], + ['trainerType', 0], // trains class spells + ['trainerRequirement', $this->typeId] + ); + + $trainer = new CreatureList($conditions); + if (!$trainer->error) + { + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $trainer->getListviewData(), + 'id' => 'trainers', + 'name' => '$LANG.tab_trainers' + ), CreatureList::$brickFile)); + } + + // tab: races + $races = new CharRaceList(array(['classMask', $cl->toMask(), '&'])); + if (!$races->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $races->getListviewData()], CharRaceList::$brickFile)); + + // tab: criteria-of + $conditions = array( + DB::AND, + ['ac.type', ACHIEVEMENT_CRITERIA_TYPE_HK_CLASS], + ['ac.value1', $this->typeId] + ); + + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` IN %in AND `value1` = %i', [ACHIEVEMENT_CRITERIA_DATA_TYPE_S_PLAYER_CLASS_RACE, ACHIEVEMENT_CRITERIA_DATA_TYPE_T_PLAYER_CLASS_RACE], $this->typeId)) + $conditions = [DB::OR, $conditions, ['ac.id', $extraCrt]]; + + $crtOf = new AchievementList($conditions); + if (!$crtOf->error) + { + $this->extendGlobalData($crtOf->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $crtOf->getListviewData(), + 'name' => '$LANG.tab_criteriaof', + 'id' => 'criteria-of' + ), AchievementList::$brickFile)); + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::CHR_CLASS, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/classes/classes.php b/endpoints/classes/classes.php new file mode 100644 index 00000000..07e1f6f9 --- /dev/null +++ b/endpoints/classes/classes.php @@ -0,0 +1,48 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('classes')); + + + array_unshift($this->title, Util::ucFirst(Lang::game('classes'))); + + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $classes = new CharClassList(); + if (!$classes->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $classes->getListviewData()], CharClassList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/comment/add-reply.php b/endpoints/comment/add-reply.php new file mode 100644 index 00000000..6b9d7931 --- /dev/null +++ b/endpoints/comment/add-reply.php @@ -0,0 +1,51 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'replyId' => ['filter' => FILTER_VALIDATE_INT ], + 'body' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']] + ); + + protected function generate(): void + { + if (!$this->assertPOST('commentId', 'replyId', 'body')) + { + trigger_error('CommentAddreplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + if (!User::canReply()) + $this->generate404(Lang::main('cannotComment')); + + if (!$this->_post['commentId'] || !DB::Aowow()->selectCell('SELECT 1 FROM ::comments WHERE `id` = %i', $this->_post['commentId'])) + { + trigger_error('CommentAddreplyResponse - parent comment #'.$this->_post['commentId'].' does not exist', E_USER_ERROR); + $this->generate404(Lang::main('intError')); + } + + if (mb_strlen($this->_post['body']) < CommunityContent::REPLY_LENGTH_MIN || mb_strlen($this->_post['body']) > CommunityContent::REPLY_LENGTH_MAX) + $this->generate404(Lang::main('textLength', [mb_strlen($this->_post['body']), CommunityContent::REPLY_LENGTH_MIN, CommunityContent::REPLY_LENGTH_MAX])); + + if (!DB::Aowow()->qry('INSERT INTO ::comments (`userId`, `roles`, `body`, `date`, `replyTo`) VALUES (%i, %i, %s, UNIX_TIMESTAMP(), %i)', User::$id, User::$groups, $this->_post['body'], $this->_post['commentId'])) + { + trigger_error('CommentAddreplyResponse - write to db failed', E_USER_ERROR); + $this->generate404(Lang::main('intError')); + } + + $this->result = Util::toJSON(CommunityContent::getCommentReplies($this->_post['commentId'])); + } +} + +?> diff --git a/endpoints/comment/add.php b/endpoints/comment/add.php new file mode 100644 index 00000000..78dffad6 --- /dev/null +++ b/endpoints/comment/add.php @@ -0,0 +1,79 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']] + ); + protected array $expectedGET = array( + 'type' => ['filter' => FILTER_VALIDATE_INT], + 'typeid' => ['filter' => FILTER_VALIDATE_INT] + ); + + // i .. have problems believing, that everything uses nifty ajax while adding comments requires a brutal header(Loacation: ), yet, thats how it is + protected function generate() : void + { + if (!$this->assertGET('type', 'typeid') || !$this->assertPOST('commentbody') || !Type::validateIds($this->_get['type'], $this->_get['typeid'])) + { + trigger_error('CommentAddResponse - malforemd request received', E_USER_ERROR); + return; // whatever, we cant even send him back + } + + // we now have a valid return target + $idOrUrl = $this->_get['typeid']; + if ($this->_get['type'] == Type::GUIDE) + if ($_ = DB::Aowow()->selectCell('SELECT `url` FROM ::guides WHERE `id` = %i', $this->_get['typeid'])) + $idOrUrl = $_; + + $this->redirectTo = '?'.Type::getFileString($this->_get['type']).'='.$idOrUrl.'#comments'; + + // this type cannot be commented on + if (!Type::checkClassAttrib($this->_get['type'], 'contribute', CONTRIBUTE_CO)) + { + trigger_error('CommentAddResponse - tried to comment on unsupported type: '.Type::getFileString($this->_get['type']), E_USER_ERROR); + $_SESSION['error']['co'] = Lang::main('intError'); + return; + } + + if (!User::canComment()) + { + $_SESSION['error']['co'] = Lang::main('cannotComment'); + return; + } + + $len = mb_strlen($this->_post['commentbody']); + + if ((!User::isInGroup(U_GROUP_MODERATOR) && $len < CommunityContent::COMMENT_LENGTH_MIN) || ($len > CommunityContent::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1))) + { + $_SESSION['error']['co'] = Lang::main('textLength', [$len, CommunityContent::COMMENT_LENGTH_MIN, CommunityContent::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1)]); + return; + } + + if ($postId = DB::Aowow()->qry('INSERT INTO ::comments (`type`, `typeId`, `userId`, `roles`, `body`, `date`) VALUES (%i, %i, %i, %i, %s, UNIX_TIMESTAMP())', $this->_get['type'], $this->_get['typeid'], User::$id, User::$groups, $this->_post['commentbody'])) + { + Util::gainSiteReputation(User::$id, SITEREP_ACTION_COMMENT, ['id' => $postId]); + + // every comment starts with a rating of +1 and i guess the simplest thing to do is create a db-entry with the system as owner + DB::Aowow()->qry('INSERT INTO ::user_ratings (`type`, `entry`, `userId`, `value`) VALUES (%i, %i, 0, 1)', RATING_COMMENT, $postId); + + // flag target with hasComment + if ($tbl = Type::getClassAttrib($this->_get['type'], 'dataTable')) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_COMMENT, $this->_get['typeid']); + + return; + } + + trigger_error('CommentAddResponse - write to db failed', E_USER_ERROR); + $_SESSION['error']['co'] = Lang::main('intError'); + } +} + +?> diff --git a/endpoints/comment/delete-reply.php b/endpoints/comment/delete-reply.php new file mode 100644 index 00000000..096a62dd --- /dev/null +++ b/endpoints/comment/delete-reply.php @@ -0,0 +1,41 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentDeletereplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + $where = [['`id` = %i', $this->_post['id']]]; + if (!User::isInGroup(U_GROUP_MODERATOR)) + $where[] = ['`userId` = %i', User::$id]; + + // flag as deleted + if (DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` | %i, `deleteUserId` = %i, `deleteDate` = UNIX_TIMESTAMP() WHERE %and', CC_FLAG_DELETED, User::$id, $where)) + DB::Aowow()->qry('DELETE FROM ::user_ratings WHERE `type` = %i AND `entry` = %i', RATING_COMMENT, $this->_post['id']); + else + { + trigger_error('CommentDeletereplyResponse - deleting reply #'.$this->_post['id'].' by user #'.User::$id.' from db failed', E_USER_ERROR); + $this->generate404(Lang::main('intError')); + } + } +} + +?> diff --git a/endpoints/comment/delete.php b/endpoints/comment/delete.php new file mode 100644 index 00000000..582a0b01 --- /dev/null +++ b/endpoints/comment/delete.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']], + // 'username' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentDeleteResponse - malformed request received', E_USER_ERROR); + return; + } + + // in theory, there is a username passed alongside if executed from userpage... lets just use the current user (see user.js) + $where = [['`id` IN %in', $this->_post['id']]]; + if (!User::isInGroup(U_GROUP_MODERATOR)) + $where[] = ['`userId` = %i', User::$id]; + + // flag as deleted; unflag subject: hasComment + if (DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` | %i, `deleteUserId` = %i, `deleteDate` = UNIX_TIMESTAMP() WHERE %and', CC_FLAG_DELETED, User::$id, $where)) + { + $coInfo = DB::Aowow()->selectAssoc( + 'SELECT IF(BIT_OR(~b.`flags`) & %i, 1, 0) AS "0", b.`type` AS "1", b.`typeId` AS "2" FROM ::comments a JOIN ::comments b ON a.`type` = b.`type` AND a.`typeId` = b.`typeId` WHERE a.`id` IN %in GROUP BY b.`type`, b.`typeId`', + CC_FLAG_DELETED, $this->_post['id'] + ); + + foreach ($coInfo as [$hasMore, $type, $typeId]) + if (!$hasMore && ($tbl = Type::getClassAttrib($type, 'dataTable'))) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` & ~%i WHERE `id` = %i', $tbl, CUSTOM_HAS_COMMENT, $typeId); + + return; + } + + trigger_error('CommentDeleteResponse - user #'.User::$id.' could not flag comment(s) #'.implode(', ', $this->_post['id']).' as deleted', E_USER_ERROR); + } +} + +?> diff --git a/endpoints/comment/detach-reply.php b/endpoints/comment/detach-reply.php new file mode 100644 index 00000000..7c79f29a --- /dev/null +++ b/endpoints/comment/detach-reply.php @@ -0,0 +1,30 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentDetachreplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + DB::Aowow()->qry('UPDATE ::comments c1, ::comments c2 SET c1.`replyTo` = 0, c1.`type` = c2.`type`, c1.`typeId` = c2.`typeId` WHERE c1.`replyTo` = c2.`id` AND c1.`id` = %i', $this->_post['id']); + } +} + +?> diff --git a/endpoints/comment/downvote-reply.php b/endpoints/comment/downvote-reply.php new file mode 100644 index 00000000..cd035abc --- /dev/null +++ b/endpoints/comment/downvote-reply.php @@ -0,0 +1,55 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentDownvotereplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + if (!User::canDownvote()) + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'cannot downvote' : ''); + + $comment = DB::Aowow()->selectRow('SELECT `userId`, IF(`flags` & %i, 1, 0) AS "deleted" FROM ::comments WHERE `id` = %i', CC_FLAG_DELETED, $this->_post['id']); + if (!$comment) + { + trigger_error('CommentDownvotereplyResponse - comment #'.$this->_post['id'].' not found in db', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'replyID not found' : ''); + } + + if (User::$id == $comment['userId']) // not worth logging? + $this->generate404('LANG.voteself_tip'); + + if ($comment['deleted']) + $this->generate404('LANG.votedeleted_tip'); + + if (is_null(DB::Aowow()->qry('INSERT INTO ::user_ratings (`type`, `entry`, `userId`, `value`) VALUES (%i, %i, %i, %i)', + RATING_COMMENT, $this->_post['id'], User::$id, User::canSupervote() ? -2 : -1 + ))) + { + trigger_error('CommentDownvotereplyResponse - write to db failed', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'write to db failed' : ''); + } + + Util::gainSiteReputation($comment['userId'], SITEREP_ACTION_DOWNVOTED, ['id' => $this->_post['id'], 'voterId' => User::$id]); + User::decrementDailyVotes(); + } +} + +?> diff --git a/endpoints/comment/edit-reply.php b/endpoints/comment/edit-reply.php new file mode 100644 index 00000000..7a6aed50 --- /dev/null +++ b/endpoints/comment/edit-reply.php @@ -0,0 +1,65 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'replyId' => ['filter' => FILTER_VALIDATE_INT ], + 'body' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']] + ); + + protected function generate() : void + { + if (!$this->assertPOST('commentId', 'replyId', 'body')) + { + trigger_error('CommentEditreplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + $ownerId = DB::Aowow()->selectCell('SELECT `userId` FROM ::comments WHERE `id` = %i AND `replyTo` = %i', $this->_post['replyId'], $this->_post['commentId']); + + if (!User::canReply() || (User::$id != $ownerId && !User::isInGroup(U_GROUP_MODERATOR))) + $this->generate404(Lang::main('cannotComment')); + + if (!$ownerId) + { + trigger_error('CommentEditreplyResponse - comment #'.$this->_post['commentId'].' or reply #'.$this->_post['replyId'].' does not exist', E_USER_ERROR); + $this->generate404(Lang::main('intError')); + } + + if (mb_strlen($this->_post['body']) < CommunityContent::REPLY_LENGTH_MIN || mb_strlen($this->_post['body']) > CommunityContent::REPLY_LENGTH_MAX) + $this->generate404(Lang::main('textLength', [mb_strlen($this->_post['body']), CommunityContent::REPLY_LENGTH_MIN, CommunityContent::REPLY_LENGTH_MAX])); + + $update = array( + 'body' => $this->_post['body'], + 'editUserId' => User::$id, + 'editDate' => time() + ); + if (User::$id == $ownerId) + $update['roles'] = User::$groups; + + $where = [['`id` = %i', $this->_post['replyId']], ['`replyTo` = %i', $this->_post['commentId']]]; + if (!User::isInGroup(U_GROUP_MODERATOR)) + $where[] = ['`userId` = %i', User::$id]; + + if (!DB::Aowow()->qry('UPDATE ::comments SET `editCount` = `editCount` + 1, %a WHERE %and', $update, $where)) + { + trigger_error('CommentEditreplyResponse - write to db failed', E_USER_ERROR); + $this->generate404(Lang::main('intError')); + } + + $this->result = Util::toJSON(CommunityContent::getCommentReplies($this->_post['commentId'])); + } +} + +?> diff --git a/endpoints/comment/edit.php b/endpoints/comment/edit.php new file mode 100644 index 00000000..a3a2e3cb --- /dev/null +++ b/endpoints/comment/edit.php @@ -0,0 +1,63 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']], + 'response' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']] + ); + protected array $expectedGET = array( + 'id' => ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertGET('id') || !$this->assertPOST('body')) + { + trigger_error('CommentEditResponse - malforemd request received', E_USER_ERROR); + return; + } + + $ownerId = DB::Aowow()->selectCell('SELECT `userId` FROM ::comments WHERE `id` = %i', $this->_get['id']); + + if (!User::canComment() || (User::$id != $ownerId && !User::isInGroup(U_GROUP_MODERATOR))) + { + trigger_error('CommentEditResponse - user #'.User::$id.' not allowed to edit comment #'.$this->_get['id'], E_USER_ERROR); + return; + } + + if (!User::isInGroup(U_GROUP_MODERATOR) && mb_strlen($this->_post['body']) < CommunityContent::COMMENT_LENGTH_MIN) + return; // no point in reporting this trifle + + // trim to max length + if (!User::isInGroup(U_GROUP_MODERATOR)) + $this->_post['body'] = mb_substr($this->_post['body'], 0, (CommunityContent::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1))); + + $update = array( + 'body' => $this->_post['body'], + 'editUserId' => User::$id, + 'editDate' => time() + ); + if (User::$id == $ownerId) + $update['roles'] = User::$groups; + + if (User::isInGroup(U_GROUP_MODERATOR)) + { + $update['responseBody'] = $this->_post['response'] ?? ''; + $update['responseUserId'] = User::$id; + $update['responseRoles'] = User::$groups; + } + + DB::Aowow()->qry('UPDATE ::comments SET `editCount` = `editCount` + 1, %a WHERE `id` = %i', $update, $this->_get['id']); + } +} + +?> diff --git a/endpoints/comment/flag-reply.php b/endpoints/comment/flag-reply.php new file mode 100644 index 00000000..55589b45 --- /dev/null +++ b/endpoints/comment/flag-reply.php @@ -0,0 +1,45 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentFlagreplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + $replyOwner = DB::Aowow()->selectCell('SELECT `userId` FROM ::commments WHERE `id` = %i', $this->_post['id']); + if (!$replyOwner) + { + trigger_error('CommentFlagreplyResponse - reply not found', E_USER_ERROR); + $this->generate404(Lang::main('intError')); + } + + // ui element should not be present + if ($replyOwner == User::$id) + $this->generate404(); + + $report = new Report(Report::MODE_COMMENT, Report::CO_INAPPROPRIATE, $this->_post['id']); + if (!$report->create('Report Reply Button Click')) + $this->generate404('LANG.ct_resp_error'.$report->getError()); + else if (count($report->getSimilar()) >= CommunityContent::REPORT_THRESHOLD_AUTO_DELETE) + DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` | %i WHERE `id` = %i', CC_FLAG_DELETED, $this->_post['id']); + } +} + +?> diff --git a/endpoints/comment/out-of-date.php b/endpoints/comment/out-of-date.php new file mode 100644 index 00000000..70cb3dfd --- /dev/null +++ b/endpoints/comment/out-of-date.php @@ -0,0 +1,58 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'remove' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 1]], + 'reason' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentOutofdateResponse - malformed request received', E_USER_ERROR); + if (User::isInGroup(U_GROUP_STAFF)) + $this->result = 'malformed request received'; + } + + $ok = false; + if (User::isInGroup(U_GROUP_MODERATOR)) // directly mark as outdated + { + if (!$this->_post['remove']) + $ok = DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` | %i WHERE `id` = %i', CC_FLAG_OUTDATED, $this->_post['id']); + else + $ok = DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` & ~%i WHERE `id` = %i', CC_FLAG_OUTDATED, $this->_post['id']); + } + else // try to report as outdated + { + $report = new Report(Report::MODE_COMMENT, Report::CO_OUT_OF_DATE, $this->_post['id']); + if (!$report->create($this->_post['reason'])) + $this->result = Lang::main('intError'); + + if (count($report->getSimilar()) >= CommunityContent::REPORT_THRESHOLD_AUTO_OUT_OF_DATE) + $ok = DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` | %i WHERE `id` = %i', CC_FLAG_OUTDATED, $this->_post['id']); + } + + if (!$ok) + { + trigger_error('CommentOutofdateResponse - failed to update comment in db', E_USER_ERROR); + $this->result = Lang::main('intError'); + return; + } + + $this->result = 'ok'; // the js expects the actual characters 'ok' on success, not some json string like '"ok"' + } +} + +?> diff --git a/endpoints/comment/rating.php b/endpoints/comment/rating.php new file mode 100644 index 00000000..d080e71e --- /dev/null +++ b/endpoints/comment/rating.php @@ -0,0 +1,31 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + $this->result = Util::toJSON(['success' => 0]); + return; + } + + if ($votes = DB::Aowow()->selectRow('SELECT 1 AS "success", SUM(IF(`value` > 0, `value`, 0)) AS "up", SUM(IF(`value` < 0, -`value`, 0)) AS "down" FROM ::user_ratings WHERE `type` = %i AND `entry` = %i AND `userId` <> 0 GROUP BY `entry`', RATING_COMMENT, $this->_get['id'])) + $this->result = Util::toJSON($votes); + else + $this->result = Util::toJSON(['success' => 1, 'up' => 0, 'down' => 0]); + } +} + +?> diff --git a/endpoints/comment/show-replies.php b/endpoints/comment/show-replies.php new file mode 100644 index 00000000..2de0ed02 --- /dev/null +++ b/endpoints/comment/show-replies.php @@ -0,0 +1,24 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + $this->result = Util::toJSON([]); + else + $this->result = Util::toJSON(CommunityContent::getCommentReplies($this->_get['id'])); + } +} + +?> diff --git a/endpoints/comment/sticky.php b/endpoints/comment/sticky.php new file mode 100644 index 00000000..16fcf3a7 --- /dev/null +++ b/endpoints/comment/sticky.php @@ -0,0 +1,34 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'sticky' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 0, 'max_range' => 1]] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id', 'sticky')) + { + trigger_error('CommentStickyResponse - malformed request received', E_USER_ERROR); + return; + } + + if ($this->_post['sticky']) + DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` | %i WHERE `id` = %i', CC_FLAG_STICKY, $this->_post['id']); + else + DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` & ~%i WHERE `id` = %i', CC_FLAG_STICKY, $this->_post['id']); + } +} + +?> diff --git a/endpoints/comment/undelete.php b/endpoints/comment/undelete.php new file mode 100644 index 00000000..1d21a6a1 --- /dev/null +++ b/endpoints/comment/undelete.php @@ -0,0 +1,49 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']], + // 'username' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentUndeleteResponse - malformed request received', E_USER_ERROR); + return; + } + + // in theory, there is a username passed alongside if executed from userpage... lets just use the current user (see user.js) + $where = [['`id` IN %in', $this->_post['id']]]; + if (!User::isInGroup(U_GROUP_MODERATOR)) + { + $where[] = ['`deleteUserId` = `userId']; + $where[] = ['`deleteUserId` = %i', User::$id]; + } + + // unflag subject: hasComment + if (DB::Aowow()->qry('UPDATE ::comments SET `flags` = `flags` & ~%i WHERE %and', CC_FLAG_DELETED, $where)) + { + $coInfo = DB::Aowow()->selectAssoc('SELECT `type` AS "0", `typeId` AS "1" FROM ::comments WHERE `id` IN %in GROUP BY `type`, `typeId`', $this->_post['id']); + foreach ($coInfo as [$type, $typeId]) + if ($tbl = Type::getClassAttrib($type, 'dataTable')) + DB::Aowow()->qry('UPDATE %n SET `cuFlags` = `cuFlags` | %i WHERE `id` = %i', $tbl, CUSTOM_HAS_COMMENT, $typeId); + + return; + } + + trigger_error('CommentUndeleteResponse - user #'.User::$id.' could not unflag comment(s) #'.implode(', ', $this->_post['id']).' from deleted', E_USER_ERROR); + } +} + +?> diff --git a/endpoints/comment/upvote-reply.php b/endpoints/comment/upvote-reply.php new file mode 100644 index 00000000..f6e84a34 --- /dev/null +++ b/endpoints/comment/upvote-reply.php @@ -0,0 +1,55 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id')) + { + trigger_error('CommentUpvotereplyResponse - malformed request received', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'request malformed' : ''); + } + + if (!User::canUpvote()) + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'cannot upvote' : ''); + + $comment = DB::Aowow()->selectRow('SELECT `userId`, IF(`flags` & %i, 1, 0) AS "deleted" FROM ::comments WHERE `id` = %i', CC_FLAG_DELETED, $this->_post['id']); + if (!$comment) + { + trigger_error('CommentUpvotereplyResponse - comment #'.$this->_post['id'].' not found in db', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'replyID not found' : ''); + } + + if (User::$id == $comment['userId']) // not worth logging? + $this->generate404('LANG.voteself_tip'); + + if ($comment['deleted']) + $this->generate404('LANG.votedeleted_tip'); + + if (is_null(DB::Aowow()->qry('INSERT INTO ::user_ratings (`type`, `entry`, `userId`, `value`) VALUES (%i, %i, %i, %i)', + RATING_COMMENT, $this->_post['id'], User::$id, User::canSupervote() ? 2 : 1 + ))) + { + trigger_error('CommentUpvotereplyResponse - write to db failed', E_USER_ERROR); + $this->generate404(User::isInGroup(U_GROUP_STAFF) ? 'write to db failed' : ''); + } + + Util::gainSiteReputation($comment['userId'], SITEREP_ACTION_UPVOTED, ['id' => $this->_post['id'], 'voterId' => User::$id]); + User::decrementDailyVotes(); + } +} + +?> diff --git a/endpoints/comment/vote.php b/endpoints/comment/vote.php new file mode 100644 index 00000000..1bf3b1ff --- /dev/null +++ b/endpoints/comment/vote.php @@ -0,0 +1,84 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'rating' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => -2, 'max_range' => 2]] + ); + + protected function generate(): void + { + if (!$this->assertGET('id', 'rating')) + { + trigger_error('CommentVoteResponse - malformed request received', E_USER_ERROR); + $this->result = Util::toJSON(['error' => 1, 'message' => Lang::main('genericError')]); + return; + } + + if (User::getCurrentDailyVotes() <= 0) + { + $this->result = Util::toJSON(['error' => 1, 'message' => Lang::main('tooManyVotes')]); + return; + } + + $target = DB::Aowow()->selectRow( + 'SELECT c.`userId` AS "owner", ur.`value`, IF(c.`flags` & %i, 1, 0) AS "deleted" FROM ::comments c LEFT JOIN ::user_ratings ur ON ur.`type` = %i AND ur.`entry` = c.id AND ur.`userId` = %i WHERE c.id = %i', + CC_FLAG_DELETED, RATING_COMMENT, User::$id, $this->_get['id'] + ); + if (!$target) + { + trigger_error('CommentVoteResponse - target comment #'.$this->_get['id'].' not found', E_USER_ERROR); + $this->result = Util::toJSON(['error' => 1, 'message' => Lang::main('genericError')]); + return; + } + + $val = User::canSupervote() ? 2 : 1; + if ($this->_get['rating'] < 0) + $val *= -1; + + if (User::$id == $target['owner'] || $val != $this->_get['rating'] || $target['deleted']) + { + // circumvented the checks in JS + $this->result = Util::toJSON(['error' => 1, 'message' => Lang::main('genericError')]); + return; + } + + if (($val > 0 && !User::canUpvote()) || ($val < 0 && !User::canDownvote())) + { + $this->result = Util::toJSON(['error' => 1, 'message' => Lang::main('bannedRating')]); + return; + } + + $ok = false; + // old and new have same sign; undo vote (user may have gained/lost access to superVote in the meantime) + if ($target['value'] && ($target['value'] < 0) == ($val < 0)) + $ok = DB::Aowow()->qry('DELETE FROM ::user_ratings WHERE `type` = %i AND `entry` = %i AND `userId` = %i', RATING_COMMENT, $this->_get['id'], User::$id); + else // replace, because we may be overwriting an old, opposing vote + if ($ok = DB::Aowow()->qry('REPLACE INTO ::user_ratings (`type`, `entry`, `userId`, `value`) VALUES (%i, %i, %i, %i)', RATING_COMMENT, $this->_get['id'], User::$id, $val)) + User::decrementDailyVotes(); // do not refund retracted votes! + + if ($ok) + { + if ($val > 0) // gain rep + Util::gainSiteReputation($target['owner'], SITEREP_ACTION_UPVOTED, ['id' => $this->_get['id'], 'voterId' => User::$id]); + else if ($val < 0) + Util::gainSiteReputation($target['owner'], SITEREP_ACTION_DOWNVOTED, ['id' => $this->_get['id'], 'voterId' => User::$id]); + + $this->result = Util::toJSON(['error' => 0]); + } + else + $this->result = Util::toJSON(['error' => 1, 'message' => Lang::main('intError')]); + } +} + +?> diff --git a/endpoints/compare/compare.php b/endpoints/compare/compare.php new file mode 100644 index 00000000..67604b4f --- /dev/null +++ b/endpoints/compare/compare.php @@ -0,0 +1,116 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCompareString']] + ); + protected array $expectedCOOKIE = array( + 'compare_groups' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCompareString']] + ); + + public Summary $summary; + public array $cmpItems = []; + + private string $compareString = ''; + + public function __construct($rawParam) + { + parent::__construct($rawParam); + + // prefer GET over COOKIE + if ($this->_get['compare']) + $this->compareString = $this->_get['compare']; + else if ($this->_cookie['compare_groups']) + $this->compareString = $this->_cookie['compare_groups']; + } + + protected function generate() : void + { + $this->h1 = Lang::main('compareTool'); + + + array_unshift($this->title, $this->h1); + + + $this->summary = new Summary(array( + 'template' => 'compare', + 'id' => 'compare', + 'parent' => 'compare-generic' + )); + + if ($this->compareString) + { + $items = []; + foreach (explode(';', $this->compareString) as $itemsString) + { + $suGroup = []; + foreach (explode(':', $itemsString) as $itemDef) + { + // [itemId, subItem, permEnch, tempEnch, gem1, gem2, gem3, gem4] + $params = array_pad(array_map('intVal', explode('.', $itemDef)), 8, 0); + $items[] = $params[0]; + $suGroup[] = $params; + } + + $this->summary->addGroup($suGroup); + } + + $iList = new ItemList(array(['i.id', $items])); + $data = $iList->getListviewData(ITEMINFO_SUBITEMS | ITEMINFO_JSON); + + foreach ($iList->iterate() as $itemId => $__) + { + if (empty($data[$itemId])) + continue; + + if (!empty($data[$itemId]['subitems'])) + foreach ($data[$itemId]['subitems'] as &$si) + { + $si['enchantment'] = implode(', ', $si['enchantment']); + unset($si['chance']); + } + + $this->cmpItems[$itemId] = [ + 'name_'.Lang::getLocale()->json() => $iList->getField('name', true), + 'quality' => $iList->getField('quality'), + 'icon' => $iList->getField('iconString'), + 'jsonequip' => $data[$itemId] + ]; + } + } + + parent::generate(); + } + + protected static function checkCompareString(string $val) : string + { + $val = urldecode($val); + if (preg_match('/[^-?\d\.:;]/', $val)) + return ''; + + return $val; + } +} + +?> diff --git a/endpoints/contactus/contactus.php b/endpoints/contactus/contactus.php new file mode 100644 index 00000000..ace3fa02 --- /dev/null +++ b/endpoints/contactus/contactus.php @@ -0,0 +1,49 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'reason' => ['filter' => FILTER_VALIDATE_INT ], + 'ua' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'appname' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'page' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/']], + 'desc' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ], + 'id' => ['filter' => FILTER_VALIDATE_INT ], + 'relatedurl' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[[:print:]]+$/']], + 'email' => ['filter' => FILTER_SANITIZE_EMAIL ] + ); + + /* responses + 0: success + 1: captcha invalid + 2: description too long + 3: reason missing + 7: already reported + $: prints response + */ + protected function generate() : void + { + if (!$this->assertPOST('mode', 'reason')) + { + $this->result = 4; + return; + } + + $report = new Report($this->_post['mode'], $this->_post['reason'], $this->_post['id']); + if ($report->create($this->_post['desc'], $this->_post['ua'], $this->_post['appname'], $this->_post['page'], $this->_post['relatedurl'], $this->_post['email'])) + $this->result = 0; + else if (($e = $report->getError()) > 0) + $this->result = $e; + else + $this->result = Lang::main('intError'); + } +} + +?> diff --git a/endpoints/cookie/cookie.php b/endpoints/cookie/cookie.php new file mode 100644 index 00000000..5b5f9af0 --- /dev/null +++ b/endpoints/cookie/cookie.php @@ -0,0 +1,54 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + public function __construct(private string $param) + { + // note that parent::__construct has to come after this + if ($param && preg_match('/^[\w-]+$/i', $param)) + $this->expectedGET = [$param => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']]]; + + // NOW we know, what to expect and sanitize + parent::__construct($param); + } + + /* responses + 0: success + $: silent error + */ + protected function generate() : void + { + if (!$this->param && $this->_get['purge']) + { + if (User::$id && DB::Aowow()->qry('UPDATE ::account_cookies SET `data` = "purged" WHERE `userId` = %i AND `name` LIKE "announcement-%"', User::$id) !== null) + $this->result = 0; + + return; + } + + if (!$this->param || !$this->assertGET($this->param)) + { + trigger_error('CookieBaseResponse - malformed request received', E_USER_ERROR); + return; + } + + if (DB::Aowow()->qry('REPLACE INTO ::account_cookies VALUES (%i, %s, %s)', User::$id, $this->param, $this->_get[$this->param])) + $this->result = 0; + else + trigger_error('CookieBaseResponse - write to db failed', E_USER_ERROR); + } +} + +?> diff --git a/endpoints/currencies/currencies.php b/endpoints/currencies/currencies.php new file mode 100644 index 00000000..bc07dbca --- /dev/null +++ b/endpoints/currencies/currencies.php @@ -0,0 +1,76 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('currencies')); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::currency('cat', $this->category[0])); + + + /*************/ + /* Menu Path */ + /*************/ + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = []; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($this->category) + $conditions[] = ['category', $this->category[0]]; + + $money = new CurrencyList($conditions); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(['data' => $money->getListviewData()], CurrencyList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/currency/currency.php b/endpoints/currency/currency.php new file mode 100644 index 00000000..92a761a7 --- /dev/null +++ b/endpoints/currency/currency.php @@ -0,0 +1,267 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + + protected function generate() : void + { + $this->subject = new CurrencyList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('currency'), Lang::currency('notFound')); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_relItemId = $this->subject->getField('itemId'); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('currency'))); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('category'); + + + /***********/ + /* Infobox */ + /**********/ + + $infobox = Lang::getInfoBoxForFlags(intval($this->subject->getField('cuFlags'))); + + // cap + if ($_ = $this->subject->getField('cap')) + $infobox[] = Lang::currency('cap').Lang::nf($_); + + // id + $infobox[] = Lang::currency('id') . $this->typeId; + + // icon + if ($_ = $this->subject->getField('iconId')) + { + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $this->extendGlobalIds(Type::ICON, $_); + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $hi = $this->subject->getJSGlobals()[Type::CURRENCY][$this->typeId]['icon']; + if ($hi[0] == $hi[1]) + unset($hi[1]); + + $this->headIcons = $hi; + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => true + ); + + if ($_ = $this->subject->getField('description', true)) + $this->extraText = new Markup($_, ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], 'text-generic'); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + if ($this->typeId != CURRENCY_HONOR_POINTS && $this->typeId != CURRENCY_ARENA_POINTS) + { + // tabs: this currency is contained in.. + $lootTabs = new LootByItem($_relItemId); + + if ($lootTabs->getByItem()) + { + $this->extendGlobalData($lootTabs->jsGlobals); + + foreach ($lootTabs->iterate() as [$template, $tabData]) + { + if ($template == 'npc' || $template == 'object') + $this->addDataLoader('zones'); + + if ($template != 'quest') + { + foreach ($tabData['data'] as &$row) + if (!empty($row['stack'])) + $row['currency'] = [[$this->typeId, $row['stack'][0]]]; + + $tabData['extraCols'][] = '$Listview.extraCols.currency'; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, $template)); + } + } + + // tab: sold by + $itemObj = new ItemList(array(['id', $_relItemId])); + if (!empty($itemObj->getExtendedCost()[$_relItemId])) + { + $vendors = $itemObj->getExtendedCost()[$_relItemId]; + $this->extendGlobalData($itemObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $soldBy = new CreatureList(array(['id', array_keys($vendors)])); + if (!$soldBy->error) + { + $sbData = $soldBy->getListviewData(); + $extraCols = ['$Listview.extraCols.stock', "\$Listview.funcBox.createSimpleCol('stack', 'stack', '10%', 'stack')", '$Listview.extraCols.cost', '$Listview.extraCols.condition']; + foreach ($sbData as $k => &$row) + { + $items = []; + $tokens = []; + // note: can only display one entry per row, so only use first entry of each vendor + foreach ($vendors[$k][0] as $id => $qty) + { + if (is_string($id)) + continue; + + if ($id > 0) + $tokens[] = [$id, $qty]; + else if ($id < 0) + $items[] = [-$id, $qty]; + } + + if ($e = $vendors[$k][0]['event']) + if (Conditions::extendListviewRow($row, Conditions::SRC_NONE, $k, [Conditions::ACTIVE_EVENT, $e])) + $this->extendGlobalIds(Type::WORLDEVENT, $e); + + $row['stock'] = $vendors[$k][0]['stock']; + $row['stack'] = $itemObj->getField('buyCount'); + $row['cost'] = array( + $itemObj->getField('buyPrice'), + $items ?: null, + $tokens ?: null + ); + } + + // no conditions > remove conditions column + if (!array_column($sbData, 'condition')) + array_pop($extraCols); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sbData, + 'name' => '$LANG.tab_soldby', + 'id' => 'sold-by-npc', + 'extraCols' => $extraCols, + 'hiddenCols' => ['level', 'type'] + ), CreatureList::$brickFile)); + } + } + } + + // tab: created by (spell) [for items its handled in LootByItem] + if ($this->typeId == CURRENCY_HONOR_POINTS) + { + $createdBy = new SpellList(array(['effect1Id', SPELL_EFFECT_ADD_HONOR], ['effect2Id', SPELL_EFFECT_ADD_HONOR], ['effect3Id', SPELL_EFFECT_ADD_HONOR], DB::OR)); + if (!$createdBy->error) + { + $this->extendGlobalData($createdBy->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $tabData = array( + 'data' => $createdBy->getListviewData(), + 'name' => '$LANG.tab_createdby', + 'id' => 'created-by', + ); + + if ($createdBy->hasSetFields('reagent1', 'reagent2', 'reagent3', 'reagent4', 'reagent5', 'reagent6', 'reagent7', 'reagent8')) + $tabData['visibleCols'] = ['reagents']; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + } + + // tab: currency for + $n = $w = null; + if ($this->typeId == CURRENCY_ARENA_POINTS) + { + $n = '?items&filter=cr=145;crs=1;crv=0'; + $w = '`reqArenaPoints` > 0'; + } + else if ($this->typeId == CURRENCY_HONOR_POINTS) + { + $n = '?items&filter=cr=144;crs=1;crv=0'; + $w = '`reqHonorPoints` > 0'; + } + else + $w = '`reqItemId1` = '.$_relItemId.' OR `reqItemId2` = '.$_relItemId.' OR `reqItemId3` = '.$_relItemId.' OR `reqItemId4` = '.$_relItemId.' OR `reqItemId5` = '.$_relItemId; + + if (!$n && !is_null(ItemListFilter::getCriteriaIndex(158, $_relItemId))) + $n = '?items&filter=cr=158;crs='.$_relItemId.';crv=0'; + + $xCosts = DB::Aowow()->selectCol('SELECT `id` FROM ::itemextendedcost WHERE '.$w); + $boughtBy = $xCosts ? DB::World()->selectCol('SELECT `item` FROM npc_vendor WHERE `extendedCost` IN %in UNION SELECT `item` FROM game_event_npc_vendor WHERE `extendedCost` IN %in', $xCosts, $xCosts) : []; + if ($boughtBy) + { + $boughtBy = new ItemList(array(['id', $boughtBy])); + if (!$boughtBy->error) + { + $tabData = array( + 'data' => $boughtBy->getListviewData(ITEMINFO_VENDOR, [Type::CURRENCY => $this->typeId]), + 'name' => '$LANG.tab_currencyfor', + 'id' => 'currency-for', + 'extraCols' => ["\$Listview.funcBox.createSimpleCol('stack', 'stack', '10%', 'stack')", '$Listview.extraCols.cost'] + ); + + if ($n) + $tabData['note'] = sprintf(Util::$filterResultString, $n); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + + $this->extendGlobalData($boughtBy->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/currency/currency_power.php b/endpoints/currency/currency_power.php new file mode 100644 index 00000000..57b9c076 --- /dev/null +++ b/endpoints/currency/currency_power.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(string $id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $currency = new CurrencyList(array(['id', $this->typeId])); + if ($currency->error) + $this->cacheType = CACHE_TYPE_NONE; + else + $opts = array( + 'name' => $currency->getField('name', true), + 'tooltip' => $currency->renderTooltip(), + 'icon' => $currency->getField('iconString') + ); + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/data/data.php b/endpoints/data/data.php new file mode 100644 index 00000000..8b38397b --- /dev/null +++ b/endpoints/data/data.php @@ -0,0 +1,150 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkLocale' ]], + 't' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine' ]], + 'catg' => ['filter' => FILTER_VALIDATE_INT ], + 'skill' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkSkill' ]], + 'class' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 11]], + 'callback' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCallback' ]] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if ($this->_get['locale']?->validate()) + Lang::load($this->_get['locale']); + } + + protected function generate() : void + { + // different data can be strung together + foreach ($this->params as $set) + { + // requires valid token to hinder automated access + if ($set != 'item-scaling' && $set != 'spell-scaling' && (!$this->_get['t'] || empty($_SESSION['dataKey']) || $this->_get['t'] != $_SESSION['dataKey'])) + { + trigger_error('DataBaseResponse::generate - session data key empty or expired', E_USER_ERROR); + continue; + } + + /* issue on no initial data: + when we loadOnDemand, the jScript tries to generate the catg-tree before it is initialized + it cant be initialized, without loading the data as empty catg are omitted + loading the data triggers the generation of the catg-tree + */ + + $this->result .= match($set) + { + 'factions' => $this->loadProfilerData($set), + 'mounts' => $this->loadProfilerData($set, SKILL_MOUNTS), + 'companions' => $this->loadProfilerData($set, SKILL_COMPANIONS), + 'quests' => $this->loadProfilerQuests($set, $this->_get['catg']), + 'recipes' => $this->loadProfilerRecipes(), + // locale independent + 'quick-excludes', + 'weight-presets', + 'item-scaling', + 'spell-scaling', + 'realms', + 'statistics' => $this->loadAgnosticFile($set), + // localized + 'talents', + 'achievements', + 'pet-talents', + 'glyphs', + 'gems', + 'enchants', + 'itemsets', + 'pets', + 'zones' => $this->loadLocalizedFile($set), + default => (function($x) { trigger_error('DataBaseResponse::generate - invalid file "'.$x.'" in request', E_USER_ERROR); })($set), + }; + } + } + + private function loadProfilerRecipes() : string + { + if (!$this->_get['callback'] || !$this->_get['skill']) + return ''; + + $result = ''; + + foreach ($this->_get['skill'] as $s) + Util::loadStaticFile('p-recipes-'.$s, $result, true); + + Util::loadStaticFile('p-recipes-sec', $result, true); + $result .= "\n\$WowheadProfiler.loadOnDemand('recipes', null);\n"; + + return $result; + } + + private function loadProfilerQuests(string $file, ?string $catg = null) : string + { + $result = ''; + + if ($catg === null) + Util::loadStaticFile('p-'.$file, $result, false); + else + Util::loadStaticFile('p-'.$file.'-'.$catg, $result, true); + + $result .= "\n\$WowheadProfiler.loadOnDemand('".$file."', ".($catg ?? 'null').");\n"; + + return $result; + } + + private function loadProfilerData(string $file, ?string $catg = null) : string + { + $result = ''; + + if ($this->_get['callback']) + if (Util::loadStaticFile('p-'.$file, $result, true)) + $result .= "\n\$WowheadProfiler.loadOnDemand('".$file."', ".($catg ?? 'null').");\n"; + + return $result; + } + + private function loadAgnosticFile(string $file) : string + { + $result = ''; + + if (!Util::loadStaticFile($file, $result) && Cfg::get('DEBUG')) + $result .= "alert('could not fetch static data: ".$file."');"; + + return $result . "\n\n"; + } + + private function loadLocalizedFile(string $file) : string + { + $result = ''; + + if ($file == 'talents' && ($_ = $this->_get['class'])) + $file .= "-".$_; + + if (!Util::loadStaticFile($file, $result, true) && Cfg::get('DEBUG')) + $result .= "alert('could not fetch static data: ".$file." for locale: ".Lang::getLocale()->json()."');"; + + return $result . "\n\n"; + } + + protected static function checkSkill(string $val) : array + { + return array_intersect(array_merge(SKILLS_TRADE_PRIMARY, [SKILL_FIRST_AID, SKILL_COOKING, SKILL_FISHING]), explode(',', $val)); + } + + protected static function checkCallback(string $val) : bool + { + return substr($val, 0, 29) === '$WowheadProfiler.loadOnDemand'; + } +} + +?> diff --git a/endpoints/edit/image.php b/endpoints/edit/image.php new file mode 100644 index 00000000..587d5a5e --- /dev/null +++ b/endpoints/edit/image.php @@ -0,0 +1,48 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'guide' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 1]] + ); + + /* + success: bool + id: image enumerator + type: 3 ? png : jpg + name: old filename + error: errString + */ + protected function generate() : void + { + if (!$this->assertGET('qqfile', 'guide')) + { + $this->result = Util::toJSON(['success' => false, 'error' => Lang::main('genericError')]); + return; + } + + if (!User::canWriteGuide()) + { + $this->result = Util::toJSON(['success' => false, 'error' => Lang::main('genericError')]); + return; + } + + $this->result = GuideMgr::handleUpload(); + + if (isset($this->result['success'])) + $this->result += ['name' => $this->_get['qqfile']]; + + $this->result = Util::toJSON($this->result); + } +} + +?> diff --git a/endpoints/emote/emote.php b/endpoints/emote/emote.php new file mode 100644 index 00000000..8a73cfdd --- /dev/null +++ b/endpoints/emote/emote.php @@ -0,0 +1,226 @@ + 0: player text emote + * id < 0: creature emote + */ + + $this->typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new EmoteList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('emote'), Lang::emote('notFound')); + + $this->h1 = Util::ucFirst($this->subject->getField('cmd')); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('emote'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // has Animation + if ($this->subject->getField('isAnimated') && !$this->subject->getField('stateParam')) + { + $infobox[] = Lang::emote('isAnimated'); + + // anim state + $state = Lang::emote('state', $this->subject->getField('state')); + if ($this->subject->getField('state') == 1) + $state .= Lang::main('colon').Lang::unit('bytes1', 0, $this->subject->getField('stateParam')); + $infobox[] = $state; + } + + if (User::isInGroup(U_GROUP_STAFF | U_GROUP_TESTER)) + { + // player emote: point to internal data + if ($_ = $this->subject->getField('parentEmote')) + { + $this->extendGlobalIds(Type::EMOTE, $_); + $infobox[] = '[emote='.$_.']'; + } + + if ($flags = $this->subject->getField('flags')) + { + $box = Lang::game('flags').Lang::main('colon').'[ul]'; + foreach (Lang::emote('flags') as $bit => $str) + if ($bit & $flags) + $box .= '[li][tooltip name=hint-'.$bit.']'.Util::asHex($bit).'[/tooltip][span class=tip tooltip=hint-'.$bit.']'.$str.'[/span][/li]'; + $infobox[] = $box.'[/ul]'; + } + } + + // id + $infobox[] = Lang::emote('id') . $this->typeId; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $text = ''; + + if ($this->subject->getField('cuFlags') & EMOTE_CU_MISSING_CMD) + $text .= Lang::emote('noCommand').'[br][br]'; + else if ($aliasses = DB::Aowow()->selectCol('SELECT `command` FROM ::emotes_aliasses WHERE `id` = %i AND `locales` & %i', $this->typeId, 1 << Lang::getLocale()->value)) + { + $text .= '[h3]'.Lang::emote('aliases').'[/h3][ul]'; + foreach ($aliasses as $a) + $text .= '[li]/'.$a.'[/li]'; + + $text .= '[/ul][br][br]'; + } + + $target = $noTarget = []; + if ($_ = $this->subject->getField('extToExt', true)) + $target[] = $this->prepare($_); + if ($_ = $this->subject->getField('extToMe', true)) + $target[] = $this->prepare($_); + if ($_ = $this->subject->getField('meToExt', true)) + $target[] = $this->prepare($_); + if ($_ = $this->subject->getField('extToNone', true)) + $noTarget[] = $this->prepare($_); + if ($_ = $this->subject->getField('meToNone', true)) + $noTarget[] = $this->prepare($_); + + if (!$target && !$noTarget) + $text .= '[div][i class=q0]'.Lang::emote('noText').'[/i][/div]'; + + if ($target) + { + $text .= '[pad][b]'.Lang::emote('targeted').'[/b][ul]'; + foreach ($target as $t) + $text .= '[li][span class=s4]'.$t.'[/span][/li]'; + $text .= '[/ul]'; + } + + if ($noTarget) + { + $text .= '[pad][b]'.Lang::emote('untargeted').'[/b][ul]'; + foreach ($noTarget as $t) + $text .= '[li][span class=s4]'.$t.'[/span][/li]'; + $text .= '[/ul]'; + } + + // event sound + if ($_ = $this->subject->getField('soundId')) + { + $this->extendGlobalIds(Type::SOUND, $_); + $text .= '[h3]'.Lang::emote('eventSound').'[/h3][sound='.$_.']'; + } + + if ($text) + $this->extraText = new Markup($text, ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], 'text-generic'); + + $this->redButtons = array( + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_WOWHEAD => false + ); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: achievement + $condition = array( + ['ac.type', ACHIEVEMENT_CRITERIA_TYPE_DO_EMOTE], + ['ac.value1', $this->typeId], + ); + $acv = new AchievementList($condition); + if (!$acv->error) + { + $this->extendGlobalData($acv->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(['data' => $acv->getListviewData()], AchievementList::$brickFile)); + } + + // tab: sound + $ems = DB::Aowow()->selectAssoc( + 'SELECT `soundId` AS ARRAY_KEY, BIT_OR(1 << (`raceId` - 1)) AS "raceMask", BIT_OR(1 << (`gender` - 1)) AS "gender" + FROM ::emotes_sounds + WHERE %if', $this->typeId < 0, '-`emoteId` = %i OR', $this->subject->getField('parentEmote'), '%end `emoteId` = %i + GROUP BY `soundId`', + $this->typeId, + ); + + if ($ems) + { + $sounds = new SoundList(array(['id', array_keys($ems)])); + if (!$sounds->error) + { + $this->extendGlobalData($sounds->getJSGlobals(GLOBALINFO_SELF)); + $data = $sounds->getListviewData(); + foreach ($data as $id => &$d) + { + $d['races'] = $ems[$id]['raceMask']; + $d['gender'] = $ems[$id]['gender']; + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + // gender races + 'extraCols' => ['$Listview.templates.title.columns[1]', '$Listview.templates.classs.columns[1]'] + ), SoundList::$brickFile)); + } + } + + parent::generate(); + } + + private function prepare(string $emote) : string + { + $emote = Util::parseHtmlText($emote, true); + return preg_replace('/%\d?\$?s/', '<'.Util::ucFirst(Lang::main('name')).'>', $emote); + } +} + +?> diff --git a/endpoints/emotes/emotes.php b/endpoints/emotes/emotes.php new file mode 100644 index 00000000..de77397a --- /dev/null +++ b/endpoints/emotes/emotes.php @@ -0,0 +1,61 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('emotes')); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $cnd = []; // don't limit, for we have no filter or category + if (!User::isInGroup(U_GROUP_STAFF)) + $cnd[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $tabData = array( + 'data' => (new EmoteList($cnd))->getListviewData(), + 'name' => Util::ucFirst(Lang::game('emotes')) + ); + + $this->lvTabs->addListviewTab(new Listview($tabData, EmoteList::$brickFile, 'emote')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/enchantment/enchantment.php b/endpoints/enchantment/enchantment.php new file mode 100644 index 00000000..98ae613f --- /dev/null +++ b/endpoints/enchantment/enchantment.php @@ -0,0 +1,320 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new EnchantmentList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('enchantment'), Lang::enchantment('notFound')); + + $this->extendGlobalData($this->subject->getJSGlobals()); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + /*************/ + /* Menu Path */ + /*************/ + + if ($_ = $this->getDistinctType()) + $this->breadcrumb[] = $_; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('enchantment'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // reqLevel + if ($_ = $this->subject->getField('requiredLevel')) + $infobox[] = sprintf(Lang::game('reqLevel'), $_); + + // reqskill + if ($_ = $this->subject->getField('skillLine')) + { + $this->extendGlobalIds(Type::SKILL, $_); + + $foo = Lang::game('requires', [' [skill='.$_.']']); + if ($_ = $this->subject->getField('skillLevel')) + $foo .= ' ('.$_.')'; + + $infobox[] = $foo; + } + + // id + $infobox[] = Lang::enchantment('id') . $this->typeId; + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons = array( + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_WOWHEAD => false + ); + + $this->effects = []; + // 3 effects + for ($i = 1; $i < 4; $i++) + { + $_ty = $this->subject->getField('type'.$i); + $_qty = $this->subject->getField('amount'.$i); + $_obj = $this->subject->getField('object'.$i); + $_tip = []; + + switch ($_ty) + { + case ENCHANTMENT_TYPE_COMBAT_SPELL: + case ENCHANTMENT_TYPE_EQUIP_SPELL: + case ENCHANTMENT_TYPE_USE_SPELL: + [$spellId, $trigger, $charges, $procChance] = $this->subject->getField('spells')[$i]; + $spl = $this->subject->getRelSpell($spellId); + $this->effects[$i] = array( + 'name' => $this->fmtStaffTip(Lang::item('trigger', $trigger), 'Type: '.$_ty), + 'proc' => $procChance, + 'value' => $_qty ?: null, + 'tip' => [], + 'icon' => new IconElement( + Type::SPELL, + $spellId, + !$spl ? Util::ucFirst(Lang::game('spell')).' #'.$spellId : Util::localizedString($spl, 'name'), + $charges, + link: !!$spl + ) + ); + break; + case ENCHANTMENT_TYPE_STAT: + if ($idx = Stat::getIndexFrom(Stat::IDX_ITEM_MOD, $_obj)) + if ($jsonStat = Stat::getJsonString($idx)) + $_tip = [User::isInGroup(U_GROUP_STAFF) ? $_obj : null, $jsonStat]; + // DO NOT BREAK! + case ENCHANTMENT_TYPE_DAMAGE: + case ENCHANTMENT_TYPE_TOTEM: + case ENCHANTMENT_TYPE_PRISMATIC_SOCKET: + case ENCHANTMENT_TYPE_RESISTANCE: + $this->effects[$i] = array( + 'name' => $this->fmtStaffTip(Lang::enchantment('types', $_ty), 'Type: '.$_ty), + 'proc' => null, + 'value' => $_qty, + 'tip' => $_tip, + 'icon' => null + ); + if ($_ty == ENCHANTMENT_TYPE_RESISTANCE) + $this->effects[$i]['name'] .= Lang::main('colon').'('.$this->fmtStaffTip(Lang::getMagicSchools(1 << $_obj), 'Object: '.$_obj).')'; + } + } + + // activation conditions + if ($_ = $this->subject->getField('conditionId')) + $this->activation = Game::getEnchantmentCondition($_); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // used by gem + $gemList = new ItemList(array(['gemEnchantmentId', $this->typeId])); + if (!$gemList->error) + { + $this->extendGlobalData($gemList->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $gemList->getListviewData(), + 'name' => '$LANG.tab_usedby + \' \' + LANG.gems', + 'id' => 'used-by-gem', + ), ItemList::$brickFile)); + } + + // used by socket bonus + $socketsList = new ItemList(array(['socketBonus', $this->typeId])); + if (!$socketsList->error) + { + $this->extendGlobalData($socketsList->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $socketsList->getListviewData(), + 'name' => '$LANG.tab_socketbonus', + 'id' => 'used-by-socketbonus', + ), ItemList::$brickFile)); + } + + // used by spell + // used by useItem + $cnd = array( + DB::OR, + [DB::AND, ['effect1Id', SpellList::EFFECTS_ENCHANTMENT], ['effect1MiscValue', $this->typeId]], + [DB::AND, ['effect2Id', SpellList::EFFECTS_ENCHANTMENT], ['effect2MiscValue', $this->typeId]], + [DB::AND, ['effect3Id', SpellList::EFFECTS_ENCHANTMENT], ['effect3MiscValue', $this->typeId]], + ); + $spellList = new SpellList($cnd); + if (!$spellList->error) + { + $spellData = $spellList->getListviewData(); + $this->extendGlobalData($spellList->getJsGlobals()); + + $spellIds = $spellList->getFoundIDs(); + $conditions = array( + DB::OR, + [DB::AND, ['spellTrigger1', [SPELL_TRIGGER_USE, SPELL_TRIGGER_USE_NODELAY]], ['spellId1', $spellIds]], + [DB::AND, ['spellTrigger2', [SPELL_TRIGGER_USE, SPELL_TRIGGER_USE_NODELAY]], ['spellId2', $spellIds]], + [DB::AND, ['spellTrigger3', [SPELL_TRIGGER_USE, SPELL_TRIGGER_USE_NODELAY]], ['spellId3', $spellIds]], + [DB::AND, ['spellTrigger4', [SPELL_TRIGGER_USE, SPELL_TRIGGER_USE_NODELAY]], ['spellId4', $spellIds]], + [DB::AND, ['spellTrigger5', [SPELL_TRIGGER_USE, SPELL_TRIGGER_USE_NODELAY]], ['spellId5', $spellIds]] + ); + + $ubItems = new ItemList($conditions); + if (!$ubItems->error) + { + $this->extendGlobalData($ubItems->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubItems->getListviewData(), + 'name' => '$LANG.tab_usedby + \' \' + LANG.types[3][0]', + 'id' => 'used-by-item', + ), ItemList::$brickFile)); + } + + // remove found spells if they are used by an item + if (!$ubItems->error) + { + foreach ($spellList->iterate() as $sId => $__) + { + // if Perm. Enchantment display both + for ($i = 1; $i < 4; $i++) + if ($spellList->getField('effect'.$i.'Id') == SPELL_EFFECT_ENCHANT_ITEM) + continue 2; + + foreach ($ubItems->iterate() as $__) + { + for ($i = 1; $i < 6; $i++) + { + if ($ubItems->getField('spellId'.$i) == $sId) + { + unset($spellData[$sId]); + break 2; + } + } + } + } + } + + if ($spellData) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $spellData, + 'name' => '$LANG.tab_usedby + \' \' + LANG.types[6][0]', + 'id' => 'used-by-spell', + ), SpellList::$brickFile)); + } + + // used by randomAttrItem + $ire = DB::Aowow()->selectAssoc( + 'SELECT *, ABS(`id`) AS ARRAY_KEY FROM ::itemrandomenchant WHERE `enchantId1` = %i OR `enchantId2` = %i OR `enchantId3` = %i OR `enchantId4` = %i OR `enchantId5` = %i', + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId + ); + if ($ire) + { + if ($iet = DB::World()->selectAssoc('SELECT `entry` AS ARRAY_KEY, `ench`, `chance` FROM item_enchantment_template WHERE `ench` IN %in', array_keys($ire))) + { + $randIds = []; // transform back to signed format + foreach ($iet as $tplId => $data) + $randIds[$ire[$data['ench']]['id'] > 0 ? $tplId : -$tplId] = $ire[$data['ench']]['id']; + + $randItems = new ItemList(array(['randomEnchant', array_keys($randIds)])); + if (!$randItems->error) + { + $data = $randItems->getListviewData(); + foreach ($randItems->iterate() as $iId => $__) + { + $re = $randItems->getField('randomEnchant'); + + $data[$iId]['percent'] = $iet[abs($re)]['chance']; + $data[$iId]['count'] = 1; // expected by js or the pct-col becomes unsortable + $data[$iId]['rel'] = 'rand='.$ire[$iet[abs($re)]['ench']]['id']; + $data[$iId]['name'] .= ' '.Util::localizedString($ire[$iet[abs($re)]['ench']], 'name'); + } + + $this->extendGlobalData($randItems->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + 'id' => 'used-by-rand', + 'name' => '$LANG.tab_usedby + \' \' + \''.Lang::item('_rndEnchants').'\'', + 'extraCols' => ['$Listview.extraCols.percent'] + ), ItemList::$brickFile)); + } + } + } + + parent::generate(); + } + + private function getDistinctType() : int + { + $type = 0; + for ($i = 1; $i < 4; $i++) + { + if ($_ = $this->subject->getField('type'.$i)) + { + if ($type && $type != $_) // already set + return 0; + else + $type = $_; + } + } + + return $type; + } +} + +?> diff --git a/endpoints/enchantments/enchantments.php b/endpoints/enchantments/enchantments.php new file mode 100644 index 00000000..3dfbb79a --- /dev/null +++ b/endpoints/enchantments/enchantments.php @@ -0,0 +1,131 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = [1, 2, 3, 4, 5, 6, 7, 8]; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + $this->forward('?enchantments&filter=ty='.$this->category[0]); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new EnchantmentListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('enchantments')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + + /**************/ + /* Page Title */ + /**************/ + + $fiForm = $this->filter->values; + + array_unshift($this->title, $this->h1); + if (isset($fiForm['ty']) && count($fiForm['ty']) == 1 && $fiForm['ty'][0] > ENCHANTMENT_TYPE_NONE && $fiForm['ty'][0] <= ENCHANTMENT_TYPE_PRISMATIC_SOCKET) + array_unshift($this->title, Lang::enchantment('types', $fiForm['ty'][0])); + + + /*************/ + /* Menu Path */ + /*************/ + + if (isset($fiForm['ty']) && count($fiForm['ty']) == 1) + $this->breadcrumb[] = $fiForm['ty'][0]; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = false; + + $tabData = array( + 'data' => [], + 'name' => Util::ucFirst(Lang::game('enchantments')) + ); + + $ench = new EnchantmentList($conditions, ['calcTotal' => true]); + + $tabData['data'] = $ench->getListviewData(); + $this->extendGlobalData($ench->getJSGlobals()); + + $xCols = []; + foreach (Stat::getFilterCriteriumIdFor() as $idx => $fiId) + if (array_filter(array_column($tabData['data'], Stat::getJsonString($idx)))) + $xCols[] = $fiId; + + // some kind of declaration conflict going on here..., expects colId for WEAPON_DAMAGE_MAX but jsonString is WEAPON_DAMAGE + if (array_filter(array_column($tabData['data'], 'dmg'))) + $xCols[] = Stat::getFilterCriteriumId(Stat::WEAPON_DAMAGE_MAX); + + if ($xCols) + $this->filter->fiExtraCols = array_merge($this->filter->fiExtraCols, $xCols); + + if ($this->filter->fiExtraCols) + $tabData['extraCols'] = '$fi_getExtraCols(fi_extraCols, 0, 0)'; + + if (array_filter(array_column($tabData['data'], 'spells'))) + $tabData['visibleCols'] = ['trigger']; + + if (!$ench->hasSetFields('skillLine')) + $tabData['hiddenCols'] = ['skill']; + + if ($ench->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_enchantmentsfound', $ench->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, EnchantmentList::$brickFile, 'enchantment')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/event/event.php b/endpoints/event/event.php new file mode 100644 index 00000000..7796afc1 --- /dev/null +++ b/endpoints/event/event.php @@ -0,0 +1,373 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new WorldEventList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('event'), Lang::event('notFound')); + + $this->h1 = $this->subject->getField('name', true); + $this->dates = array( + 'firstDate' => $this->subject->getField('startTime'), + 'lastDate' => $this->subject->getField('endTime'), + 'length' => $this->subject->getField('length'), + 'rec' => $this->subject->getField('occurence') + ); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_holidayId = $this->subject->getField('holidayId'); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = match ($this->subject->getField('scheduleType')) + { + -1 => 1, + 0, 1 => 2, + 2 => 3, + '' => 0, + default => 0 + }; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucWords(Lang::game('event'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // boss + if ($_ = $this->subject->getField('bossCreature')) + { + $this->extendGlobalIds(Type::NPC, $_); + $infobox[] = Lang::npc('rank', 3).Lang::main('colon').'[npc='.$_.']'; + } + + // id + $infobox[] = Lang::event('id') . $this->typeId; + + // display holiday id to staff + if ($_holidayId && User::isInGroup(U_GROUP_STAFF)) + $infobox[] = 'Holiday ID'.Lang::main('colon').$_holidayId; + + // icon + if ($_ = $this->subject->getField('iconId')) + { + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $this->extendGlobalIds(Type::ICON, $_); + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + // no entry in ::articles? use default HolidayDescription + if ($_holidayId && empty($this->article)) + $this->article = new Markup($this->subject->getField('description', true), ['dbpage' => true]); + + if ($_holidayId) + $this->wowheadLink = sprintf(WOWHEAD_LINK, Lang::getLocale()->domain(), 'event=', $_holidayId); + + $this->headIcons = [$this->subject->getField('iconString')]; + $this->redButtons = array( + BUTTON_WOWHEAD => $_holidayId > 0, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId] + ); + + parent::generate(); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: npcs + if ($npcIds = DB::World()->selectCol('SELECT `id` AS ARRAY_KEY, IF(ec.`eventEntry` > 0, 1, 0) AS "added" FROM creature c, game_event_creature ec WHERE ec.`guid` = c.`guid` AND ABS(ec.`eventEntry`) = %i', $this->typeId)) + { + $creatures = new CreatureList(array(['id', array_keys($npcIds)])); + if (!$creatures->error) + { + $data = $creatures->getListviewData(); + foreach ($data as &$d) + $d['method'] = $npcIds[$d['id']]; + + $tabData = ['data' => $data]; + + if ($_holidayId && CreatureListFilter::getCriteriaIndex(38, $_holidayId)) + $tabData['note'] = sprintf(Util::$filterResultString, '?npcs&filter=cr=38;crs='.$_holidayId.';crv=0'); + + $this->result->addDataLoader('zones'); // req. by secondary tooltip in this tab + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile)); + } + } + + // tab: objects + if ($objectIds = DB::World()->selectCol('SELECT `id` AS ARRAY_KEY, IF(eg.`eventEntry` > 0, 1, 0) AS "added" FROM gameobject g, game_event_gameobject eg WHERE eg.`guid` = g.`guid` AND ABS(eg.`eventEntry`) = %i', $this->typeId)) + { + $objects = new GameObjectList(array(['id', array_keys($objectIds)])); + if (!$objects->error) + { + $data = $objects->getListviewData(); + foreach ($data as &$d) + $d['method'] = $objectIds[$d['id']]; + + $tabData = ['data' => $data]; + + if ($_holidayId && GameObjectListFilter::getCriteriaIndex(16, $_holidayId)) + $tabData['note'] = sprintf(Util::$filterResultString, '?objects&filter=cr=16;crs='.$_holidayId.';crv=0'); + + $this->result->addDataLoader('zones'); // req. by secondary tooltip in this tab + $this->lvTabs->addListviewTab(new Listview($tabData, GameObjectList::$brickFile)); + } + } + + // tab: achievements + $exclAcvs = []; + if ($_ = $this->subject->getField('achievementCatOrId')) + { + $condition = $_ > 0 ? [['category', $_]] : [['id', -$_]]; + $acvs = new AchievementList($condition); + if (!$acvs->error) + { + $this->extendGlobalData($acvs->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $tabData = array( + 'data' => $acvs->getListviewData(), + 'visibleCols' => ['category'] + ); + + // don't reuse for criteria-of tab + $exclAcvs = array_keys($tabData['data']); + + if ($_holidayId && AchievementListFilter::getCriteriaIndex(11, $_holidayId)) + $tabData['note'] = sprintf(Util::$filterResultString, '?achievements&filter=cr=11;crs='.$_holidayId.';crv=0'); + + $this->lvTabs->addListviewTab(new Listview($tabData, AchievementList::$brickFile)); + } + } + + $itemCnd = []; + if ($_holidayId) + { + // tab: criteria-of + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` = %i AND `value1` = %i', ACHIEVEMENT_CRITERIA_DATA_TYPE_HOLIDAY, $_holidayId)) + { + $condition = array(['ac.id', $extraCrt]); + if ($exclAcvs) + $condition[] = ['a.id', $exclAcvs, '!']; + + $crtOf = new AchievementList($condition); + if (!$crtOf->error) + { + $this->extendGlobalData($crtOf->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $crtOf->getListviewData(), + 'name' => '$LANG.tab_criteriaof', + 'id' => 'criteria-of' + ), AchievementList::$brickFile)); + } + } + + $itemCnd[] = ['eventId', $this->typeId]; // direct requirement on item + } + + // tab: quests (by table, go & creature) + $quests = new QuestList(array(['eventId', $this->typeId])); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); + + $tabData = ['data'=> $quests->getListviewData()]; + + if (QuestListFilter::getCriteriaIndex(33, $_holidayId)) + $tabData['note'] = sprintf(Util::$filterResultString, '?quests&filter=cr=33;crs='.$_holidayId.';crv=0'); + + $this->lvTabs->addListviewTab(new Listview($tabData, QuestList::$brickFile)); + + $questItems = []; + foreach (array_column($quests->rewards, Type::ITEM) as $arr) + $questItems = array_merge($questItems, array_keys($arr)); + + foreach (array_column($quests->choices, Type::ITEM) as $arr) + $questItems = array_merge($questItems, array_keys($arr)); + + foreach (array_column($quests->requires, Type::ITEM) as $arr) + $questItems = array_merge($questItems, $arr); + + if ($questItems) + $itemCnd[] = ['id', $questItems]; + } + + // items from creature + if ($npcIds && !$creatures->error) + { + // vendor + $cIds = $creatures->getFoundIDs(); + if ($sells = DB::World()->selectCol( + 'SELECT `item` FROM npc_vendor nv WHERE `entry` IN %in UNION + SELECT nv1.`item` FROM npc_vendor nv1 JOIN npc_vendor nv2 ON -nv1.`entry` = nv2.`item` WHERE nv2.`entry` IN %in UNION + SELECT `item` FROM game_event_npc_vendor genv JOIN creature c ON genv.`guid` = c.`guid` WHERE c.`id` IN %in', + $cIds, $cIds, $cIds + )) + $itemCnd[] = ['id', $sells]; + } + + // tab: items + // not checking for loot ... cant distinguish between eventLoot and fillerCrapLoot + if ($itemCnd) + { + array_unshift($itemCnd, DB::OR); + $eventItems = new ItemList($itemCnd); + if (!$eventItems->error) + { + $this->extendGlobalData($eventItems->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = ['data'=> $eventItems->getListviewData()]; + + if ($_holidayId && ItemListFilter::getCriteriaIndex(160, $_holidayId)) + $tabData['note'] = sprintf(Util::$filterResultString, '?items&filter=cr=160;crs='.$_holidayId.';crv=0'); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + } + } + + // tab: see also (event conditions) + if ($rel = DB::World()->selectCol('SELECT IF(`eventEntry` = `prerequisite_event`, NULL, IF(`eventEntry` = %i, `prerequisite_event`, -`eventEntry`)) FROM game_event_prerequisite WHERE `prerequisite_event` = %i OR `eventEntry` = %i', $this->typeId, $this->typeId, $this->typeId)) + { + if (array_filter($rel, fn($x) => $x === null)) + trigger_error('game_event_prerequisite: this event has itself as prerequisite', E_USER_WARNING); + + if ($seeAlso = array_filter($rel, fn($x) => $x > 0)) + { + $relEvents = new WorldEventList(array(['id', $seeAlso])); + $this->extendGlobalData($relEvents->getJSGlobals()); + $relData = $relEvents->getListviewData(); + foreach ($relEvents->getFoundIDs() as $id) + Conditions::extendListviewRow($relData[$id], Conditions::SRC_NONE, $this->typeId, [-Conditions::ACTIVE_EVENT, $this->typeId]); + + $this->extendGlobalData($this->subject->getJSGlobals()); + $d = $this->subject->getListviewData(); + foreach ($rel as $r) + if ($r > 0) + if (Conditions::extendListviewRow($d[$this->typeId], Conditions::SRC_NONE, $this->typeId, [-Conditions::ACTIVE_EVENT, $r])) + $this->extendGlobalIds(Type::WORLDEVENT, $r); + + $tabData = array( + 'data' => array_merge($relData, $d), + 'id' => 'see-also', + 'name' => '$LANG.tab_seealso', + 'hiddenCols' => ['date'], + 'extraCols' => ['$Listview.extraCols.condition'] + ); + $this->lvTabs->addListviewTab(new Listview($tabData, WorldEventList::$brickFile)); + } + } + + // tab: condition for + $cnd = new Conditions(); + $cnd->getByCondition(Type::WORLDEVENT, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + $this->result->registerDisplayHook('lvTabs', [self::class, 'tabsHook']); + $this->result->registerDisplayHook('infobox', [self::class, 'infoboxHook']); + } + + // update dates to now() + public static function tabsHook(Template\PageTemplate &$pt, Tabs &$lvTabs) : void + { + foreach ($lvTabs->iterate() as &$listview) + if (is_object($listview) && $listview?->getTemplate() == 'holiday') + WorldEventList::updateListview($listview); + } + + /* finalize infobox */ + public static function infoboxHook(Template\PageTemplate &$pt, ?InfoboxMarkup &$markup) : void + { + WorldEventList::updateDates($pt->dates, $start, $end, $rec); + $infobox = []; + + // start + if ($start) + $infobox[] = Lang::event('start').date(Lang::main('dateFmtLong'), $start); + + // end + if ($end) + $infobox[] = Lang::event('end').date(Lang::main('dateFmtLong'), $end); + + // interval + if ($rec > 0) + $infobox[] = Lang::event('interval').DateTime::formatTimeElapsed($rec * 1000); + + // in progress + if ($start < time() && $end > time()) + $infobox[] = '[span class=q2]'.Lang::event('inProgress').'[/span]'; + + if ($infobox && !$markup) + $markup = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + else if ($markup) + foreach ($infobox as $ib) + $markup->addItem($ib); + } +} + +?> diff --git a/endpoints/event/event_power.php b/endpoints/event/event_power.php new file mode 100644 index 00000000..48c75683 --- /dev/null +++ b/endpoints/event/event_power.php @@ -0,0 +1,79 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + private array $dates = []; + + public function __construct(string $id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $worldevent = new WorldEventList(array(['id', $this->typeId])); + if ($worldevent->error) + $this->cacheType = CACHE_TYPE_NONE; + else + { + $icon = $worldevent->getField('iconString'); + if ($icon == 'trade_engineering') + $icon = null; + + $opts = array( + 'name' => $worldevent->getField('name', true), + 'tooltip' => $worldevent->renderTooltip(), + 'icon' => $icon + ); + + $this->dates = array( + 'firstDate' => $worldevent->getField('startTime'), + 'lastDate' => $worldevent->getField('endTime'), + 'length' => $worldevent->getField('length'), + 'rec' => $worldevent->getField('occurence') + ); + + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay'], $this->dates); + } + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } + + public static function onBeforeDisplay(string $tooltip, array $dates) : string + { + // update dates to now() + WorldEventList::updateDates($dates, $start, $end); + + return sprintf( + $tooltip, + $start ? date(Lang::main('dateFmtLong'), $start) : null, + $end ? date(Lang::main('dateFmtLong'), $end) : null + ); + } +} + +?> diff --git a/endpoints/events/events.php b/endpoints/events/events.php new file mode 100644 index 00000000..885a7821 --- /dev/null +++ b/endpoints/events/events.php @@ -0,0 +1,96 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucWords(Lang::game('events')); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::event('category')[$this->category[0]]); + + + /*************/ + /* Menu Path */ + /*************/ + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $condition = [Listview::DEFAULT_SIZE]; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $condition[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($this->category) + $condition[] = match ($this->category[0]) + { + 1 => ['h.scheduleType', -1], + 2 => ['h.scheduleType', [0, 1]], + 3 => ['h.scheduleType', 2], + default => ['e.holidayId', 0] // also cat 0 + }; + + $events = new WorldEventList($condition); + $this->extendGlobalData($events->getJSGlobals()); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(['data' => $events->getListviewData()], WorldEventList::$brickFile)); + + if ($_ = array_filter($events->getListviewData(), fn($x) => $x['category'] > 0)) + $this->lvTabs->addListviewTab(new Listview(['data' => $_, 'hideCount' => 1], 'calendar')); + + parent::generate(); + + $this->result->registerDisplayHook('lvTabs', [self::class, 'tabsHook']); + } + + // recalculate dates with now() + public static function tabsHook(Template\PageTemplate &$pt, Tabs &$lvTabs) : void + { + foreach ($lvTabs->iterate() as &$listview) + if (is_object($listview) && ($listview?->getTemplate() == 'holiday' || $listview?->getTemplate() == 'holidaycal')) + WorldEventList::updateListview($listview); + } +} + +?> diff --git a/endpoints/faction/faction.php b/endpoints/faction/faction.php new file mode 100644 index 00000000..e85f5a79 --- /dev/null +++ b/endpoints/faction/faction.php @@ -0,0 +1,362 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new FactionList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('faction'), Lang::faction('notFound')); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('faction'))); + + + /**************/ + /* Page Title */ + /**************/ + + if ($foo = $this->subject->getField('cat')) + { + if ($bar = $this->subject->getField('cat2')) + $this->breadcrumb[] = $bar; + + $this->breadcrumb[] = $foo; + } + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // Quartermaster if any + if ($ids = $this->subject->getField('qmNpcIds')) + { + $this->extendGlobalIds(Type::NPC, ...$ids); + + $qmStr = Lang::faction('quartermaster'); + + if (count($ids) == 1) + $qmStr .= '[npc='.$ids[0].']'; + else if (count($ids) > 1) + { + $qmStr .= '[ul]'; + foreach ($ids as $id) + $qmStr .= '[li][npc='.$id.'][/li]'; + + $qmStr .= '[/ul]'; + } + + $infobox[] = $qmStr; + } + + // side if any + if ($_ = $this->subject->getField('side')) + $infobox[] = Lang::main('side').'[span class=icon-'.($_ == SIDE_ALLIANCE ? 'alliance' : 'horde').']'.Lang::game('si', $_).'[/span]'; + + // id + $infobox[] = Lang::faction('id') . $this->typeId; + + // profiler relateed (note that this is part of the cache. I don't think this is important enough to calc for every view) + if (Cfg::get('PROFILER_ENABLE') && !($this->subject->getField('cuFlags') & CUSTOM_EXCLUDE_FOR_LISTVIEW)) + { + $x = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_completion_reputation WHERE `exalted` = 1 AND `factionId` = %i', $this->typeId); + $y = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_profiles WHERE `custom` = 0 AND `stub` = 0'); + $infobox[] = Lang::profiler('attainedBy', [round(($x ?: 0) * 100 / ($y ?: 1))]); + + // completion row added by InfoboxMarkup + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) // unsure if this should be tracked (needs data dump in User::getCompletion()) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0', 0); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId] + ); + + // Spillover Effects + /* todo (low): also check on reputation_spillover_template (but its data is identical to calculation below + $rst = DB::World()->selectRow('SELECT + CONCAT_WS(" ", faction1, faction2, faction3, faction4) AS faction, + CONCAT_WS(" ", rate_1, rate_2, rate_3, rate_4) AS rate, + CONCAT_WS(" ", rank_1, rank_2, rank_3, rank_4) AS rank + FROM reputation_spillover_template WHERE faction = %i', $this->typeId); + */ + + + $conditions = array( + ['id', $this->typeId, '!'], // not self + ['repIdx', -1, '!'] // only gainable + ); + + if ($p = $this->subject->getField('parentFactionId')) // linked via parent + $conditions[] = [DB::OR, ['id', $p], ['parentFactionId', $p]]; + else // self as parent + $conditions[] = ['parentFactionId', $this->typeId]; + + $spillover = new FactionList($conditions); + $this->extendGlobalData($spillover->getJSGlobals()); + + $buff = ''; + foreach ($spillover->iterate() as $spillId => $__) + if ($val = ($spillover->getField('spilloverRateIn') * $this->subject->getField('spilloverRateOut') * 100)) + $buff .= '[tr][td][faction='.$spillId.'][/td][td][span class=q'.($val > 0 ? '2]+' : '10]').$val.'%[/span][/td][td]'.Lang::game('rep', $spillover->getField('spilloverMaxRank')).'[/td][/tr]'; + + if ($buff) + $this->extraText = new Markup( + '[h3 class=clear]'.Lang::faction('spillover').'[/h3][div margin=15px]'.Lang::faction('spilloverDesc').'[/div][table class=grid width=400px][tr][td width=150px][b]'.Util::ucFirst(Lang::game('faction')).'[/b][/td][td width=100px][b]'.Lang::spell('_value').'[/b][/td][td width=150px][b]'.Lang::faction('maxStanding').'[/b][/td][/tr]'.$buff.'[/table]', + ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], + 'text-generic' + ); + + // reward rates (ultimately this should be calculated into each reward display) + if ($rates = DB::World()->selectRow('SELECT `quest_rate`, `quest_daily_rate`, `quest_weekly_rate`, `quest_monthly_rate`, `quest_repeatable_rate`, `creature_rate`, `spell_rate` FROM reputation_reward_rate WHERE `faction` = %i', $this->typeId)) + { + $buff = ''; + foreach ($rates as $k => $v) + { + if ($v == 1) + continue; + + $head = match ($k) + { + 'quest_rate' => Lang::game('quests'), + 'quest_daily_rate' => Lang::game('quests').' ('.Lang::quest('daily').')', + 'quest_weekly_rate' => Lang::game('quests').' ('.Lang::quest('weekly').')', + 'quest_monthly_rate' => Lang::game('quests').' ('.Lang::quest('monthly').')', + 'quest_repeatable_rate' => Lang::game('quests').' ('.Lang::quest('repeatable').')', + 'creature_rate' => Lang::game('npcs'), + 'spell_rate' => Lang::game('spells') + }; + + $buff .= '[tr][td]'.$head.Lang::main('colon').'[/td][td width=35px align=right][span class=q'.($v < 1 ? '10]' : '2]+').intVal(($v - 1) * 100).'%[/span][/td][/tr]'; + } + + if ($buff && $this->extraText) + $this->extraText->append('[h3 class=clear]'.Lang::faction('customRewRate').'[/h3][table class=grid width=250px]'.$buff.'[/table]'); + else if ($buff) + $this->extraText = new Markup('[h3 class=clear]'.Lang::faction('customRewRate').'[/h3][table class=grid width=250px]'.$buff.'[/table]', ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], 'text-generic'); + } + + // factionchange-equivalent + if ($pendant = DB::World()->selectCell('SELECT IF(`horde_id` = %i, `alliance_id`, -`horde_id`) FROM player_factionchange_reputations WHERE `alliance_id` = %i OR `horde_id` = %i', $this->typeId, $this->typeId, $this->typeId)) + { + $altFac = new FactionList(array(['id', abs($pendant)])); + if (!$altFac->error) + { + $this->transfer = Lang::faction('_transfer', array( + $altFac->id, + $altFac->getField('name', true), + $pendant > 0 ? 'alliance' : 'horde', + $pendant > 0 ? Lang::game('si', SIDE_ALLIANCE) : Lang::game('si', SIDE_HORDE) + )); + } + } + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: items + $items = new ItemList(array(Listview::DEFAULT_SIZE, ['requiredFaction', $this->typeId]), ['calcTotal' => true]); + if (!$items->error) + { + $this->extendGlobalData($items->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = array( + 'data' => $items->getListviewData(), + 'extraCols' => '$_', + 'sort' => ['standing', 'name'] + ); + + if ($items->getMatches() > Listview::DEFAULT_SIZE) + if (!is_null(ItemListFilter::getCriteriaIndex(17, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?items&filter=cr=17;crs='.$this->typeId.';crv=0'); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile, 'itemStandingCol')); + } + + // tab: creatures with onKill reputation + // only if you can actually gain reputation by kills + if ($this->subject->getField('reputationIndex') != -1) + { + // inherit siblings/children from $spillover + $cRep = DB::World()->selectCol('SELECT DISTINCT `creature_id` AS ARRAY_KEY, `qty` FROM ( + SELECT `creature_id`, `RewOnKillRepValue1` as "qty" FROM creature_onkill_reputation WHERE `RewOnKillRepValue1` > 0 AND (`RewOnKillRepFaction1` = %i OR (`RewOnKillRepFaction1` IN %in AND `IsTeamAward1` <> 0) ) UNION + SELECT `creature_id`, `RewOnKillRepValue2` as "qty" FROM creature_onkill_reputation WHERE `RewOnKillRepValue2` > 0 AND (`RewOnKillRepFaction2` = %i OR (`RewOnKillRepFaction2` IN %in AND `IsTeamAward2` <> 0) ) + ) x', + $this->typeId, $spillover->getFoundIDs() ?: [0], + $this->typeId, $spillover->getFoundIDs() ?: [0] + ); + + if ($cRep) + { + $killCreatures = new CreatureList(array(Listview::DEFAULT_SIZE, ['id', array_keys($cRep)]), ['calcTotal' => true]); + if (!$killCreatures->error) + { + $data = $killCreatures->getListviewData(); + foreach ($data as $id => &$d) + $d['reputation'] = $cRep[$id]; + + $tabData = array( + 'data' => $data, + 'extraCols' => '$_', + 'sort' => ['-reputation', 'name'] + ); + + if ($killCreatures->getMatches() > Listview::DEFAULT_SIZE) + if (!is_null(CreatureListFilter::getCriteriaIndex(42, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?npcs&filter=cr=42;crs='.$this->typeId.';crv=0'); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile, 'npcRepCol')); + } + } + } + + // tab: members + if ($_ = $this->subject->getField('templateIds')) + { + $members = new CreatureList(array(Listview::DEFAULT_SIZE, ['faction', $_]), ['calcTotal' => true]); + if (!$members->error) + { + $tabData = array( + 'data' => $members->getListviewData(), + 'id' => 'member', + 'name' => '$LANG.tab_members' + ); + + if ($members->getMatches() > Listview::DEFAULT_SIZE) + if (!is_null(CreatureListFilter::getCriteriaIndex(3, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?npcs&filter=cr=3;crs='.$this->typeId.';crv=0'); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile)); + } + } + + // tab: objects + if ($_ = $this->subject->getField('templateIds')) + { + $objects = new GameObjectList(array(['faction', $_])); + if (!$objects->error) + { + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(['data' => $objects->getListviewData()], GameObjectList::$brickFile)); + } + } + + // tab: quests + $conditions = array( + DB::OR, + Listview::DEFAULT_SIZE, + [DB::AND, ['rewardFactionId1', $this->typeId], ['rewardFactionValue1', 0, '>']], + [DB::AND, ['rewardFactionId2', $this->typeId], ['rewardFactionValue2', 0, '>']], + [DB::AND, ['rewardFactionId3', $this->typeId], ['rewardFactionValue3', 0, '>']], + [DB::AND, ['rewardFactionId4', $this->typeId], ['rewardFactionValue4', 0, '>']], + [DB::AND, ['rewardFactionId5', $this->typeId], ['rewardFactionValue5', 0, '>']] + ); + $quests = new QuestList($conditions, ['calcTotal' => true]); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals(GLOBALINFO_ANY)); + + $tabData = array( + 'data' => $quests->getListviewData($this->typeId), + 'extraCols' => '$_' + ); + + if ($quests->getMatches() > Listview::DEFAULT_SIZE) + if (!is_null(QuestListFilter::getCriteriaIndex(1, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?quests&filter=cr=1;crs='.$this->typeId.';crv=0'); + + $this->lvTabs->addListviewTab(new Listview($tabData, QuestList::$brickFile, 'questRepCol')); + } + + // tab: achievements + $conditions = array( + ['ac.type', ACHIEVEMENT_CRITERIA_TYPE_GAIN_REPUTATION], + ['ac.value1', $this->typeId] + ); + $acvs = new AchievementList($conditions); + if (!$acvs->error) + { + $this->extendGlobalData($acvs->getJSGlobals(GLOBALINFO_ANY)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $acvs->getListviewData(), + 'id' => 'criteria-of', + 'name' => '$LANG.tab_criteriaof', + 'visibleCols' => ['category'] + ), AchievementList::$brickFile)); + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::FACTION, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/factions/factions.php b/endpoints/factions/factions.php new file mode 100644 index 00000000..5e8dd308 --- /dev/null +++ b/endpoints/factions/factions.php @@ -0,0 +1,104 @@ + [469, 891, 67, 892, 169], + 980 => [936], + 1097 => [1037, 1052, 1117], + 0 => true + ); + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('factions')); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + if ($this->category) + { + switch (count($this->category)) + { + case 1: + $t = Lang::faction('cat', $this->category[0]); + array_unshift($this->title, is_array($t) ? $t[0] : $t); + break; + case 2: + array_unshift($this->title, Lang::faction('cat', $this->category[0], $this->category[1])); + break; + } + } + + + /*************/ + /* Menu Path */ + /*************/ + + foreach ($this->category as $c) + $this->breadcrumb[] = $c; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = [Listview::DEFAULT_SIZE]; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) // unlisted factions + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if (isset($this->category[1])) + $conditions[] = ['parentFactionId', $this->category[1]]; + else if (isset($this->category[0])) + { + if ($this->category[0]) + $subs = DB::Aowow()->selectCol('SELECT `id` FROM ::factions WHERE `parentFactionId` = %i', $this->category[0]); + else + $subs = [0]; + + $conditions[] = [DB::OR, ['parentFactionId', $subs], ['id', $subs]]; + } + + $data = []; + $factions = new FactionList($conditions); + if (!$factions->error) + $data = $factions->getListviewData(); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(['data' => $data], FactionList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/faq/faq.php b/endpoints/faq/faq.php new file mode 100644 index 00000000..7946f354 --- /dev/null +++ b/endpoints/faq/faq.php @@ -0,0 +1,34 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/filter/filter.php b/endpoints/filter/filter.php new file mode 100644 index 00000000..9830385c --- /dev/null +++ b/endpoints/filter/filter.php @@ -0,0 +1,86 @@ +page = strtolower($page); + + if ($catg !== null) + { + // category is a string for profiler (region.realm) but not passed through here + foreach (explode('.', $catg) as $c) + { + if (preg_match('/\D/', $c)) + break; + + $this->catg[] = intval($c); + } + } + + $opts = ['parentCats' => $this->catg]; + + // so usually the page call is just the DBTypes file string with a plural 's' .. but then there are currencies + $fileStr = match ($this->page) + { + 'currencies' => 'currency', + default => substr($this->page, 0, -1) + }; + + // yes, the whole _POST! .. should the input fields be exposed and static so they can be evaluated via BaseResponse::initRequestData() ? + if (!$this->filter = Type::newFilter($fileStr, $_POST, $opts)) + trigger_error('Filter::__construct - tried to init filter from bogus GET data', E_USER_WARNING); + } + + protected function generate() : void + { + // could not build filter from $this->page > go to front page + if (!$this->filter) + { + $this->redirectTo = '.'; + return; + } + + $url = '?'.$this->page; + + $this->filter->mergeCat($this->catg); + + if ($this->catg) + $url .= '='.implode('.', $this->catg); + + if ($x = $this->filter?->buildGETParam()) + $url .= '&filter='.$x; + + if ($this->filter->error) + $_SESSION['error']['fi'] = $this->filter::class; + + // do get request + $this->redirectTo = $url; + } +} + +?> diff --git a/endpoints/get-description/get-description.php b/endpoints/get-description/get-description.php new file mode 100644 index 00000000..139c6949 --- /dev/null +++ b/endpoints/get-description/get-description.php @@ -0,0 +1,35 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob']] + ); + + public function __construct(string $param) + { + if ($param) // should be empty + $this->generate404(); + + parent::__construct($param); + } + + protected function generate() : void + { + if (!User::canWriteGuide()) + return; + + $this->result = GuideMgr::createDescription($this->_post['description']); + } +} + +?> diff --git a/endpoints/go-to-comment/go-to-comment.php b/endpoints/go-to-comment/go-to-comment.php new file mode 100644 index 00000000..c99d55f4 --- /dev/null +++ b/endpoints/go-to-comment/go-to-comment.php @@ -0,0 +1,45 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('GotocommentBaseResponse - malformed request received', E_USER_ERROR); + return; + } + + // the reputation-history listview only creates go-to-comment links. So either upvoting replies does not grant reputation, or.... bug.? + + $comment = DB::Aowow()->selectRow('SELECT IFNULL(c2.`id`, c1.`id`) AS "id", IFNULL(c2.`type`, c1.`type`) AS "type", IFNULL(c2.`typeId`, c1.`typeId`) AS "typeId" FROM ::comments c1 LEFT JOIN ::comments c2 ON c1.`replyTo` = c2.`id` WHERE c1.`id` = %i', $this->_get['id']); + if (!$comment) + { + trigger_error('GotocommentBaseResponse - comment #'.$this->_get['id'].' not found', E_USER_ERROR); + return; + } + + if (!Type::validateIds($comment['type'], $comment['typeId'])) + { + trigger_error('GotocommentBaseResponse - comment #'.$this->_get['id'].' belongs to nonexistent type/typeID combo '.$comment['type'].'/'.$comment['typeId'], E_USER_ERROR); + return; + } + + $this->redirectTo = sprintf('?%s=%d#comments:id=%d', Type::getFileString($comment['type']), $comment['typeId'], $comment['id']); + if ($comment['id'] != $this->_get['id']) // i am reply + $this->redirectTo .= ':reply='.$this->_get['id']; + } +} + +?> diff --git a/endpoints/go-to-reply/go-to-reply.php b/endpoints/go-to-reply/go-to-reply.php new file mode 100644 index 00000000..e3cbbcc4 --- /dev/null +++ b/endpoints/go-to-reply/go-to-reply.php @@ -0,0 +1,42 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('GotoreplyBaseResponse - malformed request received', E_USER_ERROR); + return; + } + + // type = typeId = 0 AND replyTo <> 0 for replies + $reply = DB::Aowow()->selectRow('SELECT c.`id`, r.`id` AS "reply", c.`type`, c.`typeId` FROM ::comments r JOIN ::comments c ON r.`replyTo` = c.`id` WHERE r.`id` = %i', $this->_get['id']); + if (!$reply) + { + trigger_error('GotoreplyBaseResponse - reply #'.$this->_get['id'].' not found', E_USER_ERROR); + return; + } + + if (!Type::validateIds($reply['type'], $reply['typeId'])) + { + trigger_error('GotoreplyBaseResponse - parent comment #'.$reply['id'].' belongs to nonexistent type/typeID combo '.$reply['type'].'/'.$reply['typeId'], E_USER_ERROR); + return; + } + + $this->redirectTo = sprintf('?%s=%d#comments:id=%d:reply=%d', Type::getFileString($reply['type']), $reply['typeId'], $reply['id'], $reply['reply']); + } +} + +?> diff --git a/endpoints/guide/changelog.php b/endpoints/guide/changelog.php new file mode 100644 index 00000000..eee823e7 --- /dev/null +++ b/endpoints/guide/changelog.php @@ -0,0 +1,105 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + protected function generate() : void + { + // main container should be tagged:
+ + if (!$this->assertGET('id')) + $this->generateNotFound(Lang::game('guide'), Lang::guide('notFound')); + + $guide = new GuideList(array(['id', $this->_get['id']])); + if ($guide->error) + $this->generateNotFound(Lang::game('guide'), Lang::guide('notFound')); + + if (!$guide->canBeViewed() && !$guide->userCanView()) + $this->forward('?guides='.$guide->getField('category')); + + $this->h1 = Lang::guide('clTitle', [$this->_get['id'], $guide->getField('title')]); + if (!$this->h1) + $this->h1 = $guide->getField('name'); + + $this->gPageInfo += ['name' => $guide->getField('name')]; + + + $this->breadcrumb[] = $guide->getField('category'); + + + parent::generate(); + + /* - NYI (see "&& false") + $this->addScript([SC_JS_STRING, + + <<= parseInt(e.value)); + }); + + }; + + radios.each(function (i, e) { + e.onchange = limit.bind(this, e.name, parseInt(e.value)); + + if (i < 2 && e.name == "b") // first pair + $(e).trigger("click"); + else if (e.value == 0 && e.name == "a") // last pair + $(e).trigger("click"); + }); + }); + JS + ]); + */ + + $buff = '
    '; + $inp = fn($rev) => User::isInGroup(U_GROUP_STAFF) && false ? ($rev !== null ? '' : '') : ''; + $now = new DateTime(); + + $logEntries = DB::Aowow()->selectAssoc('SELECT a.`username` AS `name`, gcl.`date`, gcl.`status`, gcl.`msg`, gcl.`rev` FROM ::guides_changelog gcl JOIN ::account a ON a.`id` = gcl.`userId` WHERE gcl.`id` = %i ORDER BY gcl.`date` DESC', $this->_get['id']); + foreach ($logEntries as $log) + { + if ($log['status'] != GuideMgr::STATUS_NONE) + $buff .= '
  • '.$inp($log['rev']).''.Lang::guide('clStatusSet', [Lang::guide('status', $log['status'])]).''.$now->formatDate($log['date'], true)."
  • \n"; + else if ($log['msg']) + $buff .= '
  • '.$inp($log['rev']).''.$now->formatDate($log['date'], true).Lang::main('colon').''.$log['msg'].' '.Lang::main('byUser', [$log['name'], 'style="text-decoration:underline"'])."
  • \n"; + else + $buff .= '
  • '.$inp($log['rev']).''.$now->formatDate($log['date'], true).Lang::main('colon').''.Lang::guide('clMinorEdit').' '.Lang::main('byUser', [$log['name'], 'style="text-decoration:underline"'])."
  • \n"; + } + + // append creation + $buff .= '
  • '.$inp(0).''.Lang::guide('clCreated').''.$now->formatDate($guide->getField('date'), true)."
  • \n
\n"; + + if (User::isInGroup(U_GROUP_STAFF) && false) + $buff .= ''; + + $this->extraHTML = $buff; + } +} + +?> diff --git a/endpoints/guide/edit.php b/endpoints/guide/edit.php new file mode 100644 index 00000000..be86d440 --- /dev/null +++ b/endpoints/guide/edit.php @@ -0,0 +1,214 @@ + span { display: block; height: 22px; } + #upload-result { display: inline-block; text-align: right; } + #upload-progress { display: inline-block; margin-right: 8px; } + + CSS] + ); + protected array $expectedPOST = array( + 'save' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ], // saved for more editing + 'submit' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ], // submitted for review + 'title' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'name' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'description' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkDescription'] ], + 'changelog' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ], + 'body' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ], + 'locale' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkLocale'] ], + 'category' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_value' => 1, 'max_value' => 9] ], + 'specId' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_value' => -1, 'max_value' => 2, 'default' => -1]], + 'classId' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_value' => 1, 'max_value' => 11, 'default' => 0]] + ); + protected array $expectedGET = array( + 'id' => ['filter' => FILTER_VALIDATE_INT], + 'rev' => ['filter' => FILTER_VALIDATE_INT] + ); + + public function __construct(string $param) + { + parent::__construct($param); + + if (!User::canWriteGuide()) + $this->generateError(); + + if (!is_int($this->_get['id'])) // edit existing guide + return; + + $this->typeId = $this->_get['id']; // just to display sensible not-found msg + $status = DB::Aowow()->selectCell('SELECT `status` FROM ::guides WHERE %if', !User::isInGroup(U_GROUP_STAFF), '`userId` = %i AND', User::$id, '%end `id` = %i AND `status` <> %i', $this->typeId, GuideMgr::STATUS_ARCHIVED); + if (!$status && $this->typeId) + $this->generateNotFound(Lang::game('guide'), Lang::guide('notFound')); + else if (!$this->typeId) + return; + + // just so we don't have to access GuideMgr from template + $this->isDraft = $status == GuideMgr::STATUS_DRAFT; + $this->editStatus = $status; + $this->editRev = DB::Aowow()->selectCell('SELECT `rev` FROM ::articles WHERE `type` = %i AND `typeId` = %i ORDER BY `rev` DESC', Type::GUIDE, $this->typeId); + } + + protected function generate() : void + { + if ($this->_post['save'] || $this->_post['submit']) + { + if (!$this->saveGuide()) + $this->error = Lang::main('intError'); + else if ($this->_get['id'] === 0) + $this->forward('?guide=edit&id='.$this->typeId); + } + + $guide = new GuideList(array(['id', $this->typeId])); + + $this->h1 = Lang::guide('editTitle'); + array_unshift($this->title, $this->h1.Lang::main('colon').$guide->getField('title'), Lang::game('guides')); + + Lang::sort('guide', 'category'); + + // init required template vars + $this->editCategory = $this->_post['category'] ?? $guide->getField('category'); + $this->editTitle = $this->_post['title'] ?? $guide->getField('title'); + $this->editName = $this->_post['name'] ?? $guide->getField('name'); + $this->editDescription = $this->_post['description'] ?? $guide->getField('description'); + $this->editText = $this->_post['body'] ?? $guide->getArticle(); + $this->editClassId = $this->_post['classId'] ?? $guide->getField('classId'); + $this->editSpecId = $this->_post['specId'] ?? $guide->getField('specId'); + $this->editLocale = $this->_post['locale'] ?? Locale::tryFrom($guide->getField('locale')); + $this->editStatus = $this->editStatus ?: $guide->getField('status'); + $this->editStatusColor = GuideMgr::STATUS_COLORS[$this->editStatus]; + + $this->extendGlobalData($guide->getJSGlobals()); + + parent::generate(); + } + + private function saveGuide() : bool + { + // test requiered fields set + if (!$this->assertPOST('title', 'name', 'body', 'locale', 'category')) + { + trigger_error('GuideEditResponse::saveGuide - received malformed request', E_USER_ERROR); + return false; + } + + // test required fields context + if (!$this->_post['locale']->validate()) + return false; + + // sanitize: spec / class + if ($this->_post['category'] == 1) // Classes + { + if ($this->_post['classId'] && !ChrClass::tryFrom($this->_post['classId'])) + $this->_post['classId'] = 0; + + if ($this->_post['specId'] > -1 && !$this->_post['classId']) + $this->_post['specId'] = -1; + } + else + { + $this->_post['classId'] = 0; + $this->_post['specId'] = -1; + } + + $guideData = array( + 'category' => $this->_post['category'], + 'classId' => $this->_post['classId'], + 'specId' => $this->_post['specId'], + 'title' => $this->_post['title'], + 'name' => $this->_post['name'], + 'description' => $this->_post['description'] ?: GuideMgr::createDescription($this->_post['body']), + 'locale' => $this->_post['locale']->value, + 'roles' => User::$groups, + 'status' => $this->_post['submit'] ? GuideMgr::STATUS_REVIEW : GuideMgr::STATUS_DRAFT, + 'date' => time() + ); + + // new guide > reload editor + if ($this->_get['id'] === 0) + { + $guideData += ['userId' => User::$id]; + if (!($this->typeId = (int)DB::Aowow()->qry('INSERT INTO ::guides %v', $guideData))) + { + trigger_error('GuideEditResponse::saveGuide - failed to save guide to db', E_USER_ERROR); + return false; + } + } + // existing guide > :shrug: + else if (DB::Aowow()->qry('UPDATE ::guides SET %a WHERE `id` = %i', $guideData, $this->typeId)) + DB::Aowow()->qry('INSERT INTO ::guides_changelog (`id`, `rev`, `date`, `userId`, `msg`) VALUES (%i, %i, %i, %i, %s)', $this->typeId, $this->editRev, time(), User::$id, $this->_post['changelog']); + else + { + trigger_error('GuideEditResponse::saveGuide - failed to update guide in db', E_USER_ERROR); + return false; + } + + // insert Article + $articleId = DB::Aowow()->qry( + 'INSERT INTO ::articles (`type`, `typeId`, `locale`, `rev`, `editAccess`, `article`) VALUES (%i, %i, %i, %i, %i, %s)', + Type::GUIDE, + $this->typeId, + $this->_post['locale']->value, + ++$this->editRev, + User::$groups & U_GROUP_STAFF ? User::$groups : User::$groups | U_GROUP_BLOGGER, + $this->_post['body'] + ); + + if (!is_int($articleId)) + { + if ($this->_get['id'] === 0) + DB::Aowow()->qry('DELETE FROM ::guides WHERE `id` = %i', $this->typeId); + + trigger_error('GuideEditResponse::saveGuide - failed to save article to db', E_USER_ERROR); + return false; + } + + if ($this->_post['submit'] && $this->editStatus != GuideMgr::STATUS_REVIEW) + DB::Aowow()->qry('INSERT INTO ::guides_changelog (`id`, `date`, `userId`, `status`) VALUES (%i, %i, %i, %i)', $this->typeId, time(), User::$id, GuideMgr::STATUS_REVIEW); + + $this->editStatus = $guideData['status']; + + return true; + } + + protected static function checkDescription(string $str) : string + { + // run checkTextBlob and also replace \n => \s and \s+ => \s + $str = preg_replace(parent::PATTERN_TEXT_BLOB, '', $str); + + $str = strtr($str, ["\n" => ' ', "\r" => ' ']); + + return preg_replace('/\s+/', ' ', trim($str)); + } +} + +?> diff --git a/endpoints/guide/guide.php b/endpoints/guide/guide.php new file mode 100644 index 00000000..45e30c07 --- /dev/null +++ b/endpoints/guide/guide.php @@ -0,0 +1,250 @@ + ['filter' => FILTER_VALIDATE_INT], + 'rev' => ['filter' => FILTER_VALIDATE_INT] + ); + + public int $type = Type::GUIDE; + public int $typeId = 0; + public int $guideStatus = 0; + public array $guideRating = []; + public ?int $guideRevision = null; + + private GuideList $subject; + + public function __construct(string $nameOrId) + { + parent::__construct($nameOrId); + + /**********************/ + /* get mode + guideId */ + /**********************/ + + if (Util::checkNumeric($nameOrId, NUM_CAST_INT)) + $this->typeId = $nameOrId; + else if (preg_match(GuideMgr::VALID_URL, $nameOrId)) + { + if ($id = DB::Aowow()->selectCell('SELECT `id` FROM ::guides WHERE `url` = %s', Util::lower($nameOrId))) + { + $this->typeId = intVal($id); + $this->articleUrl = Util::lower($nameOrId); + } + } + + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new GuideList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('guide'), Lang::guide('notFound')); + + if (!$this->subject->canBeViewed() && !$this->subject->userCanView()) + $this->forward('?guides='.$this->subject->getField('category')); + + $this->guideStatus = $this->subject->getField('status'); + if ($this->guideStatus != GuideMgr::STATUS_APPROVED && $this->guideStatus != GuideMgr::STATUS_ARCHIVED) + { + $this->cacheType = CACHE_TYPE_NONE; + $this->contribute = CONTRIBUTE_NONE; + } + + // owner or staff and manual rev passed + if ($this->subject->userCanView() && $this->_get['rev']) + $this->guideRevision = $this->_get['rev']; + // has publicly viewable version + else if ($this->subject->canBeViewed()) + $this->guideRevision = $this->subject->getField('rev'); + + $this->h1 = $this->subject->getField('name'); + + $this->gPageInfo += array( + 'name' => $this->h1, + 'author' => $this->subject->getField('author') + ); + + + /*************/ + /* Menu Path */ + /*************/ + + if ($x = $this->subject?->getField('category')) + $this->breadcrumb[] = $x; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->subject->getField('title'), Lang::game('guides')); + + + /***********/ + /* Infobox */ + /***********/ + + if (!($this->subject->getField('cuFlags') & GUIDE_CU_NO_QUICKFACTS)) + $this->generateInfobox(); + + // needs post-cache updating + if (!($this->subject->getField('cuFlags') & GUIDE_CU_NO_RATING)) + $this->guideRating = array( + $this->subject->getField('rating'), // avg rating + User::canUpvote() && User::canDownvote() ? 'true' : 'false', + $this->subject->getField('_self'), // my rating amt; 0 = no vote + $this->typeId // guide Id + ); + + + /****************/ + /* Main Content */ + /****************/ + + if ($this->subject->userCanView()) + $this->redButtons[BUTTON_GUIDE_EDIT] = User::canWriteGuide() && $this->guideStatus != GuideMgr::STATUS_ARCHIVED; + + $this->redButtons[BUTTON_GUIDE_LOG] = true; + $this->redButtons[BUTTON_GUIDE_REPORT] = $this->subject->canBeReported(); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], __forceTabs: true); + + // the article text itself is added by TemplateResponse::addArticle() + parent::generate(); + + $this->result->registerDisplayHook('infobox', [self::class, 'infoboxHook']); + if ($this->guideRating) + $this->result->registerDisplayHook('guideRating', [self::class, 'starsHook']); + } + + private function generateInfobox() : void + { + $infobox = []; + + if ($this->subject->getField('cuFlags') & CC_FLAG_STICKY) + $infobox[] = '[span class=guide-sticky]'.Lang::guide('sticky').'[/span]'; + + $infobox[] = Lang::guide('author').'[url=?user='.$this->subject->getField('author').']'.$this->subject->getField('author').'[/url]'; + + if ($this->subject->getField('category') == 1) + { + $c = $this->subject->getField('classId'); + $s = $this->subject->getField('specId'); + if ($c > 0) + { + $this->extendGlobalIds(Type::CHR_CLASS, $c); + $infobox[] = Util::ucFirst(Lang::game('class')).Lang::main('colon').'[class='.$c.']'; + } + if ($s > -1) + $infobox[] = Lang::guide('spec').'[icon class="c'.$c.' icontiny" name='.Game::$specIconStrings[$c][$s].']'.Lang::game('classSpecs', $c, $s).'[/icon]'; + } + + // $infobox[] = Lang::guide('patch').Lang::main('colon').'3.3.5'; // replace with date + $infobox[] = Lang::guide('added').'[tooltip name=added]'.date('l, G:i:s', $this->subject->getField('date')).'[/tooltip][span class=tip tooltip=added]'.date(Lang::main('dateFmtShort'), $this->subject->getField('date')).'[/span]'; + + if ($this->guideStatus == GuideMgr::STATUS_ARCHIVED) + $infobox[] = Lang::guide('status', GuideMgr::STATUS_ARCHIVED); + + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + if ($this->guideStatus == GuideMgr::STATUS_REVIEW && User::isInGroup(U_GROUP_STAFF) && $this->_get['rev']) + { + $this->addScript([SC_JS_STRING, <<infobox->append('[h3 style="text-align:center"]Admin[/h3]'); + $this->infobox->append('[div style="text-align:center"][url=# id="btn-accept" class=icon-tick]Approve[/url][url=# style="margin-left:20px" id="btn-reject" class=icon-delete]Reject[/url][/div]'); + } + } + + public static function infoboxHook(Template\PageTemplate &$pt, ?InfoboxMarkup &$infobox) : void + { + if ($pt->guideStatus != GuideMgr::STATUS_APPROVED) + return; + + // increment and display views + DB::Aowow()->qry('UPDATE ::guides SET `views` = `views` + 1 WHERE `id` = %i', $pt->typeId); + + $nViews = DB::Aowow()->selectCell('SELECT `views` FROM ::guides WHERE `id` = %i', $pt->typeId); + + $infobox->addItem(Lang::guide('views').'[n5='.$nViews.']'); + + // should we have a rating item in the lv? + if (!$pt->guideRating) + return; + + $rating = GuideMgr::getRatings([$pt->typeId]); + if ($rating[$pt->typeId]['nvotes'] < 5) + $infobox->addItem(Lang::guide('rating').Lang::guide('noVotes')); + else + $infobox->addItem(Lang::guide('rating').Lang::guide('votes', [round($rating[$pt->typeId]['rating'], 1), $rating[$pt->typeId]['nvotes']])); + } + + public static function starsHook(Template\PageTemplate &$pt, ?array &$guideRating) : void + { + if ($pt->guideStatus != GuideMgr::STATUS_APPROVED) + return; + + $rating = GuideMgr::getRatings([$pt->typeId]); + $guideRating = array( + $rating[$pt->typeId]['rating'], + User::canUpvote() && User::canDownvote() ? 'true' : 'false', + $rating[$pt->typeId]['_self'] ?? 0, + $pt->typeId + ); + } +} + +?> diff --git a/endpoints/guide/guide_power.php b/endpoints/guide/guide_power.php new file mode 100644 index 00000000..970bc302 --- /dev/null +++ b/endpoints/guide/guide_power.php @@ -0,0 +1,59 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + private string $url = ''; + + public function __construct(string $idOrName) + { + parent::__construct($idOrName); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + if (Util::checkNumeric($idOrName, NUM_CAST_INT)) + $this->typeId = $idOrName; + else if ($id = DB::Aowow()->selectCell('SELECT `id` FROM ::guides WHERE `url` = %s', Util::lower($idOrName))) + { + $this->typeId = intVal($id); + $this->url = Util::lower($idOrName); + } + } + + protected function generate() : void + { + $opts = []; + if ($this->typeId) + if (!($guide = new GuideList(array(['id', $this->typeId])))->error) + $opts = array( + 'name' => $guide->getField('name', true), + 'tooltip' => $guide->renderTooltip() + ); + + if (!$opts) + $this->cacheType = CACHE_TYPE_NONE; + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->url ?: $this->typeId, $opts); + } +} + +?> diff --git a/endpoints/guide/new.php b/endpoints/guide/new.php new file mode 100644 index 00000000..95de5c4d --- /dev/null +++ b/endpoints/guide/new.php @@ -0,0 +1,66 @@ + span { display: block; height: 22px; } + #upload-result { display: inline-block; text-align: right; } + #upload-progress { display: inline-block; margin-right: 8px; } + + CSS] + ); + + public function __construct(string $param) + { + parent::__construct($param); + + if (!User::canWriteGuide()) + $this->generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::guide('newTitle'); + + array_unshift($this->title, $this->h1, Lang::game('guides')); + + Lang::sort('guide', 'category'); + + // update required template vars + $this->editLocale = Lang::getLocale(); + + parent::generate(); + } +} + +?> diff --git a/endpoints/guide/vote.php b/endpoints/guide/vote.php new file mode 100644 index 00000000..ff82c837 --- /dev/null +++ b/endpoints/guide/vote.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_VALIDATE_INT ], + 'rating' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 0, 'max_range' => 5]] + ); + + protected function generate() : void + { + if (!$this->assertPOST('id', 'rating')) + { + trigger_error('GuideVoteResponse - malformed request received', E_USER_ERROR); + $this->generate404(); + } + + if (!User::canUpvote() || !User::canDownvote()) // same logic as comments? + $this->generate403(); + + // by id, not own, published + $points = $votes = 0; + if ($g = DB::Aowow()->selectRow('SELECT `userId`, `cuFlags` FROM ::guides WHERE `id` = %i AND (`status` = %i OR `rev` > 0)', $this->_post['id'], GuideMgr::STATUS_APPROVED)) + { + // apparently you are allowed to vote on your own guide + if ($g['cuFlags'] & GUIDE_CU_NO_RATING) + $this->generate403(); + + if (!$this->_post['rating']) + DB::Aowow()->qry('DELETE FROM ::user_ratings WHERE `type` = %i AND `entry` = %i AND `userId` = %i', RATING_GUIDE, $this->_post['id'], User::$id); + else + DB::Aowow()->qry('REPLACE INTO ::user_ratings (`type`, `entry`, `userId`, `value`) VALUES (%i, %i, %i, %i)', RATING_GUIDE, $this->_post['id'], User::$id, $this->_post['rating']); + + [$points, $votes] = DB::Aowow()->selectRow('SELECT IFNULL(SUM(`value`), 0) AS "0", IFNULL(COUNT(*), 0) AS "1" FROM ::user_ratings WHERE `type` = %i AND `entry` = %i', RATING_GUIDE, $this->_post['id']); + } + + $this->result = Util::toJSON($votes ? ['rating' => $points / $votes, 'nvotes' => $votes] : ['rating' => 0, 'nvotes' => 0]); + } +} + +?> diff --git a/endpoints/guides/guides.php b/endpoints/guides/guides.php new file mode 100644 index 00000000..23116f41 --- /dev/null +++ b/endpoints/guides/guides.php @@ -0,0 +1,73 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('guides')); + + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::guide('category', $this->category[0])); + + + $conditions = array( + ['locale', Lang::getLocale()->value], + ['status', GuideMgr::STATUS_ARCHIVED, '!'], // never archived guides + [ + DB::OR, + ['status', GuideMgr::STATUS_APPROVED], // currently approved + ['rev', 0, '>'] // has previously approved revision + ] + ); + if ($this->category) + $conditions[] = ['category', $this->category[0]]; + + $this->redButtons = [BUTTON_GUIDE_NEW => User::canWriteGuide()]; + + $guides = new GuideList($conditions); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $guides->getListviewData(), + 'name' => Util::ucFirst(Lang::game('guides')), + 'hiddenCols' => ['patch'], // pointless: display date instead + 'extraCols' => ['$Listview.extraCols.date'] // ok + ), GuideList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/guild/guild.php b/endpoints/guild/guild.php new file mode 100644 index 00000000..9db0d252 --- /dev/null +++ b/endpoints/guild/guild.php @@ -0,0 +1,153 @@ + Profiler > Guilds + + protected array $dataLoader = ['realms', 'weight-presets']; + protected array $scripts = array( + [SC_JS_FILE, 'js/profile_all.js'], + [SC_JS_FILE, 'js/profile.js'], + [SC_CSS_FILE, 'css/Profiler.css'] + ); + + public int $type = Type::GUILD; + + public function __construct(string $idOrProfile) + { + parent::__construct($idOrProfile); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generateError(); + + if (!$idOrProfile) + $this->generateError(); + + $this->getSubjectFromUrl($idOrProfile); + + // we have an ID > ok + if ($this->typeId) + return; + + // param was incomplete profile > error + if (!$this->subjectName) + $this->generateError(); + + // 3 possibilities + // 1) already synced to aowow + if ($subject = DB::Aowow()->selectRow('SELECT `id`, `realmGUID`, `stub` FROM ::profiler_guild WHERE `realm` = %i AND `nameUrl` = %s', $this->realmId, Profiler::urlize($this->subjectName))) + { + $this->typeId = $subject['id']; + + if ($subject['stub']) + $this->handleIncompleteData(Type::GUILD, $subject['realmGUID']); + + return; + } + + // 2) not yet synced but exists on realm (wont work if we get passed an urlized name, but there is nothing we can do about it) + $subjects = DB::Characters($this->realmId)->selectAssoc('SELECT `guildid` AS "realmGUID", `name` FROM guild WHERE `name` = %s', $this->subjectName); + if ($subject = array_find($subjects ?: [], fn($x) => Util::lower($x['name']) === Util::lower($this->subjectName))) + { + $subject['realm'] = $this->realmId; + $subject['stub'] = 1; + $subject['nameUrl'] = Profiler::urlize($subject['name']); + + // create entry from realm with basic info + DB::Aowow()->qry('INSERT IGNORE INTO ::profiler_guild %v', $subject); + + $this->handleIncompleteData(Type::GUILD, $subject['realmGUID']); + return; + } + + // 3) does not exist at all + $this->notFound(); + } + + protected function generate() : void + { + if ($this->doResync) + { + parent::generate(); + return; + } + + $subject = new LocalGuildList(array(['id', $this->typeId])); + if ($subject->error) + $this->notFound(); + + // guild accessed by id + if (!$this->subjectName) + $this->forward($subject->getProfileUrl()); + + $this->h1 = Lang::profiler('guildRoster', [$subject->getField('name')]); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->followBreadcrumbPath(); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift( + $this->title, + $subject->getField('name').' ('.$this->realm.' - '.Lang::profiler('regions', $this->region).')', + Util::ucFirst(Lang::profiler('profiler')) + ); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_RESYNC] = [$this->typeId, 'guild']; + + // statistic calculations here + + // smuggle the guild ranks into the html + if ($ranks = DB::Aowow()->selectCol('SELECT `rank` AS ARRAY_KEY, `name` FROM ::profiler_guild_rank WHERE `guildId` = %i', $this->typeId)) + $this->extraHTML = ''; + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated'); + + // tab: members + $member = new LocalProfileList(array(['p.guild', $this->typeId])); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $member->getListviewData(PROFILEINFO_CHARACTER | PROFILEINFO_GUILD), + 'sort' => [-15], + 'visibleCols' => ['race', 'classs', 'level', 'talents', 'gearscore', 'achievementpoints', 'guildrank'], + 'hiddenCols' => ['guild', 'location'] + ), ProfileList::$brickFile)); + + parent::generate(); + } + + public function notFound() : never + { + parent::generateNotFound(Lang::game('guild'), Lang::profiler('notFound', 'guild')); + } + +} + +?> diff --git a/endpoints/guild/resync.php b/endpoints/guild/resync.php new file mode 100644 index 00000000..82df7f8f --- /dev/null +++ b/endpoints/guild/resync.php @@ -0,0 +1,48 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'profile' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional, not used] + profile: [optional, also get related chars] + return: 1 + */ + protected function generate() : void + { + if (!$this->assertGET('id')) + return; + + if ($guilds = DB::Aowow()->selectAssoc('SELECT `realm`, `realmGUID` FROM ::profiler_guild WHERE `id` IN %in', $this->_get['id'])) + foreach ($guilds as $g) + Profiler::scheduleResync(Type::GUILD, $g['realm'], $g['realmGUID']); + + if ($this->_get['profile']) + if ($chars = DB::Aowow()->selectAssoc('SELECT `realm`, `realmGUID` FROM ::profiler_profiles WHERE `guild` IN %in', $this->_get['id'])) + foreach ($chars as $c) + Profiler::scheduleResync(Type::PROFILE, $c['realm'], $c['realmGUID']); + + $this->result = 1; // as string? + } +} + +?> diff --git a/endpoints/guild/status.php b/endpoints/guild/status.php new file mode 100644 index 00000000..df311ee0 --- /dev/null +++ b/endpoints/guild/status.php @@ -0,0 +1,29 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + protected function generate() : void + { + $this->result = Profiler::resyncStatus(Type::GUILD, $this->_get['id']); + } +} + +?> diff --git a/endpoints/guilds/guilds.php b/endpoints/guilds/guilds.php new file mode 100644 index 00000000..38f76557 --- /dev/null +++ b/endpoints/guilds/guilds.php @@ -0,0 +1,158 @@ + Profiler > Guilds + + protected array $dataLoader = ['realms']; + protected array $scripts = array( + [SC_JS_FILE, 'js/filters.js'], + [SC_JS_FILE, 'js/profile_all.js'], + [SC_JS_FILE, 'js/profile.js'] + ); + protected array $expectedGET = array( + 'filter' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + + public int $type = Type::GUILD; + + private int $sumSubjects = 0; + + public function __construct(string $rawParam) + { + if (!Cfg::get('PROFILER_ENABLE')) + $this->generateError(); + + $this->getSubjectFromUrl($rawParam); + + parent::__construct($rawParam); + + $realms = []; + foreach (Profiler::getRealms() as $idx => $r) + { + if ($this->region && $r['region'] != $this->region) + continue; + + if ($this->realm && $r['name'] != $this->realm) + continue; + + $this->sumSubjects += DB::Characters($idx)->selectCell('SELECT count(*) FROM guild'); + $realms[] = $idx; + } + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new GuildListFilter($this->_get['filter'] ?? '', ['realms' => $realms]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Lang::game('guilds'); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->followBreadcrumbPath(); + + + /**************/ + /* Page Title */ + /**************/ + + if ($this->realm) + array_unshift($this->title, $this->realm,/* Cfg::get('BATTLEGROUP'),*/ Lang::profiler('regions', $this->region), Lang::game('guilds')); + else if ($this->region) + array_unshift($this->title, Lang::profiler('regions', $this->region), Lang::game('guilds')); + else + array_unshift($this->title, Lang::game('guilds')); + + + /****************/ + /* Main Content */ + /****************/ + + $conditions = array( + Listview::DEFAULT_SIZE, + ['c.deleteInfos_Account', null], + ['c.level', MAX_LEVEL, '<='], // prevents JS errors + [['c.extra_flags', Profiler::CHAR_GMFLAGS, '&'], 0] + ); + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + $this->getRegions(); + + $tabData = array( + 'id' => 'guilds', + 'data' => [], + 'hideCount' => 1, + 'sort' => [-3], + 'visibleCols' => ['members', 'achievementpoints', 'gearscore'], + 'hiddenCols' => ['guild'] + ); + + if ($this->filter->values['si']) + $tabData['hiddenCols'][] = 'faction'; + + $miscParams = ['calcTotal' => true]; + if ($this->realm) + $miscParams['sv'] = $this->realm; + if ($this->region) + $miscParams['rg'] = $this->region; + + $guilds = new RemoteGuildList($conditions, $miscParams); + if (!$guilds->error) + { + $guilds->initializeLocalEntries(); + + $tabData['data'] = $guilds->getListviewData(); + + // create note if search limit was exceeded + if ($this->filter->query && $guilds->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_guildsfound2', $this->sumSubjects, $guilds->getMatches()); + $tabData['_truncated'] = 1; + } + else if ($guilds->getMatches() > Listview::DEFAULT_SIZE) + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_guildsfound', $this->sumSubjects, 0); + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated'); + + $this->lvTabs->addListviewTab(new Listview($tabData, GuildList::$brickFile, 'membersCol')); + + parent::generate(); + + $this->result->registerDisplayHook('filter', [self::class, 'filterFormHook']); + } + + public static function filterFormHook(Template\PageTemplate &$pt, GuildListFilter $filter) : void + { + // sort for dropdown-menus + Lang::sort('game', 'cl'); + Lang::sort('game', 'ra'); + } +} + +?> diff --git a/endpoints/help/help.php b/endpoints/help/help.php new file mode 100644 index 00000000..92e5f7f8 --- /dev/null +++ b/endpoints/help/help.php @@ -0,0 +1,45 @@ +generateError(); + + $pageId = array_search($rawParam, $this->validCats); + if ($pageId === false) + $this->generateError(); + + $this->catg = $rawParam; + $this->articleUrl = $this->pageName.'='.$rawParam; + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName, $this->catg); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/home/home.php b/endpoints/home/home.php new file mode 100644 index 00000000..8ca0caba --- /dev/null +++ b/endpoints/home/home.php @@ -0,0 +1,75 @@ + element + if ($_ = DB::Aowow()->selectCell('SELECT `title` FROM ::home_titles WHERE `active` = 1 AND `locale` = %i ORDER BY RAND()', Lang::getLocale()->value)) + $this->homeTitle = Util::jsEscape(Cfg::get('NAME').Lang::main('colon').$_); + + // load oneliner + if ($_ = DB::Aowow()->selectRow('SELECT * FROM ::home_oneliner WHERE `active` = 1 ORDER BY RAND() LIMIT 1')) + $this->oneliner = new Markup(new LocString($_, 'text'), [], 'home-oneliner'); + + if ($_ = $this->oneliner?->getJsGlobals()) + $this->extendGlobalData($_); + + // load featuredBox (user web server time) + if ($box = DB::Aowow()->selectRow('SELECT * FROM ::home_featuredbox WHERE %i BETWEEN `startDate` AND `endDate` ORDER BY `id` DESC', time())) + { + // define text constants for all fields (STATIC_URL, HOST_URL, etc.) + $box = Util::defStatic($box); + + if ($box['altHomeLogo']) + $this->altHomeLogo = $box['altHomeLogo']; + + $this->featuredBox = array( + 'markup' => new Markup(new LocString($box, 'text'), ['allow' => Markup::CLASS_ADMIN], 'news-generic'), + 'extended' => $box['extraWide'], + 'boxBG' => $box['boxBG'] ?? Cfg::get('STATIC_URL').'/images/'.Lang::getLocale()->json().'/mainpage-bg-news.jpg', + 'overlays' => [] + ); + + if ($_ = $this->featuredBox['markup']->getJsGlobals()) + $this->extendGlobalData($_); + + // load overlay links + foreach (DB::Aowow()->selectAssoc('SELECT * FROM ::home_featuredbox_overlay WHERE `featureId` = %i', $box['id']) as $ovl) + { + $ovl = Util::defStatic((array)$ovl); + + $this->featuredBox['overlays'][] = array( + 'url' => $ovl['url'], + 'left' => $ovl['left'], + 'width' => $ovl['width'], + 'title' => new LocString($ovl, 'title') + ); + } + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/icon/get-id-from-name.php b/endpoints/icon/get-id-from-name.php new file mode 100644 index 00000000..a74d9f62 --- /dev/null +++ b/endpoints/icon/get-id-from-name.php @@ -0,0 +1,29 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[\w_-]+$/']] + ); + + protected function generate() : void + { + if (!$this->assertGET('name')) + { + $this->result = 'null'; + return; + } + + $this->result = 0; + if ($id = DB::Aowow()->selectCell('SELECT `id` FROM ::icons WHERE `name` = %s', $this->_get['name'])) + $this->result = $id; + } +} + +?> diff --git a/endpoints/icon/icon.php b/endpoints/icon/icon.php new file mode 100644 index 00000000..b0620539 --- /dev/null +++ b/endpoints/icon/icon.php @@ -0,0 +1,158 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new IconList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('icon'), Lang::icon('notFound')); + + $this->extendGlobalData($this->subject->getJSGlobals()); + + $this->h1 = $this->subject->getField('name_source'); + $this->icon = $this->subject->getField('name', true, true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + $cats = [1 => 'nItems', 2 => 'nSpells', 3 => 'nAchievements', 6 => 'nCurrencies', 9 => 'nPets'/* , 11 => '' */]; + $crumb = ''; + foreach ($cats as $cat => $field) + { + if (!$this->subject->getField($field)) + continue; + + if ($crumb) + { + $crumb = 0; + break; + } + + $crumb = $cat; + } + + if ($crumb) + $this->breadcrumb[] = $crumb; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('icon'))); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons = array( + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_WOWHEAD => false + ); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // used by: spell + $ubSpells = new SpellList(array(['iconId', $this->typeId])); + if (!$ubSpells->error) + { + $this->extendGlobalData($ubSpells->getJsGlobals(GLOBALINFO_RELATED | GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubSpells->getListviewData(), + 'id' => 'used-by-spell' + ), SpellList::$brickFile)); + } + + // used by: item + $ubItems = new ItemList(array(['iconId', $this->typeId])); + if (!$ubItems->error) + { + $this->extendGlobalData($ubItems->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubItems->getListviewData(), + 'id' => 'used-by-item' + ), ItemList::$brickFile)); + } + + // used by: achievement + $ubAchievements = new AchievementList(array(['iconId', $this->typeId])); + if (!$ubAchievements->error) + { + $this->extendGlobalData($ubAchievements->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubAchievements->getListviewData(), + 'id' => 'used-by-achievement' + ), AchievementList::$brickFile)); + } + + // used by: currency + $ubCurrencies = new CurrencyList(array(['iconId', $this->typeId])); + if (!$ubCurrencies->error) + { + $this->extendGlobalData($ubCurrencies->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubCurrencies->getListviewData(), + 'id' => 'used-by-currency' + ), CurrencyList::$brickFile)); + } + + // used by: hunter pet + $ubPets = new PetList(array(['iconId', $this->typeId])); + if (!$ubPets->error) + { + $this->extendGlobalData($ubPets->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubPets->getListviewData(), + 'id' => 'used-by-pet' + ), PetList::$brickFile)); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/icons/icons.php b/endpoints/icons/icons.php new file mode 100644 index 00000000..75464fae --- /dev/null +++ b/endpoints/icons/icons.php @@ -0,0 +1,113 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = [0, 1, 2, 3]; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new IconListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucWords(Lang::game('icons')); + + $conditions = [600]; // LIMIT 600 - fits better onto the grid + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + + /**************/ + /* Page Title */ + /**************/ + + $title = $this->h1; + $setCr = $this->filter->getSetCriteria(1, 2, 3, 6, 9, 11); + if (count($setCr) == 1) + $title = match ($setCr[0]) + { + 1 => Util::ucFirst(Lang::game('item')), + 2 => Util::ucFirst(Lang::game('spell')), + 3 => Util::ucFirst(Lang::game('achievement')), + 6 => Util::ucFirst(Lang::game('currency')), + 9 => Util::ucFirst(Lang::game('pet')), + 11 => Util::ucFirst(Lang::game('class')), + } . ' ' . $this->h1; + + array_unshift($this->title, $title); + + + /*************/ + /* Menu Path */ + /*************/ + + if (count($setCr) == 1) + $this->breadcrumb[] = $setCr[0]; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + $icons = new IconList($conditions, ['calcTotal' => true]); + + $tabData['data'] = $icons->getListviewData(); + $this->extendGlobalData($icons->getJSGlobals()); + + if ($icons->getMatches() > $conditions[0]) // LIMIT + { + $tabData['note'] = sprintf(Util::$tryFilteringEntityString, $icons->getMatches(), 'LANG.types[29][3]', $conditions[0]); + $tabData['_truncated'] = 1; + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, IconList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/pages/item.php b/endpoints/item/item.php similarity index 51% rename from pages/item.php rename to endpoints/item/item.php index 379d178d..87778919 100644 --- a/pages/item.php +++ b/endpoints/item/item.php @@ -1,140 +1,84 @@ ['filter' => FILTER_CALLBACK, 'options' => 'GenericPage::checkDomain'], - 'rand' => ['filter' => FILTER_CALLBACK, 'options' => 'GenericPage::checkInt'], - 'ench' => ['filter' => FILTER_CALLBACK, 'options' => 'GenericPage::checkInt'], - 'gems' => ['filter' => FILTER_CALLBACK, 'options' => 'GenericPage::checkIntArray'], - 'sock' => ['filter' => FILTER_CALLBACK, 'options' => 'GenericPage::checkEmptySet'] - ); + public int $type = Type::ITEM; + public int $typeId = 0; + public bool $unavailable = false; + public ?Book $book = null; + public ?array $subItems = null; + public array $tooltip = []; - private $powerTpl = '$WowheadPower.registerItem(%s, %d, %s);'; + private ItemList $subject; - public function __construct($pageCall, $param) + public function __construct(string $id) { - parent::__construct($pageCall, $param); + parent::__construct($id); - $conditions = [['i.id', intVal($param)]]; + $this->typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } - $this->typeId = intVal($param); - - if ($this->mode == CACHE_TYPE_TOOLTIP) - { - // temp locale - if ($this->_get['domain']) - Util::powerUseLocale($this->_get['domain']); - - if ($this->_get['rand']) - $this->enhancedTT['r'] = $this->_get['rand']; - if ($this->_get['ench']) - $this->enhancedTT['e'] = $this->_get['ench']; - if ($this->_get['gems']) - $this->enhancedTT['g'] = $this->_get['gems']; - if ($this->_get['sock']) - $this->enhancedTT['s'] = ''; - } - else if ($this->mode == CACHE_TYPE_XML) - { - // temp locale - if ($this->_get['domain']) - Util::powerUseLocale($this->_get['domain']); - - // allow lookup by name for xml - if (!is_numeric($param)) - $conditions = [['name_loc'.User::$localeId, urldecode($param)]]; - } - - $this->subject = new ItemList($conditions); + protected function generate() : void + { + $this->subject = new ItemList(array(['i.id', $this->typeId])); if ($this->subject->error) - $this->notFound(Lang::game('item'), Lang::item('notFound')); + $this->generateNotFound(Lang::game('item'), Lang::item('notFound')); - if (!is_numeric($param)) - $this->typeId = $this->subject->id; + $jsg = $this->subject->getJSGlobals(GLOBALINFO_EXTRA | GLOBALINFO_SELF, $extra); + $this->extendGlobalData($jsg, $extra); - $this->name = $this->subject->getField('name', true); + $this->h1 = Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_HTML); - if ($this->mode == CACHE_TYPE_PAGE) - { - $jsg = $this->subject->getJSGlobals(GLOBALINFO_EXTRA | GLOBALINFO_SELF, $extra); - $this->extendGlobalData($jsg, $extra); - } - } - - protected function generatePath() - { - $_class = $this->subject->getField('class'); - $_subClass = $this->subject->getField('subClass'); - - if (in_array($_class, [ITEM_CLASS_REAGENT, ITEM_CLASS_GENERIC, ITEM_CLASS_PERMANENT])) - { - $this->path[] = ITEM_CLASS_MISC; // misc. - - if ($_class == ITEM_CLASS_REAGENT) // reagent - $this->path[] = 1; - else // other - $this->path[] = 4; - } - else - { - $this->path[] = $_class; - - if (!in_array($_class, [ITEM_CLASS_MONEY, ITEM_CLASS_QUEST, ITEM_CLASS_KEY])) - $this->path[] = $_subClass; - - if ($_class == ITEM_CLASS_ARMOR && in_array($_subClass, [1, 2, 3, 4])) - { - if ($_ = $this->subject->getField('slot')); - $this->path[] = $_; - } - else if (($_class == ITEM_CLASS_CONSUMABLE && $_subClass == 2) || $_class == ITEM_CLASS_GLYPH) - $this->path[] = $this->subject->getField('subSubClass'); - } - } - - protected function generateTitle() - { - array_unshift($this->title, $this->subject->getField('name', true), Util::ucFirst(Lang::game('item'))); - } - - protected function generateContent() - { - $this->addScript([JS_FILE, '?data=weight-presets.zones&locale='.User::$localeId.'&t='.$_SESSION['dataKey']]); + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); $_flags = $this->subject->getField('flags'); $_slot = $this->subject->getField('slot'); $_class = $this->subject->getField('class'); $_subClass = $this->subject->getField('subClass'); $_bagFamily = $this->subject->getField('bagFamily'); - $_model = $this->subject->getField('displayId'); + $_displayId = $this->subject->getField('displayId'); $_ilvl = $this->subject->getField('itemLevel'); - $_visSlots = array( - INVTYPE_HEAD, INVTYPE_SHOULDERS, INVTYPE_BODY, INVTYPE_CHEST, INVTYPE_WAIST, INVTYPE_LEGS, INVTYPE_FEET, INVTYPE_WRISTS, - INVTYPE_HANDS, INVTYPE_WEAPON, INVTYPE_SHIELD, INVTYPE_RANGED, INVTYPE_CLOAK, INVTYPE_2HWEAPON, INVTYPE_TABARD, INVTYPE_ROBE, - INVTYPE_WEAPONMAINHAND, INVTYPE_WEAPONOFFHAND, INVTYPE_HOLDABLE, INVTYPE_THROWN, INVTYPE_RANGEDRIGHT - ); + + + /*************/ + /* Menu Path */ + /*************/ + + if ($path = $this->followBreadcrumbPath()) + array_push($this->breadcrumb, ...$path); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_RAW), Util::ucFirst(Lang::game('item'))); + /***********/ /* Infobox */ @@ -152,13 +96,20 @@ class ItemPage extends genericPage // side if ($si = $this->subject->json[$this->typeId]['side']) - if ($si != 3) - $infobox[] = Lang::main('side').Lang::main('colon').'[span class=icon-'.($si == 1 ? 'alliance' : 'horde').']'.Lang::game('si', $si).'[/span]'; + $infobox[] = Lang::main('side') . match ($si) + { + SIDE_ALLIANCE => '[span class=icon-alliance]'.Lang::game('si', SIDE_ALLIANCE).'[/span]', + SIDE_HORDE => '[span class=icon-horde]'.Lang::game('si', SIDE_HORDE).'[/span]', + SIDE_BOTH => Lang::game('si', SIDE_BOTH) + }; + + // id + $infobox[] = Lang::item('id') . $this->typeId; // icon if ($_ = $this->subject->getField('iconId')) { - $infobox[] = Util::ucFirst(lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; $this->extendGlobalIds(Type::ICON, $_); } @@ -168,7 +119,7 @@ class ItemPage extends genericPage $hasUse = false; for ($i = 1; $i < 6; $i++) { - if ($this->subject->getField('spellId'.$i) <= 0 || in_array($this->subject->getField('spellTrigger'.$i), [1, 2])) + if ($this->subject->getField('spellId'.$i) <= 0 || in_array($this->subject->getField('spellTrigger'.$i), [SPELL_TRIGGER_EQUIP, SPELL_TRIGGER_HIT])) continue; $hasUse = true; @@ -181,27 +132,28 @@ class ItemPage extends genericPage } if ($hasUse) - $infobox[] = isset($tt) ? $tt : '[tooltip=tooltip_notconsumedonuse]'.Lang::item('nonConsumable').'[/tooltip]'; + $infobox[] = $tt ?? '[tooltip=tooltip_notconsumedonuse]'.Lang::item('nonConsumable').'[/tooltip]'; } // related holiday if ($eId = $this->subject->getField('eventId')) { $this->extendGlobalIds(Type::WORLDEVENT, $eId); - $infobox[] = Lang::game('eventShort').Lang::main('colon').'[event='.$eId.']'; + $infobox[] = Lang::game('eventShort', ['[event='.$eId.']']); } // tool if ($tId = $this->subject->getField('totemCategory')) - if ($tName = DB::Aowow()->selectRow('SELECT * FROM ?_totemcategory WHERE id = ?d', $tId)) - $infobox[] = Lang::item('tool').Lang::main('colon').'[url=?items&filter=cr=91;crs='.$tId.';crv=0]'.Util::localizedString($tName, 'name').'[/url]'; + if ($tName = DB::Aowow()->selectRow('SELECT * FROM ::totemcategory WHERE `id` = %i', $tId)) + $infobox[] = Lang::item('tool').'[url=?items&filter=cr=91;crs='.$tId.';crv=0]'.Util::localizedString($tName, 'name').'[/url]'; // extendedCost - if (!empty($this->subject->getExtendedCost([], $_reqRating)[$this->subject->id])) + if (!empty($this->subject->getExtendedCost([], $_reqRating)[$this->typeId])) { - $vendors = $this->subject->getExtendedCost()[$this->subject->id]; + $vendors = $this->subject->getExtendedCost()[$this->typeId]; $stack = $this->subject->getField('buyCount'); - $each = $this->subject->getField('stackable') > 1 ? '[color=q0] ('.Lang::item('each').')[/color]' : null; + $divisor = $stack; + $each = ''; $handled = []; $costList = []; foreach ($vendors as $npcId => $entries) @@ -224,29 +176,52 @@ class ItemPage extends genericPage if ($c < 0) // currency items (and honor or arena) { - $currency[] = -$c.','.($qty / $stack); + if (is_float($qty / $stack)) + $divisor = 1; + + $currency[] = [-$c, $qty]; $this->extendGlobalIds(Type::CURRENCY, -$c); } else if ($c > 0) // plain items (item1,count1,item2,count2,...) { - $tokens[$c] = $c.','.($qty / $stack); + if (is_float($qty / $stack)) + $divisor = 1; + + $tokens[] = [$c, $qty]; $this->extendGlobalIds(Type::ITEM, $c); } } // display every cost-combination only once - if (in_array(md5(serialize($data)), $handled)) + $hash = md5(serialize($data)); + if (in_array($hash, $handled)) continue; - $handled[] = md5(serialize($data)); + $handled[] = $hash; - $cost = isset($data[0]) ? '[money='.($data[0] / $stack) : '[money'; + if (isset($data[0])) + { + if (is_float($data[0] / $stack)) + $divisor = 1; + + $cost = '[money='.($data[0] / $divisor); + } + else + $cost = '[money'; + + $stringify = fn(&$x) => $x = $x[0] . ',' . ($x[1] / $divisor); if ($tokens) + { + array_walk($tokens, $stringify); $cost .= ' items='.implode(',', $tokens); + } if ($currency) + { + array_walk($currency, $stringify); $cost .= ' currency='.implode(',', $currency); + } $cost .= ']'; @@ -254,21 +229,26 @@ class ItemPage extends genericPage } } + if ($stack > 1 && $divisor > 1) + $each = '[color=q0] ('.Lang::item('each').')[/color]'; + else if ($stack > 1) + $each = '[color=q0] ('.$stack.')[/color]'; + if (count($costList) == 1) $infobox[] = Lang::item('cost').Lang::main('colon').$costList[0].$each; else if (count($costList) > 1) $infobox[] = Lang::item('cost').$each.Lang::main('colon').'[ul][li]'.implode('[/li][li]', $costList).'[/li][/ul]'; - if ($_reqRating) + if ($_reqRating && $_reqRating[0]) { $text = str_replace('
', ' ', Lang::item('reqRating', $_reqRating[1], [$_reqRating[0]])); - $infobox[] = Lang::breakTextClean($text, 30, false); + $infobox[] = Lang::breakTextClean($text, 30, Lang::FMT_MARKUP); } } // repair cost if ($_ = $this->subject->getField('repairPrice')) - $infobox[] = Lang::item('repairCost').Lang::main('colon').'[money='.$_.']'; + $infobox[] = Lang::item('repairCost').'[money='.$_.']'; // avg auction buyout if (in_array($this->subject->getField('bonding'), [0, 2, 3])) @@ -278,7 +258,7 @@ class ItemPage extends genericPage // avg money contained if ($_flags & ITEM_FLAG_OPENABLE) if ($_ = intVal(($this->subject->getField('minMoneyLoot') + $this->subject->getField('maxMoneyLoot')) / 2)) - $infobox[] = Lang::item('worth').Lang::main('colon').'[tooltip=tooltip_avgmoneycontained][money='.$_.'][/tooltip]'; + $infobox[] = Lang::item('worth').'[tooltip=tooltip_avgmoneycontained][money='.$_.'][/tooltip]'; // if it goes into a slot it may be disenchanted if ($_slot && $_class != ITEM_CLASS_CONTAINER) @@ -339,48 +319,55 @@ class ItemPage extends genericPage if ($_bagFamily & 0x0100) $infobox[] = Lang::item('atKeyring'); + // completion row added by InfoboxMarkup + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + $hasCompletion = !($this->subject->getField('cuFlags') & CUSTOM_EXCLUDE_FOR_LISTVIEW) && ($_class == ITEM_CLASS_RECIPE || ($_class == ITEM_CLASS_MISC && in_array($_subClass, [2, 5, -7]))); + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0', $hasCompletion); + + /****************/ /* Main Content */ /****************/ - $_cu = in_array($_class, [ITEM_CLASS_WEAPON, ITEM_CLASS_ARMOR]) || $this->subject->getField('gemEnchantmentId'); + if ($canBeWeighted = in_array($_class, [ITEM_CLASS_WEAPON, ITEM_CLASS_ARMOR, ITEM_CLASS_GEM])) + $this->addDataLoader('weight-presets'); // pageText - $pageText = []; - if ($this->pageText = Game::getPageText($this->subject->getField('pageTextId'))) - { + if ($this->book = Game::getBook($this->subject->getField('pageTextId'))) $this->addScript( - [JS_FILE, 'Book.js'], - [CSS_FILE, 'Book.css'] + [SC_JS_FILE, 'js/Book.js'], + [SC_CSS_FILE, 'css/Book.css'] ); - } - $this->headIcons = [$this->subject->getField('iconString', true, true), $this->subject->getField('stackable')]; - $this->infobox = $infobox ? '[ul][li]'.implode('[/li][li]', $infobox).'[/li][/ul]' : null; - $this->tooltip = $this->subject->renderTooltip(true); + $this->tooltip = [$this->subject->getField('iconString'), $this->subject->getField('stackable'), false]; $this->redButtons = array( BUTTON_WOWHEAD => true, - BUTTON_VIEW3D => in_array($_slot, $_visSlots) && $_model ? ['displayId' => $this->subject->getField('displayId'), 'slot' => $_slot, 'type' => Type::ITEM, 'typeId' => $this->typeId] : false, - BUTTON_COMPARE => $_cu, + BUTTON_VIEW3D => $this->subject->isDisplayable() ? ['displayId' => $_displayId, 'slot' => $_slot, 'type' => Type::ITEM, 'typeId' => $this->typeId] : false, + BUTTON_COMPARE => $canBeWeighted, BUTTON_EQUIP => in_array($_class, [ITEM_CLASS_WEAPON, ITEM_CLASS_ARMOR]), - BUTTON_UPGRADE => ($_cu ? ['class' => $_class, 'slot' => $_slot] : false), + BUTTON_UPGRADE => $canBeWeighted ? ['class' => $_class, 'slot' => $_slot] : false, BUTTON_LINKS => array( 'linkColor' => 'ff'.Game::$rarityColorStings[$this->subject->getField('quality')], 'linkId' => 'item:'.$this->typeId.':0:0:0:0:0:0:0:0', - 'linkName' => $this->name, + 'linkName' => Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_RAW), 'type' => $this->type, 'typeId' => $this->typeId ) ); // availablility - $this->unavailable = $this->subject->getField('cuFlags') & CUSTOM_UNAVAILABLE; + $this->unavailable = !!($this->subject->getField('cuFlags') & CUSTOM_UNAVAILABLE); // subItems $this->subject->initSubItems(); if (!empty($this->subject->subItems[$this->typeId])) { - uaSort($this->subject->subItems[$this->typeId], function($a, $b) { return strcmp($a['name'], $b['name']); }); + uaSort($this->subject->subItems[$this->typeId], fn($a, $b) => $a['name'] <=> $b['name']); $this->subItems = array( 'data' => array_values($this->subject->subItems[$this->typeId]), 'randIds' => array_keys($this->subject->subItems[$this->typeId]), @@ -388,6 +375,8 @@ class ItemPage extends genericPage ); // merge identical stats and names for normal users (e.g. spellPower of a specific school became general spellPower with 3.0) + // see: https://web.archive.org/web/20101118041612/wowhead.com/item=11946 + // stats should also be merged if only the keys are the same, resulting in "+(8 - 9) Spirit" etc. if (!User::isInGroup(U_GROUP_EMPLOYEE)) { @@ -398,8 +387,8 @@ class ItemPage extends genericPage if ($prev['jsonequip'] == $cur['jsonequip'] && $prev['name'] == $cur['name']) { $prev['chance'] += $cur['chance']; - array_splice($this->subItems['data'], $i , 1); - array_splice($this->subItems['randIds'], $i , 1); + array_splice($this->subItems['data'], $i, 1); + array_splice($this->subItems['randIds'], $i, 1); $i = 1; } } @@ -407,29 +396,31 @@ class ItemPage extends genericPage } // factionchange-equivalent - if ($pendant = DB::World()->selectCell('SELECT IF(horde_id = ?d, alliance_id, -horde_id) FROM player_factionchange_items WHERE alliance_id = ?d OR horde_id = ?d', $this->typeId, $this->typeId, $this->typeId)) + if ($pendant = DB::World()->selectCell('SELECT IF(`horde_id` = %i, `alliance_id`, -`horde_id`) FROM player_factionchange_items WHERE `alliance_id` = %i OR `horde_id` = %i', $this->typeId, $this->typeId, $this->typeId)) { $altItem = new ItemList(array(['id', abs($pendant)])); if (!$altItem->error) { - $this->transfer = sprintf( - Lang::item('_transfer'), + $this->transfer = Lang::item('_transfer', [ $altItem->id, $altItem->getField('quality'), - $altItem->getField('iconString', true, true), + $altItem->getField('iconString'), $altItem->getField('name', true), $pendant > 0 ? 'alliance' : 'horde', - $pendant > 0 ? Lang::game('si', 1) : Lang::game('si', 2) - ); + $pendant > 0 ? Lang::game('si', SIDE_ALLIANCE) : Lang::game('si', SIDE_HORDE) + ]); } } + /**************/ /* Extra Tabs */ /**************/ + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + // tab: createdBy (perfect item specific) - if ($perfItem = DB::World()->select('SELECT *, spellId AS ARRAY_KEY FROM skill_perfect_item_template WHERE perfectItemType = ?d', $this->typeId)) + if ($perfItem = DB::World()->selectAssoc('SELECT *, `spellId` AS ARRAY_KEY FROM skill_perfect_item_template WHERE `perfectItemType` = %i', $this->typeId)) { $perfSpells = new SpellList(array(['id', array_column($perfItem, 'spellId')])); if (!$perfSpells->error) @@ -440,145 +431,176 @@ class ItemPage extends genericPage foreach ($lvData as $sId => &$data) { $data['percent'] = $perfItem[$sId]['perfectCreateChance']; - $data['condition'][0][$this->typeId] = [[[CND_SPELL, $perfItem[$sId]['requiredSpecialization']]]]; - $this->extendGlobalIDs(Type::SPELL, $perfItem[$sId]['requiredSpecialization']); + if (Conditions::extendListviewRow($data, Conditions::SRC_NONE, $this->typeId, [Conditions::SPELL, $perfItem[$sId]['requiredSpecialization']])) + $this->extendGlobalIDs(Type::SPELL, $perfItem[$sId]['requiredSpecialization']); } - $this->lvTabs[] = ['spell', array( - 'data' => array_values($lvData), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lvData, 'name' => '$LANG.tab_createdby', 'id' => 'created-by', // should by exclusive with created-by from spell_loot 'extraCols' => ['$Listview.extraCols.percent', '$Listview.extraCols.condition'] - )]; + ), SpellList::$brickFile)); } } // tabs: this item is contained in.. - $lootTabs = new Loot(); + $lootTabs = new LootByItem($this->typeId); $createdBy = []; - if ($lootTabs->getByItem($this->typeId)) + if ($lootTabs->getByItem()) { $this->extendGlobalData($lootTabs->jsGlobals); - foreach ($lootTabs->iterate() as $idx => [$file, $tabData]) + foreach ($lootTabs->iterate() as $idx => [$template, $tabData]) { if (!$tabData['data']) continue; - if ($idx == 16) + if ($idx == LootByItem::SPELL_CREATED) $createdBy = array_column($tabData['data'], 'id'); - $this->lvTabs[] = [$file, $tabData]; + if ($idx == LootByItem::ITEM_DISENCHANTED) + $tabData['note'] = sprintf(Util::$filterResultString, '?items&filter=cr=163;crs='.$this->typeId.';crv=0'); + + if ($idx == LootByItem::NPC_DROPPED && $this->subject->getSources($s, $sm) && $s[0] == SRC_DROP && isset($sm[0]['dd'])) + $tabData['note'] = match($sm[0]['dd']) + { + -1 => '$LANG.lvnote_itemdropsinnormalonly', + -2 => '$LANG.lvnote_itemdropsinheroiconly', + -3 => '$LANG.lvnote_itemdropsinnormalheroic', + 1 => '$LANG.lvnote_itemdropsinnormal10only', + 2 => '$LANG.lvnote_itemdropsinnormal25only', + 3 => '$LANG.lvnote_itemdropsinheroic10only', + 4 => '$LANG.lvnote_itemdropsinheroic25only', + default => null + }; + + if ($idx == LootByItem::OBJECT_FISHED && !$this->map) + { + $nodeIds = array_map(fn($x) => $x['id'], $tabData['data']); + $fishedIn = new GameObjectList(array(['id', $nodeIds])); + if (!$fishedIn->error) + { + // show mapper for fishing locations + if ($nodeSpawns = $fishedIn->getSpawns(SPAWNINFO_FULL, true, true, true, true)) + { + $this->map = array( + ['parent' => 'mapper-generic'], // Mapper + $nodeSpawns, // mapperData + null, // ShowOnMap + [Lang::item('fishedIn')], // foundIn + Lang::item('fishingLoc') // title + ); + foreach ($nodeSpawns as $areaId => $_) + $this->map[3][$areaId] = ZoneList::getName($areaId); + } + } + } + + if ($template == 'npc' || $template == 'object') + $this->addDataLoader('zones'); + + $this->lvTabs->addListviewTab(new Listview($tabData, $template)); } } // tabs: this item contains.. $sourceFor = array( - [LOOT_ITEM, $this->subject->id, '$LANG.tab_contains', 'contains', ['$Listview.extraCols.percent'], [] , []], - [LOOT_PROSPECTING, $this->subject->id, '$LANG.tab_prospecting', 'prospecting', ['$Listview.extraCols.percent'], ['side', 'slot', 'reqlevel'], []], - [LOOT_MILLING, $this->subject->id, '$LANG.tab_milling', 'milling', ['$Listview.extraCols.percent'], ['side', 'slot', 'reqlevel'], []], - [LOOT_DISENCHANT, $this->subject->getField('disenchantId'), '$LANG.tab_disenchanting', 'disenchanting', ['$Listview.extraCols.percent'], ['side', 'slot', 'reqlevel'], []] + [Loot::ITEM, $this->typeId, '$LANG.tab_contains', 'contains', ['$Listview.extraCols.percent'], [] ], + [Loot::PROSPECTING, $this->typeId, '$LANG.tab_prospecting', 'prospecting', ['$Listview.extraCols.percent'], ['side', 'slot', 'reqlevel']], + [Loot::MILLING, $this->typeId, '$LANG.tab_milling', 'milling', ['$Listview.extraCols.percent'], ['side', 'slot', 'reqlevel']], + [Loot::DISENCHANT, $this->subject->getField('disenchantId'), '$LANG.tab_disenchanting', 'disenchanting', ['$Listview.extraCols.percent'], ['side', 'slot', 'reqlevel']] ); - $reqQuest = []; - foreach ($sourceFor as $sf) + foreach ($sourceFor as [$lootTemplate, $lootId, $tabName, $tabId, $extraCols, $hiddenCols]) { - $lootTab = new Loot(); - if ($lootTab->getByContainer($sf[0], $sf[1])) + $lootTab = new LootByContainer(); + if ($lootTab->getByContainer($lootTemplate, [$lootId])) { $this->extendGlobalData($lootTab->jsGlobals); - $sf[4] = array_merge($sf[4], $lootTab->extraCols); - - foreach ($lootTab->iterate() as $lv) - { - if (!$lv['quest']) - continue; - - $sf[4] = array_merge($sf[4], ['$Listview.extraCols.condition']); - - $reqQuest[$lv['id']] = 0; - - $lv['condition'][0][$this->typeId][] = [[CND_QUESTTAKEN, &$reqQuest[$lv['id']]]]; - } + $extraCols = array_merge($extraCols, $lootTab->extraCols); $tabData = array( - 'data' => array_values($lootTab->getResult()), - 'name' => $sf[2], - 'id' => $sf[3], + 'data' => $lootTab->getResult(), + 'name' => $tabName, + 'id' => $tabId, + 'computeDataFunc' => '$Listview.funcBox.initLootTable' ); - if ($sf[4]) - $tabData['extraCols'] = array_unique($sf[4]); + if ($extraCols) + $tabData['extraCols'] = array_values(array_unique($extraCols)); - if ($sf[5]) - $tabData['hiddenCols'] = array_unique($sf[5]); + if ($hiddenCols) + $tabData['hiddenCols'] = array_unique($hiddenCols); - if ($sf[6]) - $tabData['visibleCols'] = array_unique($sf[6]); - - $this->lvTabs[] = ['item', $tabData]; + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); } } - if ($reqIds = array_keys($reqQuest)) // apply quest-conditions as back-reference + // append spell loot mimicking item opening + if ($this->subject->getField('spellTrigger1') === SPELL_TRIGGER_USE && ($s = $this->subject->getField('spellId1'))) { - $conditions = array( - 'OR', - ['reqSourceItemId1', $reqIds], ['reqSourceItemId2', $reqIds], - ['reqSourceItemId3', $reqIds], ['reqSourceItemId4', $reqIds], - ['reqItemId1', $reqIds], ['reqItemId2', $reqIds], ['reqItemId3', $reqIds], - ['reqItemId4', $reqIds], ['reqItemId5', $reqIds], ['reqItemId6', $reqIds] - ); - - $reqQuests = new QuestList($conditions); - $reqQuests->getJSGlobals(GLOBALINFO_SELF); - - foreach ($reqQuests->iterate() as $qId => $__) + if (($spellLoot = new LootByContainer())->getByContainer(Loot::SPELL, [$s])) { - if (empty($reqQuests->requires[$qId][Type::ITEM])) - continue; + $this->extendGlobalData($spellLoot->jsGlobals); - foreach ($reqIds as $rId) - if (in_array($rId, $reqQuests->requires[$qId][Type::ITEM])) - $reqQuest[$rId] = $reqQuests->id; + $makeNew = true; + foreach ($this->lvTabs->iterate() as $k => $tab) + { + if ($tab->getId() != 'contains') + continue; + + $tab->appendData($spellLoot->getResult()); + $makeNew = false; + break; + } + + if ($makeNew) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $spellLoot->getResult(), + 'name' => '$LANG.tab_contains', + 'id' => 'contains', + 'computeDataFunc' => '$Listview.funcBox.initLootTable', + 'extraCols' => array_merge(['$Listview.extraCols.percent'], $spellLoot->extraCols) + ), ItemList::$brickFile)); } } // tab: container can contain if ($this->subject->getField('slots') > 0) { - $contains = new ItemList(array(['bagFamily', $_bagFamily, '&'], ['slots', 1, '<'], CFG_SQL_LIMIT_NONE)); + $contains = new ItemList(array(['bagFamily', $_bagFamily, '&'], ['slots', 1, '<'])); if (!$contains->error) { $this->extendGlobalData($contains->getJSGlobals(GLOBALINFO_SELF)); $hCols = ['side']; - if (!$contains->hasSetFields(['slot'])) + if (!$contains->hasSetFields('slot')) $hCols[] = 'slot'; - $this->lvTabs[] = ['item', array( - 'data' => array_values($contains->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $contains->getListviewData(), 'name' => '$LANG.tab_cancontain', 'id' => 'can-contain', 'hiddenCols' => $hCols - )]; + ), ItemList::$brickFile)); } } // tab: can be contained in (except keys) else if ($_bagFamily != 0x0100) { - $contains = new ItemList(array(['bagFamily', $_bagFamily, '&'], ['slots', 0, '>'], CFG_SQL_LIMIT_NONE)); + $contains = new ItemList(array(['bagFamily', $_bagFamily, '&'], ['slots', 0, '>'])); if (!$contains->error) { $this->extendGlobalData($contains->getJSGlobals(GLOBALINFO_SELF)); - $this->lvTabs[] = ['item', array( - 'data' => array_values($contains->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $contains->getListviewData(), 'name' => '$LANG.tab_canbeplacedin', 'id' => 'can-be-placed-in', 'hiddenCols' => ['side'] - )]; + ), ItemList::$brickFile)); } } @@ -594,21 +616,21 @@ class ItemPage extends genericPage $this->extendGlobalData($criteriaOf->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); $tabData = array( - 'data' => array_values($criteriaOf->getListviewData()), + 'data' => $criteriaOf->getListviewData(), 'name' => '$LANG.tab_criteriaof', 'id' => 'criteria-of', 'visibleCols' => ['category'] ); - if (!$criteriaOf->hasSetFields(['reward_loc0'])) + if (!$criteriaOf->hasSetFields('reward_loc0')) $tabData['hiddenCols'] = ['rewards']; - $this->lvTabs[] = ['achievement', $tabData]; + $this->lvTabs->addListviewTab(new Listview($tabData, AchievementList::$brickFile)); } // tab: reagent for $conditions = array( - 'OR', + DB::OR, ['reagent1', $this->typeId], ['reagent2', $this->typeId], ['reagent3', $this->typeId], ['reagent4', $this->typeId], ['reagent5', $this->typeId], ['reagent6', $this->typeId], ['reagent7', $this->typeId], ['reagent8', $this->typeId] ); @@ -618,20 +640,22 @@ class ItemPage extends genericPage { $this->extendGlobalData($reagent->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); - $this->lvTabs[] = ['spell', array( - 'data' => array_values($reagent->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $reagent->getListviewData(), 'name' => '$LANG.tab_reagentfor', 'id' => 'reagent-for', 'visibleCols' => ['reagents'] - )]; + ), SpellList::$brickFile)); } // tab: unlocks (object or item) $lockIds = DB::Aowow()->selectCol( - 'SELECT id FROM ?_lock WHERE (type1 = 1 AND properties1 = ?d) OR - (type2 = 1 AND properties2 = ?d) OR (type3 = 1 AND properties3 = ?d) OR - (type4 = 1 AND properties4 = ?d) OR (type5 = 1 AND properties5 = ?d)', - $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId + 'SELECT `id` FROM ::lock WHERE (`type1` = %i AND `properties1` = %i) OR + (`type2` = %i AND `properties2` = %i) OR (`type3` = %i AND `properties3` = %i) OR + (`type4` = %i AND `properties4` = %i) OR (`type5` = %i AND `properties5` = %i)', + LOCK_TYPE_ITEM, $this->typeId, LOCK_TYPE_ITEM, $this->typeId, + LOCK_TYPE_ITEM, $this->typeId, LOCK_TYPE_ITEM, $this->typeId, + LOCK_TYPE_ITEM, $this->typeId ); if ($lockIds) @@ -640,11 +664,12 @@ class ItemPage extends genericPage $lockedObj = new GameObjectList(array(['lockId', $lockIds])); if (!$lockedObj->error) { - $this->lvTabs[] = ['object', array( - 'data' => array_values($lockedObj->getListviewData()), + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lockedObj->getListviewData(), 'name' => '$LANG.tab_unlocks', - 'id' => 'unlocks-object' - )]; + 'id' => 'unlocks-object', + ), GameObjectList::$brickFile)); } // items (generally unused. It's the spell on the item, that unlocks stuff) @@ -653,48 +678,14 @@ class ItemPage extends genericPage { $this->extendGlobalData($lockedItm->getJSGlobals(GLOBALINFO_SELF)); - $this->lvTabs[] = ['item', array( - 'data' => array_values($lockedItm->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lockedItm->getListviewData(), 'name' => '$LANG.tab_unlocks', 'id' => 'unlocks-item' - )]; + ), ItemList::$brickFile)); } } - // tab: see also - $conditions = array( - ['id', $this->typeId, '!'], - [ - 'OR', - ['name_loc'.User::$localeId, $this->subject->getField('name', true)], - [ - 'AND', - ['class', $_class], - ['subClass', $_subClass], - ['slot', $_slot], - ['itemLevel', $_ilvl - 15, '>'], - ['itemLevel', $_ilvl + 15, '<'], - ['quality', $this->subject->getField('quality')], - ['requiredClass', $this->subject->getField('requiredClass') ?: -1] // todo: fix db data in setup and not on fetch - ] - ] - ); - - if ($_ = $this->subject->getField('itemset')) - $conditions[1][] = ['AND', ['slot', $_slot], ['itemset', $_]]; - - $saItems = new ItemList($conditions); - if (!$saItems->error) - { - $this->extendGlobalData($saItems->getJSGlobals(GLOBALINFO_SELF)); - - $this->lvTabs[] = ['item', array( - 'data' => array_values($saItems->getListviewData()), - 'name' => '$LANG.tab_seealso', - 'id' => 'see-also' - )]; - } - // tab: starts (quest) if ($qId = $this->subject->getField('startQuest')) { @@ -703,17 +694,17 @@ class ItemPage extends genericPage { $this->extendGlobalData($starts->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); - $this->lvTabs[] = ['quest', array( - 'data' => array_values($starts->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $starts->getListviewData(), 'name' => '$LANG.tab_starts', 'id' => 'starts-quest' - )]; + ), QuestList::$brickFile)); } } // tab: objective of (quest) $conditions = array( - 'OR', + DB::OR, ['reqItemId1', $this->typeId], ['reqItemId2', $this->typeId], ['reqItemId3', $this->typeId], ['reqItemId4', $this->typeId], ['reqItemId5', $this->typeId], ['reqItemId6', $this->typeId] ); @@ -722,16 +713,16 @@ class ItemPage extends genericPage { $this->extendGlobalData($objective->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); - $this->lvTabs[] = ['quest', array( - 'data' => array_values($objective->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $objective->getListviewData(), 'name' => '$LANG.tab_objectiveof', 'id' => 'objective-of-quest' - )]; + ), QuestList::$brickFile)); } // tab: provided for (quest) $conditions = array( - 'OR', ['sourceItemId', $this->typeId], + DB::OR, ['sourceItemId', $this->typeId], ['reqSourceItemId1', $this->typeId], ['reqSourceItemId2', $this->typeId], ['reqSourceItemId3', $this->typeId], ['reqSourceItemId4', $this->typeId] ); @@ -740,44 +731,41 @@ class ItemPage extends genericPage { $this->extendGlobalData($provided->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); - $this->lvTabs[] = ['quest', array( - 'data' => array_values($provided->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $provided->getListviewData(), 'name' => '$LANG.tab_providedfor', 'id' => 'provided-for-quest' - )]; - } - - // tab: same model as - // todo (low): should also work for creatures summoned by item - if (($model = $this->subject->getField('model')) && $_slot) - { - $sameModel = new ItemList(array(['model', $model], ['id', $this->typeId, '!'], ['slot', $_slot])); - if (!$sameModel->error) - { - $this->extendGlobalData($sameModel->getJSGlobals(GLOBALINFO_SELF)); - - $this->lvTabs[] = ['genericmodel', array( - 'data' => array_values($sameModel->getListviewData(ITEMINFO_MODEL)), - 'name' => '$LANG.tab_samemodelas', - 'id' => 'same-model-as', - 'genericlinktype' => 'item' - )]; - } + ), QuestList::$brickFile)); } // tab: sold by - if (!empty($this->subject->getExtendedCost()[$this->subject->id])) + if (!empty($this->subject->getExtendedCost()[$this->typeId])) { - $vendors = $this->subject->getExtendedCost()[$this->subject->id]; + $vendors = $this->subject->getExtendedCost()[$this->typeId]; $soldBy = new CreatureList(array(['id', array_keys($vendors)])); if (!$soldBy->error) { + // show mapper for vendors + if ($vendorSpawns = $soldBy->getSpawns(SPAWNINFO_FULL, true, true, true, true)) + { + $this->map = array( + ['parent' => 'mapper-generic'], // Mapper + $vendorSpawns, // mapperData + null, // ShowOnMap + [Lang::item('purchasedIn')], // foundIn + Lang::item('vendorLoc') // title + ); + foreach ($vendorSpawns as $areaId => $_) + $this->map[3][$areaId] = ZoneList::getName($areaId); + } + $sbData = $soldBy->getListviewData(); $this->extendGlobalData($soldBy->getJSGlobals(GLOBALINFO_SELF)); $extraCols = ['$Listview.extraCols.stock', "\$Listview.funcBox.createSimpleCol('stack', 'stack', '10%', 'stack')", '$Listview.extraCols.cost']; - $holidays = []; + $cnd = new Conditions(); + $cnd->getBySource(Conditions::SRC_NPC_VENDOR, entry: $this->typeId)->prepare(); foreach ($sbData as $k => &$row) { $currency = []; @@ -799,13 +787,7 @@ class ItemPage extends genericPage $row['cost'] = [empty($vendors[$k][0][0]) ? 0 : $vendors[$k][0][0]]; if ($e = $vendors[$k][0]['event']) - { - if (count($extraCols) == 3) - $extraCols[] = '$Listview.extraCols.condition'; - - $this->extendGlobalIds(Type::WORLDEVENT, $e); - $row['condition'][0][$this->typeId][] = [[CND_ACTIVE_EVENT, $e]]; - } + $cnd->addExternalCondition(Conditions::SRC_NONE, $k.':'.$this->typeId, [Conditions::ACTIVE_EVENT, $e]); if ($currency || $tokens) // fill idx:3 if required $row['cost'][] = $currency; @@ -823,37 +805,41 @@ class ItemPage extends genericPage $row['stack'] = $x; } + if ($cnd->toListviewColumn($sbData, $extraCols, 'id', $this->typeId)) + $this->extendGlobalData($cnd->getJsGlobals()); - $this->lvTabs[] = ['creature', array( - 'data' => array_values($sbData), + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sbData, 'name' => '$LANG.tab_soldby', 'id' => 'sold-by-npc', 'extraCols' => $extraCols, 'hiddenCols' => ['level', 'type'] - )]; + ), CreatureList::$brickFile)); } } // tab: currency for // some minor trickery: get arenaPoints(43307) and honorPoints(43308) directly + $n = $w = null; if ($this->typeId == 43307) { $n = '?items&filter=cr=145;crs=1;crv=0'; - $w = 'reqArenaPoints > 0'; + $w = '`reqArenaPoints` > 0'; } else if ($this->typeId == 43308) { $n = '?items&filter=cr=144;crs=1;crv=0'; - $w = 'reqHonorPoints > 0'; + $w = '`reqHonorPoints` > 0'; } else - { - $n = in_array($this->typeId, [42, 61, 81, 241, 121, 122, 123, 125, 126, 161, 201, 101, 102, 221, 301, 341]) ? '?items&filter=cr=158;crs='.$this->typeId.';crv=0' : null; - $w = 'reqItemId1 = '.$this->typeId.' OR reqItemId2 = '.$this->typeId.' OR reqItemId3 = '.$this->typeId.' OR reqItemId4 = '.$this->typeId.' OR reqItemId5 = '.$this->typeId; - } + $w = '`reqItemId1` = '.$this->typeId.' OR `reqItemId2` = '.$this->typeId.' OR `reqItemId3` = '.$this->typeId.' OR `reqItemId4` = '.$this->typeId.' OR `reqItemId5` = '.$this->typeId; - $xCosts = DB::Aowow()->selectCol('SELECT id FROM ?_itemextendedcost WHERE '.$w); - $boughtBy = $xCosts ? DB::World()->selectCol('SELECT item FROM npc_vendor WHERE extendedCost IN (?a) UNION SELECT item FROM game_event_npc_vendor WHERE extendedCost IN (?a)', $xCosts, $xCosts) : null; + if (!$n && !is_null(ItemListFilter::getCriteriaIndex(158, $this->typeId))) + $n = '?items&filter=cr=158;crs='.$this->typeId.';crv=0'; + + $xCosts = DB::Aowow()->selectCol('SELECT `id` FROM ::itemextendedcost WHERE '.$w); + $boughtBy = $xCosts ? DB::World()->selectCol('SELECT `item` FROM npc_vendor WHERE `extendedCost` IN %in UNION SELECT `item` FROM game_event_npc_vendor WHERE `extendedCost` IN %in', $xCosts, $xCosts) : null; if ($boughtBy) { $boughtBy = new ItemList(array(['id', $boughtBy])); @@ -863,16 +849,16 @@ class ItemPage extends genericPage $filter = $iCur->error ? [Type::ITEM => $this->typeId] : [Type::CURRENCY => $iCur->id]; $tabData = array( - 'data' => array_values($boughtBy->getListviewData(ITEMINFO_VENDOR, $filter)), + 'data' => $boughtBy->getListviewData(ITEMINFO_VENDOR, $filter), 'name' => '$LANG.tab_currencyfor', 'id' => 'currency-for', - 'extraCols' => ["\$Listview.funcBox.createSimpleCol('stack', 'stack', '10%', 'stack')", '$Listview.extraCols.cost'], + 'extraCols' => ["\$Listview.funcBox.createSimpleCol('stack', 'stack', '10%', 'stack')", '$Listview.extraCols.cost'] ); - if ($boughtBy->getMatches() > CFG_SQL_LIMIT_DEFAULT && $n) + if ($n) $tabData['note'] = sprintf(Util::$filterResultString, $n); - $this->lvTabs[] = ['item', $tabData]; + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); $this->extendGlobalData($boughtBy->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); } @@ -882,9 +868,9 @@ class ItemPage extends genericPage $ids = $indirect = []; for ($i = 1; $i < 6; $i++) { - if ($this->subject->getField('spellTrigger'.$i) == 6) + if ($this->subject->getField('spellTrigger'.$i) == SPELL_TRIGGER_LEARN) $ids[] = $this->subject->getField('spellId'.$i); - else if ($this->subject->getField('spellTrigger'.$i) == 0 && $this->subject->getField('spellId'.$i) > 0) + else if ($this->subject->getField('spellTrigger'.$i) == SPELL_TRIGGER_USE && $this->subject->getField('spellId'.$i) > 0) $indirect[] = $this->subject->getField('spellId'.$i); } @@ -908,15 +894,67 @@ class ItemPage extends genericPage $this->extendGlobalData($taughtSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); $visCols = ['level', 'schools']; - if ($taughtSpells->hasSetFields(['reagent1'])) + if ($taughtSpells->hasSetFields('reagent1', 'reagent2', 'reagent3', 'reagent4', 'reagent5', 'reagent6', 'reagent7', 'reagent8')) $visCols[] = 'reagents'; - $this->lvTabs[] = ['spell', array( - 'data' => array_values($taughtSpells->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $taughtSpells->getListviewData(), 'name' => '$LANG.tab_teaches', 'id' => 'teaches', 'visibleCols' => $visCols - )]; + ), SpellList::$brickFile)); + } + } + + // tab: see also + $conditions = array( + ['id', $this->typeId, '!'], + [ + DB::OR, + ['name_loc'.Lang::getLocale()->value, $this->subject->getField('name', true)], + [ + DB::AND, + ['class', $_class], + ['subClass', $_subClass], + ['slot', $_slot], + ['itemLevel', $_ilvl - 15, '>'], + ['itemLevel', $_ilvl + 15, '<'], + ['quality', $this->subject->getField('quality')], + ['requiredClass', $this->subject->getField('requiredClass')] + ] + ] + ); + + if ($_ = $this->subject->getField('itemset')) + $conditions[1][] = [DB::AND, ['slot', $_slot], ['itemset', $_]]; + + $saItems = new ItemList($conditions); + if (!$saItems->error) + { + $this->extendGlobalData($saItems->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $saItems->getListviewData(), + 'name' => '$LANG.tab_seealso', + 'id' => 'see-also' + ), ItemList::$brickFile)); + } + + // tab: same model as + // todo (low): should also work for creatures summoned by item + if (($model = $this->subject->getField('model')) && $_slot) + { + $sameModel = new ItemList(array(['model', $model], ['id', $this->typeId, '!'], ['slot', $_slot])); + if (!$sameModel->error) + { + $this->extendGlobalData($sameModel->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sameModel->getListviewData(ITEMINFO_MODEL), + 'name' => '$LANG.tab_samemodelas', + 'id' => 'same-model-as', + 'genericlinktype' => 'item' + ), 'genericmodel')); } } @@ -934,7 +972,7 @@ class ItemPage extends genericPage $useSpells[] = $this->subject->getField('spellId'.$i); } if ($useSpells) - if ($_ = DB::Aowow()->selectCol('SELECT category FROM ?_spell WHERE id IN (?a) AND recoveryCategory > 0', $useSpells)) + if ($_ = DB::Aowow()->selectCol('SELECT `category` FROM ::spell WHERE `id` IN %in AND `recoveryCategory` > 0', $useSpells)) $cdCats += $_; if ($cdCats) @@ -942,7 +980,7 @@ class ItemPage extends genericPage $conditions = array( ['id', $this->typeId, '!'], [ - 'OR', + DB::OR, ['spellCategory1', $cdCats], ['spellCategory2', $cdCats], ['spellCategory3', $cdCats], @@ -951,18 +989,18 @@ class ItemPage extends genericPage ] ); - if ($spellsByCat = DB::Aowow()->selectCol('SELECT id FROM ?_spell WHERE category IN (?a)', $cdCats)) + if ($spellsByCat = DB::Aowow()->selectCol('SELECT `id` FROM ::spell WHERE `category` IN %in', $cdCats)) for ($i = 1; $i < 6; $i++) $conditions[1][] = ['spellId'.$i, $spellsByCat]; $cdItems = new ItemList($conditions); if (!$cdItems->error) { - $this->lvTabs[] = ['item', array( - 'data' => array_values($cdItems->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $cdItems->getListviewData(), 'name' => '$LANG.tab_sharedcooldown', 'id' => 'shared-cooldown' - )]; + ), ItemList::$brickFile)); $this->extendGlobalData($cdItems->getJSGlobals(GLOBALINFO_SELF)); } @@ -976,7 +1014,7 @@ class ItemPage extends genericPage if ($this->subject->getField('soundOverrideSubclass') > 0) $scm = (1 << $this->subject->getField('soundOverrideSubclass')); - $soundIds = DB::Aowow()->selectCol('SELECT soundId FROM ?_items_sounds WHERE subClassMask & ?d', $scm); + $soundIds = DB::Aowow()->selectCol('SELECT `soundId` FROM ::items_sounds WHERE `subClassMask` & %i', $scm); } $fields = ['pickUpSoundId', 'dropDownSoundId', 'sheatheSoundId', 'unsheatheSoundId']; @@ -986,7 +1024,7 @@ class ItemPage extends genericPage if ($x = $this->subject->getField('spellVisualId')) { - if ($spellSounds = DB::Aowow()->selectRow('SELECT * FROM ?_spell_sounds WHERE id = ?d', $x)) + if ($spellSounds = DB::Aowow()->selectRow('SELECT * FROM ::spell_sounds WHERE `id` = %i', $x)) { array_shift($spellSounds); // bye 'id'-field foreach ($spellSounds as $ss) @@ -1001,183 +1039,57 @@ class ItemPage extends genericPage if (!$sounds->error) { $this->extendGlobalData($sounds->getJSGlobals(GLOBALINFO_SELF)); - $this->lvTabs[] = ['sound', ['data' => array_values($sounds->getListviewData())]]; + $this->lvTabs->addListviewTab(new Listview(['data' => $sounds->getListviewData()], SoundList::$brickFile)); } } + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::ITEM, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + // // todo - tab: taught by // use var $createdBy to find source of this spell // id: 'taught-by-X', // name: LANG.tab_taughtby + + parent::generate(); } - protected function generateTooltip() + private function followBreadcrumbPath() : array { - $power = new StdClass(); - if (!$this->subject->error) - { - $power->{'name_'.User::$localeString} = $this->subject->getField('name', true, false, $this->enhancedTT); - $power->quality = $this->subject->getField('quality'); - $power->icon = rawurlencode($this->subject->getField('iconString', true, true)); - $power->{'tooltip_'.User::$localeString} = $this->subject->renderTooltip(false, 0, $this->enhancedTT); - } + $c = $this->subject->getField('class'); + $sc = $this->subject->getField('subClass'); + $ssc = $this->subject->getField('subSubClass'); + $slot = $this->subject->getField('slot'); - $itemString = $this->typeId; - if ($this->enhancedTT) - { - foreach ($this->enhancedTT as $k => $val) - $itemString .= $k.(is_array($val) ? implode(',', $val) : $val); - $itemString = "'".$itemString."'"; - } + if ($c == ITEM_CLASS_REAGENT) + return [ITEM_CLASS_MISC, 1]; // misc > reagents - return sprintf($this->powerTpl, $itemString, User::$localeId, Util::toJSON($power, JSON_AOWOW_POWER)); - } + if ($c == ITEM_CLASS_GENERIC || $c == ITEM_CLASS_PERMANENT) + return [ITEM_CLASS_MISC, 4]; // misc > other - protected function generateXML() - { - $root = new SimpleXML(''); + // depths: 1 + $path = [$c]; - if ($this->subject->error) - $root->addChild('error', 'Item not found!'); - else - { - // item root - $xml = $root->addChild('item'); - $xml->addAttribute('id', $this->typeId); + if (in_array($c, [ITEM_CLASS_MONEY, ITEM_CLASS_QUEST, ITEM_CLASS_KEY])) + return $path; - // name - $xml->addChild('name')->addCData($this->subject->getField('name', true)); - // itemlevel - $xml->addChild('level', $this->subject->getField('itemLevel')); - // quality - $xml->addChild('quality', Lang::item('quality', $this->subject->getField('quality')))->addAttribute('id', $this->subject->getField('quality')); - // class - $x = Lang::item('cat', $this->subject->getField('class')); - $xml->addChild('class')->addCData(is_array($x) ? $x[0] : $x)->addAttribute('id', $this->subject->getField('class')); - // subclass - $x = $this->subject->getField('class') == 2 ? Lang::spell('weaponSubClass') : Lang::item('cat', $this->subject->getField('class'), 1); - $xml->addChild('subclass')->addCData(is_array($x) ? (is_array($x[$this->subject->getField('subClass')]) ? $x[$this->subject->getField('subClass')][0] : $x[$this->subject->getField('subClass')]) : null)->addAttribute('id', $this->subject->getField('subClass')); - // icon + displayId - $xml->addChild('icon', $this->subject->getField('iconString', true, true))->addAttribute('displayId', $this->subject->getField('displayId')); - // inventorySlot - $xml->addChild('inventorySlot', Lang::item('inventoryType', $this->subject->getField('slot')))->addAttribute('id', $this->subject->getField('slot')); - // tooltip - $xml->addChild('htmlTooltip')->addCData($this->subject->renderTooltip()); + // depths: 2 + $path[] = $sc; - $this->subject->extendJsonStats(); + // maybe depths: 3 + if ($this->subject->isBodyArmor() && $slot) + $path[] = $slot; + else if (($c == ITEM_CLASS_CONSUMABLE && $sc == ITEM_SUBCLASS_ELIXIR) || $c == ITEM_CLASS_GLYPH) + $path[] = $ssc; - // json - $fields = ['classs', 'displayid', 'dps', 'id', 'level', 'name', 'reqlevel', 'slot', 'slotbak', 'speed', 'subclass']; - $json = []; - foreach ($fields as $f) - { - if (isset($this->subject->json[$this->typeId][$f])) - { - $_ = $this->subject->json[$this->typeId][$f]; - if ($f == 'name') - $_ = (7 - $this->subject->getField('quality')).$_; - - $json[$f] = $_; - } - } - - // itemsource - if ($this->subject->getSources($s, $m)) - { - $json['source'] = $s; - if ($m) - $json['sourcemore'] = $m; - } - - $xml->addChild('json')->addCData(substr(json_encode($json), 1, -1)); - - // jsonEquip missing: avgbuyout - $json = []; - if ($_ = $this->subject->getField('sellPrice')) // sellprice - $json['sellprice'] = $_; - - if ($_ = $this->subject->getField('requiredLevel')) // reqlevel - $json['reqlevel'] = $_; - - if ($_ = $this->subject->getField('requiredSkill')) // reqskill - $json['reqskill'] = $_; - - if ($_ = $this->subject->getField('requiredSkillRank')) // reqskillrank - $json['reqskillrank'] = $_; - - if ($_ = $this->subject->getField('cooldown')) // cooldown - $json['cooldown'] = $_ / 1000; - - foreach ($this->subject->itemMods[$this->typeId] as $mod => $qty) - $json[$mod] = $qty; - - foreach ($this->subject->json[$this->typeId] as $name => $qty) - if (in_array($name, Util::$itemFilter)) - $json[$name] = $qty; - - $xml->addChild('jsonEquip')->addCData(substr(json_encode($json), 1, -1)); - - // jsonUse - if ($onUse = $this->subject->getOnUseStats()) - { - $j = ''; - foreach ($onUse as $idx => $qty) - $j .= ',"'.Game::$itemMods[$idx].'":'.$qty; - - $xml->addChild('jsonUse')->addCData(substr($j, 1)); - } - - // reagents - $cnd = array( - 'OR', - ['AND', ['effect1CreateItemId', $this->typeId], ['OR', ['effect1Id', SpellList::$effects['itemCreate']], ['effect1AuraId', SpellList::$auras['itemCreate']]]], - ['AND', ['effect2CreateItemId', $this->typeId], ['OR', ['effect2Id', SpellList::$effects['itemCreate']], ['effect2AuraId', SpellList::$auras['itemCreate']]]], - ['AND', ['effect3CreateItemId', $this->typeId], ['OR', ['effect3Id', SpellList::$effects['itemCreate']], ['effect3AuraId', SpellList::$auras['itemCreate']]]], - ); - - $spellSource = new SpellList($cnd); - if (!$spellSource->error) - { - $cbNode = $xml->addChild('createdBy'); - - foreach ($spellSource->iterate() as $sId => $__) - { - foreach ($spellSource->canCreateItem() as $idx) - { - if ($spellSource->getField('effect'.$idx.'CreateItemId') != $this->typeId) - continue; - - $splNode = $cbNode->addChild('spell'); - $splNode->addAttribute('id', $sId); - $splNode->addAttribute('name', $spellSource->getField('name', true)); - $splNode->addAttribute('icon', $this->subject->getField('iconString', true, true)); - $splNode->addAttribute('minCount', $spellSource->getField('effect'.$idx.'BasePoints') + 1); - $splNode->addAttribute('maxCount', $spellSource->getField('effect'.$idx.'BasePoints') + $spellSource->getField('effect'.$idx.'DieSides')); - - foreach ($spellSource->getReagentsForCurrent() as $rId => $qty) - { - if ($reagent = $spellSource->relItems->getEntry($rId)) - { - $rgtNode = $splNode->addChild('reagent'); - $rgtNode->addAttribute('id', $rId); - $rgtNode->addAttribute('name', Util::localizedString($reagent, 'name')); - $rgtNode->addAttribute('quality', $reagent['quality']); - $rgtNode->addAttribute('icon', $reagent['iconString']); - $rgtNode->addAttribute('count', $qty[1]); - } - } - - break; - } - } - } - - // link - $xml->addChild('link', HOST_URL.'?item='.$this->subject->id); - } - - return $root->asXML(); + return $path; } } diff --git a/endpoints/item/item_power.php b/endpoints/item/item_power.php new file mode 100644 index 00000000..25617b47 --- /dev/null +++ b/endpoints/item/item_power.php @@ -0,0 +1,70 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']], + 'rand' => ['filter' => FILTER_VALIDATE_INT ], + 'ench' => ['filter' => FILTER_VALIDATE_INT ], + 'gems' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIntArray'] ], + 'sock' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ] + ); + + public function __construct($param) + { + parent::__construct($param); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($param); + + if ($this->_get['rand']) + $this->enhancedTT['r'] = $this->_get['rand']; + if ($this->_get['ench']) + $this->enhancedTT['e'] = $this->_get['ench']; + if ($this->_get['gems']) + $this->enhancedTT['g'] = $this->_get['gems']; + if ($this->_get['sock']) + $this->enhancedTT['s'] = ''; + } + + protected function generate() : void + { + $item = new ItemList(array(['i.id', $this->typeId])); + if ($item->error) + $this->cacheType = CACHE_TYPE_NONE; + else + { + $itemString = $this->typeId; + foreach ($this->enhancedTT as $k => $val) + $itemString .= $k.(is_array($val) ? implode(',', $val) : $val); + + $opts = array( + 'name' => Lang::unescapeUISequences($item->getField('name', true, false, $this->enhancedTT), Lang::FMT_RAW), + 'tooltip' => $item->renderTooltip(enhance: $this->enhancedTT), + 'icon' => $item->getField('iconString'), + 'quality' => $item->getField('quality') + ); + } + + $this->result = new Tooltip(self::POWER_TEMPLATE, $itemString ?? $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/item/item_xml.php b/endpoints/item/item_xml.php new file mode 100644 index 00000000..19997124 --- /dev/null +++ b/endpoints/item/item_xml.php @@ -0,0 +1,230 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + private ItemList $subject; + private string $search = ''; + + public function __construct(string $param) + { + parent::__construct($param); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + // allow lookup by name + if (is_numeric($param)) + $this->typeId = intVal($param); + else + $this->search = urldecode($param); + } + + protected function generate() : void + { + if ($this->search) + $conditions = [['name_loc'.Lang::getLocale()->value, $this->search]]; + else + $conditions = [['i.id', $this->typeId]]; + + $this->subject = new ItemList($conditions); + if ($this->subject->error) + { + $this->cacheType = CACHE_TYPE_NONE; + header('HTTP/1.0 404 Not Found', true, 404); + + $root = new SimpleXML(''); + $root->addChild('error', 'Item not found!'); + $this->result = $root->asXML(); + + return; + } + else + $this->typeId = $this->subject->id; + + $root = new SimpleXML(''); + + // item root + $xml = $root->addChild('item'); + $xml->addAttribute('id', $this->typeId); + + // name + $xml->addChild('name')->addCData($this->subject->getField('name', true)); + // itemlevel + $xml->addChild('level', $this->subject->getField('itemLevel')); + // quality + $xml->addChild('quality', Lang::item('quality', $this->subject->getField('quality')))->addAttribute('id', $this->subject->getField('quality')); + // class + $x = Lang::item('cat', $this->subject->getField('class')); + $xml->addChild('class')->addCData(is_array($x) ? $x[0] : $x)->addAttribute('id', $this->subject->getField('class')); + // subclass + $xml->addChild('subclass')->addCData($this->getSubclass())->addAttribute('id', $this->subject->getField('subClass')); + // icon + displayId + $xml->addChild('icon', $this->subject->getField('iconString'))->addAttribute('displayId', $this->subject->getField('displayId')); + // inventorySlot + $xml->addChild('inventorySlot', Lang::item('inventoryType', $this->subject->getField('slot')))->addAttribute('id', $this->subject->getField('slot')); + // tooltip + $xml->addChild('htmlTooltip')->addCData($this->subject->renderTooltip()); + + $this->subject->extendJsonStats(); + + // json + $fields = ['classs', 'displayid', 'dps', 'id', 'level', 'name', 'reqlevel', 'slot', 'slotbak', 'speed', 'subclass']; + $json = []; + foreach ($fields as $f) + { + if (isset($this->subject->json[$this->typeId][$f])) + { + $_ = $this->subject->json[$this->typeId][$f]; + if ($f == 'name') + $_ = (7 - $this->subject->getField('quality')).$_; + + $json[$f] = $_; + } + } + + // itemsource + if ($this->subject->getSources($s, $sm)) + { + $json['source'] = $s; + if ($sm) + $json['sourcemore'] = $sm; + } + + $xml->addChild('json')->addCData(substr(json_encode($json), 1, -1)); + + // jsonEquip missing: avgbuyout + $json = []; + if ($_ = $this->subject->getField('sellPrice')) // sellprice + $json['sellprice'] = $_; + + if ($_ = $this->subject->getField('requiredLevel')) // reqlevel + $json['reqlevel'] = $_; + + if ($_ = $this->subject->getField('requiredSkill')) // reqskill + $json['reqskill'] = $_; + + if ($_ = $this->subject->getField('requiredSkillRank')) // reqskillrank + $json['reqskillrank'] = $_; + + if ($_ = $this->subject->getField('cooldown')) // cooldown + $json['cooldown'] = $_ / 1000; + + Util::arraySumByKey($json, $this->subject->jsonStats[$this->typeId] ?? []); + + foreach ($this->subject->json[$this->typeId] as $name => $qty) + if ($idx = Stat::getIndexFrom(Stat::IDX_JSON_STR, $name)) + if (Stat::getFilterCriteriumId($idx)) + $json[$name] = $qty; + + $xml->addChild('jsonEquip')->addCData(substr(json_encode($json), 1, -1)); + + // jsonUse + if ($onUse = $this->subject->getOnUseStats()) + { + $j = ''; + foreach ($onUse->toJson(includeEmpty: false) as $key => $amt) + $j .= ',"'.$key.'":'.$amt; + + $xml->addChild('jsonUse')->addCData(substr($j, 1)); + } + + // reagents + $cnd = array( + DB::OR, + [DB::AND, ['effect1CreateItemId', $this->typeId], [DB::OR, ['effect1Id', SpellList::EFFECTS_ITEM_CREATE], ['effect1AuraId', SpellList::AURAS_ITEM_CREATE]]], + [DB::AND, ['effect2CreateItemId', $this->typeId], [DB::OR, ['effect2Id', SpellList::EFFECTS_ITEM_CREATE], ['effect2AuraId', SpellList::AURAS_ITEM_CREATE]]], + [DB::AND, ['effect3CreateItemId', $this->typeId], [DB::OR, ['effect3Id', SpellList::EFFECTS_ITEM_CREATE], ['effect3AuraId', SpellList::AURAS_ITEM_CREATE]]], + ); + + $spellSource = new SpellList($cnd); + if (!$spellSource->error) + { + $cbNode = $xml->addChild('createdBy'); + + foreach ($spellSource->iterate() as $sId => $__) + { + foreach ($spellSource->canCreateItem() as $idx) + { + if ($spellSource->getField('effect'.$idx.'CreateItemId') != $this->typeId) + continue; + + $splNode = $cbNode->addChild('spell'); + $splNode->addAttribute('id', $sId); + $splNode->addAttribute('name', $spellSource->getField('name', true)); + $splNode->addAttribute('icon', $this->subject->getField('iconString')); + $splNode->addAttribute('minCount', $spellSource->getField('effect'.$idx.'BasePoints') + 1); + $splNode->addAttribute('maxCount', $spellSource->getField('effect'.$idx.'BasePoints') + $spellSource->getField('effect'.$idx.'DieSides')); + + foreach ($spellSource->getReagentsForCurrent() as $rId => $qty) + { + if ($reagent = $spellSource->relItems->getEntry($rId)) + { + $rgtNode = $splNode->addChild('reagent'); + $rgtNode->addAttribute('id', $rId); + $rgtNode->addAttribute('name', Util::localizedString($reagent, 'name')); + $rgtNode->addAttribute('quality', $reagent['quality']); + $rgtNode->addAttribute('icon', $reagent['iconString']); + $rgtNode->addAttribute('count', $qty[1]); + } + } + + break; + } + } + } + + // link + $xml->addChild('link', Cfg::get('HOST_URL').'?item='.$this->typeId); + + $this->result = $root->asXML(); + } + + private function getSubclass() : string + { + $c = $this->subject->getField('class'); + $sc = $this->subject->getField('subClass'); + + if ($c == ITEM_CLASS_WEAPON) + $langRef = Lang::spell('weaponSubClass'); + else + $langRef = Lang::item('cat', $c, 1); + + if (!is_array($langRef)) + return Lang::item('cat', $c); + + if (is_array($langRef[$sc])) + return $langRef[$sc][0]; + + return $langRef[$sc]; + } + + public function getCacheKeyComponents() : array + { + return array( + $this->type, // DBType + $this->typeId, // DBTypeId/category + -1, // staff mask (content does not diff) + '' // misc (unused) + ); + } +} + +?> diff --git a/pages/items.php b/endpoints/items/items.php similarity index 56% rename from pages/items.php rename to endpoints/items/items.php index 99db56d3..0f5f48fd 100644 --- a/pages/items.php +++ b/endpoints/items/items.php @@ -1,25 +1,29 @@ ['filter' => FILTER_UNSAFE_RAW]]; + protected string $template = 'items'; + protected string $pageName = 'items'; + protected ?int $activeTab = parent::TAB_DATABASE; + protected array $breadcrumb = [0, 0]; - protected $validCats = array( // if > 0 class => subclass + protected array $dataLoader = ['weight-presets']; + protected array $scripts = [[SC_JS_FILE, 'js/filters.js']]; + protected array $expectedGET = array( + 'filter' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = array( // if > 0 class => subclass 2 => [15, 13, 0, 4, 7, 6, 10, 1, 5, 8, 2, 18, 3, 16, 19, 20, 14], 4 => array( 0 => true, @@ -76,77 +80,100 @@ class ItemsPage extends GenericPage 13 => true ); - private $sharedLV = array( // common listview components across all tabs + public array $gemScores = []; + public array $typeList = []; // rightPanel - content by context + public array $slotList = []; // rightPanel - INV_TYPE_Xs + + private array $filterOpts = []; + private array $sharedLV = array( // common listview components across all tabs 'hiddenCols' => [], 'visibleCols' => [], 'extraCols' => [] ); - public function __construct($pageCall, $pageParam) + public function __construct(string $rawParam) { - $this->getCategoryFromUrl($pageParam); + $this->getCategoryFromUrl($rawParam); - $this->filterObj = new ItemListFilter(false, ['parentCats' => $this->category]); + parent::__construct($rawParam); - parent::__construct($pageCall, $pageParam); + if ($this->category) + $this->subCat = '='.implode('.', $this->category); - $this->name = Util::ucFirst(Lang::game('items')); - $this->subCat = $pageParam !== '' ? '='.$pageParam : ''; + $this->filter = new ItemListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; } - protected function generateContent() + protected function generate() : void { - $this->addScript([JS_FILE, '?data=weight-presets&locale='.User::$localeId.'&t='.$_SESSION['dataKey']]); - - $conditions = []; + $this->h1 = Util::ucFirst(Lang::game('items')); + $conditions = [Listview::DEFAULT_SIZE]; if (!User::isInGroup(U_GROUP_EMPLOYEE)) $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + /*******************/ /* evaluate filter */ /*******************/ - // recreate form selection (must be evaluated first via getConditions()) - if ($_ = $this->filterObj->getConditions()) - $conditions[] = $_; - - $this->filter = $this->filterObj->getForm(); - $this->filter['query'] = $this->_get['filter']; - $this->filter['initData'] = ['init' => 'items']; - - if ($x = $this->filterObj->getSetCriteria()) - $this->filter['initData']['sc'] = $x; - - $xCols = $this->filterObj->getExtraCols(); - if ($xCols) - $this->filter['initData']['ec'] = $xCols; - - if ($x = $this->filterObj->getSetWeights()) - $this->filter['initData']['sw'] = $x; - - $menu = $this->createExtraMenus(); - foreach ($menu['type'][0] as $k => $str) - if ($str && (!$menu['type'][1] || ($menu['type'][1] & (1 << $k)))) - $this->filter['type'][$k] = $str; - - foreach ($menu['slot'][0] as $k => $str) - if ($str && (!$menu['slot'][1] || ($menu['slot'][1] & (1 << $k)))) - $this->filter['slot'][$k] = $str; - - if (isset($this->filter['slot'][INVTYPE_SHIELD])) // "Off Hand" => "Shield" - $this->filter['slot'][INVTYPE_SHIELD] = Lang::item('armorSubClass', 6); + $fiForm = $this->filter->values; + $xCols = $this->filter->fiExtraCols; $infoMask = ITEMINFO_JSON; if (array_intersect([63, 64, 125], $xCols)) // 63:buyPrice; 64:sellPrice; 125:reqarenartng $infoMask |= ITEMINFO_VENDOR; if ($xCols) - $this->sharedLV['extraCols'] = '$fi_getExtraCols(fi_extraCols, '.(isset($this->filter['gm']) ? $this->filter['gm'] : 0).', '.(array_intersect([63], $xCols) ? 1 : 0).')'; + $this->sharedLV['extraCols'] = '$fi_getExtraCols(fi_extraCols, '.($fiForm['gm'] ?? 0).', '.(array_intersect([63], $xCols) ? 1 : 0).')'; + + $this->createExtraMenus(); // right side panels in search form + + + /*************/ + /* Menu Path */ + /*************/ + + foreach ($this->category as $c) + $this->breadcrumb[] = $c; + + // if slot-dropdown is available && Armor && $path points to Armor-Class + if (count($this->breadcrumb) == 4 && $this->category[0] == 4 && count($fiForm['sl']) == 1) + $this->breadcrumb[] = $fiForm['sl'][0]; + else if (isset($this->category[0]) && $this->category[0] == 0 && count($fiForm['ty']) == 1) + $this->breadcrumb[] = $fiForm['ty'][0]; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + + if ($this->category) + { + if (isset($this->category[2]) && is_array(Lang::item('cat', $this->category[0], 1, $this->category[1]))) + $tPart = Lang::item('cat', $this->category[0], 1, $this->category[1], 1, $this->category[2]); + else if (isset($this->category[1]) && is_array(Lang::item('cat', $this->category[0]))) + $tPart = Lang::item('cat', $this->category[0], 1, $this->category[1]); + else if ($this->category[0] == 0 && count($fiForm['ty']) == 1) + $tPart = Lang::item('cat', 0, 1, $fiForm['ty'][0]); + else + $tPart = Lang::item('cat', $this->category[0]); + + array_unshift($this->title, is_array($tPart) ? $tPart[0] : $tPart); + } - if ($this->filterObj->error) - $this->sharedLV['_errors'] = '$1'; /******************/ /* set conditions */ @@ -159,32 +186,34 @@ class ItemsPage extends GenericPage if (isset($this->category[2])) $conditions[] = ['i.subSubClass', $this->category[2]]; + /***********************/ /* handle auto-gemming */ /***********************/ - $this->gemScores = $this->createGemScores(); + $this->createGemScores(); + /*************************/ /* handle upgrade search */ /*************************/ $upgItemData = []; - if (!empty($this->filter['upg']) && !empty($this->filterObj->getSetWeights())) + if ($this->filter->upgrades && $this->filter->fiSetWeights) { - $upgItems = new ItemList(array(['id', array_keys($this->filter['upg'])]), ['extraOpts' => $this->filterObj->extraOpts]); + $upgItems = new ItemList(array(['id', array_keys($this->filter->upgrades)]), ['extraOpts' => $this->filter->extraOpts]); if (!$upgItems->error) { - $this->extendGlobalData($upgItems->getJSGlobals()); $upgItemData = $upgItems->getListviewData($infoMask); + $this->extendGlobalData($upgItems->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); } } - if ($upgItemData) // check if upItems cover multiple slots + if ($upgItemData) // check if upgItems cover multiple slots { $singleSlot = true; - $ref = reset($this->filter['upg']); - foreach ($this->filter['upg'] as $slot) + $ref = reset($this->filter->upgrades); + foreach ($this->filter->upgrades as $slot) { if ($slot == $ref) continue; @@ -193,16 +222,17 @@ class ItemsPage extends GenericPage break; } - if ($singleSlot && empty($this->filter['gb'])) // enforce group by slot - $this->filter['gb'] = 1; + if ($singleSlot && empty($fiForm['gb'])) // enforce group by slot + $fiForm['gb'] = ItemListFilter::GROUP_BY_SLOT; else if (!$singleSlot) // multiples can only be grouped by slot { - $this->filter['gb'] = 1; + $fiForm['gb'] = ItemListFilter::GROUP_BY_SLOT; $maxResults = 25; $this->sharedLV['customFilter'] = '$fi_filterUpgradeListview'; } } + /********************************************************************************************************************************/ /* group by */ /* */ @@ -225,15 +255,17 @@ class ItemsPage extends GenericPage ); $groups = []; $nameSource = []; - $grouping = isset($this->filter['gb']) ? $this->filter['gb'] : null; + $grouping = $fiForm['gb'] ?? ItemListFilter::GROUP_BY_NONE; $extraOpts = []; - $maxResults = CFG_SQL_LIMIT_DEFAULT; + $maxResults = Listview::DEFAULT_SIZE; + $forceTabs = false; + $tabs = []; switch ($grouping) { // slot: (try to limit the lookups by class grouping and intersecting with preselected slots) // if intersect yields an empty array no lookups will occur - case 1: + case ItemListFilter::GROUP_BY_SLOT: if (isset($this->category[0]) && $this->category[0] == ITEM_CLASS_ARMOR) $groups = $availableSlots[ITEM_CLASS_ARMOR]; else if (isset($this->category[0]) && $this->category[0] == ITEM_CLASS_WEAPON) @@ -241,23 +273,23 @@ class ItemsPage extends GenericPage else $groups = array_merge($availableSlots[ITEM_CLASS_ARMOR], $availableSlots[ITEM_CLASS_WEAPON]); - if (isset($this->filter['sl'])) // skip lookups for unselected slots - $groups = array_intersect($groups, (array)$this->filter['sl']); + if ($fiForm['sl']) // skip lookups for unselected slots + $groups = array_intersect($groups, $fiForm['sl']); - if (!empty($this->filter['upg'])) // skip lookups for slots we dont have items to upgrade for - $groups = array_intersect($groups, (array)$this->filter['upg']); + if ($this->filter->upgrades) // skip lookups for slots we dont have items to upgrade for + $groups = array_intersect($groups, $this->filter->upgrades); if ($groups) { $nameSource = Lang::item('inventoryType'); - $this->forceTabs = true; + $forceTabs = true; } break; - case 2: // itemlevel: first, try to find 10 level steps within range (if given) as tabs + case ItemListFilter::GROUP_BY_LEVEL: // itemlevel: first, try to find 10 level steps within range (if given) as tabs // ohkayy, maybe i need to rethink $this - $this->filterOpts = $this->filterObj->extraOpts; - $this->filterOpts['is']['o'] = [null]; // remove 'order by' from itemStats + $this->filterOpts = $this->filter->extraOpts; + $this->filterOpts['is']['o'] = [null]; // remove 'order by' from ::item_stats $extraOpts = array_merge($this->filterOpts, ['i' => ['g' => ['itemlevel'], 'o' => ['itemlevel DESC']]]); $levelRef = new ItemList(array_merge($conditions, [10]), ['extraOpts' => $extraOpts]); @@ -274,62 +306,68 @@ class ItemsPage extends GenericPage $groups[] = $l; // push last value as negativ to signal misc group after $this level $extraOpts = ['i' => ['o' => ['itemlevel DESC']]]; $nameSource[$l] = Lang::item('tabOther'); - $this->forceTabs = true; + $forceTabs = true; } break; - case 3: // source + case ItemListFilter::GROUP_BY_SOURCE: // source $groups = [1, 2, 3, 4, 5, 10, 11, 12, 0]; $nameSource = Lang::game('sources'); - $this->forceTabs = true; + $forceTabs = true; break; // none default: - $grouping = 0; + $grouping = ItemListFilter::GROUP_BY_NONE; $groups[0] = null; } + // write back 'gb' to filter + if ($grouping) + $this->filter->values['gb'] = $grouping; + + /*****************************/ /* create lv-tabs for groups */ /*****************************/ foreach ($groups as $group) { - switch ($grouping) + $finalCnd = match ($grouping) { - case 1: - $finalCnd = array_merge($conditions, [['slot', $group], $maxResults]); - break; - case 2: - $finalCnd = array_merge($conditions, [['itemlevel', abs($group), $group > 0 ? null : '<'], $maxResults]); - break; - case 3: - $finalCnd = array_merge($conditions, [$group ? ['src.src'.$group, null, '!'] : ['src.typeId', null], $maxResults]); - break; - default: - $finalCnd = $conditions; - } + ItemListFilter::GROUP_BY_SLOT => array_merge($conditions, [['slot', $group], $maxResults]), + ItemListFilter::GROUP_BY_LEVEL => array_merge($conditions, [['itemlevel', abs($group), $group > 0 ? null : '<'], $maxResults]), + ItemListFilter::GROUP_BY_SOURCE => array_merge($conditions, [$group ? ['src.src'.$group, null, '!'] : ['src.typeId', null], $maxResults]), + default => $conditions + }; - $items = new ItemList($finalCnd, ['extraOpts' => array_merge($extraOpts, $this->filterObj->extraOpts)]); + $items = new ItemList($finalCnd, ['extraOpts' => array_merge($extraOpts, $this->filter->extraOpts), 'calcTotal' => true]); if ($items->error) continue; - $this->extendGlobalData($items->getJSGlobals()); + // if sold by vendor; append cost column + if ($this->filter->getSetCriteria(92) && is_array($this->sharedLV['extraCols'])) + { + $this->sharedLV['extraCols']['cost'] = '$Listview.extraCols.cost'; + $data = $items->getListviewData($infoMask | ITEMINFO_VENDOR); + } + else + $data = $items->getListviewData($infoMask); + $tabData = array_merge( - ['data' => $items->getListviewData($infoMask)], + ['data' => $data], $this->sharedLV ); + $this->extendGlobalData($items->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); $upg = []; if ($upgItemData) { - if ($grouping == 1) // slot: match upgradeItem to slot + // slot: match upgradeItem to slot + if ($grouping == ItemListFilter::GROUP_BY_SLOT) { - $upg = array_keys(array_filter($this->filter['upg'], function ($v) use ($group) { - return $v == $group; - })); + $upg = array_keys(array_filter($this->filter->upgrades, fn($x) => $x == $group)); foreach ($upg as $uId) $tabData['data'][$uId] = $upgItemData[$uId]; @@ -339,7 +377,7 @@ class ItemsPage extends GenericPage } else if ($grouping) { - $upg = array_keys($this->filter['upg']); + $upg = array_keys($this->filter->upgrades); $tabData['_upgradeIds'] = $upg; foreach ($upgItemData as $uId => $data) // using numeric keys => cant use array_merge $tabData['data'][$uId] = $data; @@ -348,25 +386,19 @@ class ItemsPage extends GenericPage if ($grouping) { - switch ($grouping) + $tabData['id'] = match ($grouping) { - case 1: - $tabData['id'] = 'slot-'.$group; - break; - case 2: - $tabData['id'] = $group > 0 ? 'level-'.$group : 'other'; - break; - case 3: - $tabData['id'] = $group ? 'source-'.$group : 'unknown'; - break; - } + ItemListFilter::GROUP_BY_SLOT => 'slot-'.$group, + ItemListFilter::GROUP_BY_LEVEL => $group > 0 ? 'level-'.$group : 'other', + ItemListFilter::GROUP_BY_SOURCE => $group ? 'source-'.$group : 'unknown' + }; $tabData['name'] = $nameSource[$group]; $tabData['tabs'] = '$tabsGroups'; } - if (!empty($this->filterObj->getSetWeights())) - if ($items->hasSetFields(['armor'])) + if ($this->filter->fiSetWeights) + if ($items->hasSetFields('tplArmor')) $tabData['visibleCols'][] = 'armor'; // create note if search limit was exceeded; overwriting 'note' is intentional @@ -374,18 +406,18 @@ class ItemsPage extends GenericPage { $tabData['_truncated'] = 1; - $cls = isset($this->category[0]) ? '='.$this->category[0] : ''; + $catg = isset($this->category[0]) ? '='.$this->category[0] : ''; $override = ['gb' => '']; if ($upg) - $override['upg'] = implode(':', $upg); + $override['upg'] = $upg; switch ($grouping) { - case 1: + case ItemListFilter::GROUP_BY_SLOT: $override['sl'] = $group; - $tabData['note'] = '$$WH.sprintf(LANG.lvnote_viewmoreslot, \''.$cls.'\', \''.$this->filterObj->getFilterString($override).'\')'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_viewmoreslot, \''.$catg.'\', \''.$this->filter->buildGETParam($override).'\')'; break; - case 2: + case ItemListFilter::GROUP_BY_LEVEL: if ($group > 0) { $override['minle'] = $group; @@ -394,121 +426,58 @@ class ItemsPage extends GenericPage else $override['maxle'] = abs($group) - 1; - $tabData['note'] = '$$WH.sprintf(LANG.lvnote_viewmorelevel, \''.$cls.'\', \''.$this->filterObj->getFilterString($override).'\')'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_viewmorelevel, \''.$catg.'\', \''.$this->filter->buildGETParam($override).'\')'; break; - case 3: + case ItemListFilter::GROUP_BY_SOURCE: if ($_ = [null, 3, 4, 5, 6, 7, 9, 10, 11][$group]) - $tabData['note'] = '$$WH.sprintf(LANG.lvnote_viewmoresource, \''.$cls.'\', \''.$this->filterObj->getFilterString($override, ['cr' => 128, 'crs' => $_, 'crv' => 0]).'\')'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_viewmoresource, \''.$catg.'\', \''.$this->filter->buildGETParam($override, ['cr' => 128, 'crs' => $_, 'crv' => 0]).'\')'; break; } } else if ($items->getMatches() > $maxResults) { - $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_itemsfound', $items->getMatches(), CFG_SQL_LIMIT_DEFAULT); + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_itemsfound', $items->getMatches(), Listview::DEFAULT_SIZE); $tabData['_truncated'] = 1; } - foreach ($tabData as $k => $p) - if (!$p && $k != 'data') - unset($tabData[$k]); + // inherited from >sharedLV, may be empty + if (!$tabData['hiddenCols']) + unset($tabData['hiddenCols']); + if (!$tabData['visibleCols']) + unset($tabData['visibleCols']); + if (!$tabData['extraCols']) + unset($tabData['extraCols']); if ($grouping) $tabData['hideCount'] = 1; - $tabData['data'] = array_values($tabData['data']); - - $this->lvTabs[] = ['item', $tabData]; + $tabs[] = $tabData; } - // reformat for use in template - if (!empty($this->filter['upg'])) - $this->filter['upg'] = implode(':', array_keys($this->filter['upg'])); + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsGroups', $forceTabs && $tabs); // whoops, we have no data? create emergency content - if (empty($this->lvTabs)) - { - $this->forceTabs = false; - $this->lvTabs[] = ['item', ['data' => []]]; - } + if (!count($tabs)) + $tabs[] = ['data' => []]; - // sort for dropdown-menus - Lang::sort('game', 'ra'); - Lang::sort('game', 'cl'); - } + foreach ($tabs as $t) + $this->lvTabs->addListviewTab(new Listview($t, ItemList::$brickFile)); - protected function generateTitle() - { - array_unshift($this->title, $this->name); + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; - if (!$this->category) - return; + parent::generate(); - if (isset($this->category[2]) && is_array(Lang::item('cat', $this->category[0], 1, $this->category[1]))) - $tPart = Lang::item('cat', $this->category[0], 1, $this->category[1], 1, $this->category[2]); - else if (isset($this->category[1]) && is_array(Lang::item('cat', $this->category[0]))) - $tPart = Lang::item('cat', $this->category[0], 1, $this->category[1]); - else if ($this->category[0] == 0 && isset($this->filter['ty']) && !is_array($this->filter['ty'])) - $tPart = Lang::item('cat', 0, 1, $this->filter['ty']); - else - $tPart = Lang::item('cat', $this->category[0]); - - array_unshift($this->title, is_array($tPart) ? $tPart[0] : $tPart); - } - - protected function generatePath() - { - foreach ($this->category as $c) - $this->path[] = $c; - - // if slot-dropdown is available && Armor && $path points to Armor-Class - $form = $this->filterObj->getForm(); - if (count($this->path) == 4 && $this->category[0] == 4 && isset($form['sl']) && !is_array($form['sl'])) - $this->path[] = $form['sl']; - else if (!empty($this->category[0]) && $this->category[0] == 0 && isset($form['ty']) && !is_array($form['ty'])) - $this->path[] = $form['ty']; + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay']); } // fetch best possible gems for chosen weights - private function createGemScores() + private function createGemScores() : void { - $gemScores = []; - - if (empty($this->filterObj->getSetWeights())) - return []; - - if (!empty($this->filter['gm'])) - { - $this->sharedLV['computeDataFunc'] = '$fi_scoreSockets'; - - $q = intVal($this->filter['gm']); - $mask = 14; - $cnd = [10, ['class', ITEM_CLASS_GEM], ['gemColorMask', &$mask, '&'], ['quality', &$q]]; - if (!isset($this->filter['jc'])) - $cnd[] = ['itemLimitCategory', 0]; // Jeweler's Gems - - If ($this->filterObj->wtCnd) - $cnd[] = $this->filterObj->wtCnd; - - $anyColor = new ItemList($cnd, ['extraOpts' => $this->filterObj->extraOpts]); - if (!$anyColor->error) - { - $this->extendGlobalData($anyColor->getJSGlobals()); - $gemScores[0] = array_values($anyColor->getListviewData(ITEMINFO_GEM)); - } - - for ($i = 0; $i < 4; $i++) - { - $mask = 1 << $i; - $q = !$i ? 3 : intVal($this->filter['gm']); // meta gems are always included.. ($q is backReferenced) - $byColor = new ItemList($cnd, ['extraOpts' => $this->filterObj->extraOpts]); - if (!$byColor->error) - { - $this->extendGlobalData($byColor->getJSGlobals()); - $gemScores[$mask] = array_values($byColor->getListviewData(ITEMINFO_GEM)); - } - } - } + if (!$this->filter->fiSetWeights) + return; $this->sharedLV['onBeforeCreate'] = '$fi_initWeightedListview'; $this->sharedLV['onAfterCreate'] = '$fi_addUpgradeIndicator'; @@ -516,21 +485,50 @@ class ItemsPage extends GenericPage array_push($this->sharedLV['hiddenCols'], 'type', 'source'); - return $gemScores; + if (!$this->filter->values['gm']) + return; + + $this->sharedLV['computeDataFunc'] = '$fi_scoreSockets'; + + $q = $this->filter->values['gm']; + $mask = 0xE; + $cnd = [10, ['class', ITEM_CLASS_GEM], ['gemColorMask', &$mask, '&'], ['quality', &$q]]; + if (!$this->filter->values['jc']) + $cnd[] = ['itemLimitCategory', 0]; // Jeweler's Gems + + if ($this->filter->wtCnd) + $cnd[] = $this->filter->wtCnd; + + $anyColor = new ItemList($cnd, ['extraOpts' => $this->filter->extraOpts]); + if (!$anyColor->error) + { + $this->extendGlobalData($anyColor->getJSGlobals()); + $this->gemScores[0] = array_values($anyColor->getListviewData(ITEMINFO_GEM)); + } + + for ($i = 0; $i < 4; $i++) + { + $mask = 1 << $i; + $q = !$i ? ITEM_QUALITY_RARE : $this->filter->values['gm']; // meta gems are always included.. ($q is backReferenced) + $byColor = new ItemList($cnd, ['extraOpts' => $this->filter->extraOpts]); + if (!$byColor->error) + { + $this->extendGlobalData($byColor->getJSGlobals()); + $this->gemScores[$mask] = array_values($byColor->getListviewData(ITEMINFO_GEM)); + } + } } // display available submenus 'type' and 'slot', if applicable - private function createExtraMenus() + private function createExtraMenus() : void { - $menu = array( - 'type' => [[], null], - 'slot' => [[], null] - ); + $typeData = $slotData = []; + $typeMask = $slotMask = 0x0; if (!$this->category) { - $menu['slot'] = [Lang::item('inventoryType'), null]; - asort($menu['slot'][0]); + $slotData = Lang::item('inventoryType'); + asort($slotData); } else { @@ -544,31 +542,36 @@ class ItemsPage extends GenericPage switch ($this->category[0]) { case 0: - $menu['type'] = [Lang::item('cat', 0, 1), null]; + $typeData = Lang::item('cat', 0, 1); if (!isset($this->category[1]) || in_array($this->category[1], [6, -3])) { - $menu['slot'] = [Lang::item('inventoryType'), 0x63EFEA]; - asort($menu['slot'][0]); + $slotData = Lang::item('inventoryType'); + $slotMask = 0x63EFEA; + asort($slotData); } break; case 2: if (!isset($this->category[1])) - $menu['type'] = [Lang::spell('weaponSubClass'), null]; + $typeData = Lang::spell('weaponSubClass'); - $menu['slot'] = [Lang::item('inventoryType'), 0x262A000]; - asort($menu['slot'][0]); + $slotData = Lang::item('inventoryType'); + $slotMask = 0x262A000; + asort($slotData); break; case 4: if (!isset($this->category[1])) { - $menu['slot'] = [Lang::item('inventoryType'), 0x10895FFE]; - $menu['type'] = [Lang::item('cat', 4, 1), null]; + $slotData = Lang::item('inventoryType'); + $slotMask = 0x10895FFE; + $typeData = Lang::item('cat', 4, 1); } else if (in_array($this->category[1], [1, 2, 3, 4])) - $menu['slot'] = [Lang::item('inventoryType'), 0x7EA]; - - asort($menu['slot'][0]); + { + $slotData = Lang::item('inventoryType'); + $slotMask = 0x7EA; + } + asort($slotData); break; case 16: if (!isset($this->category[2])) @@ -584,13 +587,26 @@ class ItemsPage extends GenericPage $this->sharedLV['hiddenCols'][] = 'slot'; case 15: if (!isset($this->category[1])) - $menu['type'] = [$catList[1], null]; + $typeData = $catList[1]; break; } } - return $menu; + foreach ($typeData as $k => $str) + if ($str && (!$typeMask || ($typeMask & (1 << $k)))) + $this->typeList[$k] = is_array($str) ? $str[0] : $str; + + foreach ($slotData as $k => $str) // "Off Hand" => "Shield" + if ($str && (!$slotMask || ($slotMask & (1 << $k)))) + $this->slotList[$k] = $k == INVTYPE_SHIELD ? Lang::item('armorSubClass', 6) : $str; + } + + protected static function onBeforeDisplay() : void + { + // sort for dropdown-menus + Lang::sort('game', 'ra'); + Lang::sort('game', 'cl'); } } diff --git a/endpoints/itemset/itemset.php b/endpoints/itemset/itemset.php new file mode 100644 index 00000000..c86ae527 --- /dev/null +++ b/endpoints/itemset/itemset.php @@ -0,0 +1,260 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new ItemsetList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('itemset'), Lang::itemset('notFound')); + + $this->extendGlobalData($this->subject->getJSGlobals()); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_ta = $this->subject->getField('contentGroup'); + $_ty = $this->subject->getField('type'); + $_sk = $this->subject->getField('skillId'); + $_evt = $this->subject->getField('eventId'); + $_cnt = count($this->subject->getField('pieces')); + $_cl = ChrClass::fromMask($this->subject->getField('classMask')); + + + /*************/ + /* Menu Path */ + /*************/ + + if (count($_cl) == 1) + $this->breadcrumb[] = $_cl[0]; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucWords(Lang::game('itemset'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + if ($this->subject->getField('cuFlags') & CUSTOM_UNAVAILABLE) + $infobox[] = Lang::main('unavailable'); + + // worldevent + if ($_evt) + { + $infobox[] = Lang::game('eventShort', ['[event='.$_evt.']']); + $this->extendGlobalIds(Type::WORLDEVENT, $_evt); + } + + // itemLevel + if ($min = $this->subject->getField('minLevel')) + $infobox[] = Lang::game('level').Lang::main('colon').Util::createNumRange($min, $this->subject->getField('maxLevel'), ' - '); + + // class + $jsg = []; + if ($cl = Lang::getClassString($this->subject->getField('classMask'), $jsg, Lang::FMT_MARKUP)) + { + $this->extendGlobalIds(Type::CHR_CLASS, ...$jsg); + $t = count($jsg) == 1 ? Lang::game('class') : Lang::game('classes'); + $infobox[] = Util::ucFirst($t).Lang::main('colon').$cl; + } + + // required level + if ($lvl = $this->subject->getField('reqLevel')) + $infobox[] = Lang::game('reqLevel', [$lvl]); + + // type + if ($_ty) + $infobox[] = Lang::game('type').Lang::itemset('types', $_ty); + + // tag + if ($_ta) + $infobox[] = Lang::itemset('_tag').'[url=?itemsets&filter=ta='.$_ta.']'.Lang::itemset('notes', $_ta).'[/url]'; + + // id + $infobox[] = Lang::itemset('id') . $this->subject->getField('refSetId'); + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + // pieces + Summary + $eqList = []; + $compare = []; + + if (!$this->subject->pieceToSet) + $cnd = [0]; + else + $cnd = ['i.id', array_keys($this->subject->pieceToSet)]; + + $iList = new ItemList(array($cnd)); + $data = $iList->getListviewData(ITEMINFO_SUBITEMS | ITEMINFO_JSON); + foreach ($iList->iterate() as $itemId => $__) + { + if (empty($data[$itemId])) + continue; + + $slot = $iList->getField('slot'); + $disp = $iList->getField('displayId'); + if ($slot && $disp) + $eqList[] = [$slot, $disp]; + + $compare[] = $itemId; + + $this->pieces[$itemId] = array( + array( + 'name_'.Lang::getLocale()->json() => $iList->getField('name', true), + 'quality' => $iList->getField('quality'), + 'icon' => $iList->getField('iconString'), + 'jsonequip' => $data[$itemId] + ), + new IconElement(Type::ITEM, $itemId, $iList->getField('name', true), quality: $iList->getField('quality'), size: IconElement::SIZE_SMALL, align: 'right', element: 'iconlist-icon') + ); + } + + if ($compare) + $this->summary = new Summary(array( + 'template' => 'itemset', + 'id' => 'itemset', + 'parent' => 'summary-generic', + 'groups' => array_map(fn ($x) => [[$x]], $compare), + 'level' => $this->subject->getField('reqLevel') + )); + + // required skill + if ($_sk) + { + $spellLink = sprintf('%s (%s)', $_sk, Lang::spell('cat', 11, $_sk, 0), $this->subject->getField('skillLevel')); + $this->bonusExt = ' – '.Lang::game('requires', [$spellLink]).''; + } + + $this->description = $_ta ? Lang::itemset('_desc', [$this->h1, Lang::itemset('notes', $_ta), $_cnt]) : Lang::itemset('_descTagless', [$this->h1, $_cnt]); + $this->unavailable = !!($this->subject->getField('cuFlags') & CUSTOM_UNAVAILABLE); + $this->spells = $this->subject->getBonuses(); + // $this->expansion = $this->subject->getField('expansion'); NYI - todo: add col to table + $this->redButtons = array( + BUTTON_WOWHEAD => $this->typeId > 0, // bool only + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_VIEW3D => ['type' => Type::ITEMSET, 'typeId' => $this->typeId, 'equipList' => $eqList], + BUTTON_COMPARE => $compare ? ['eqList' => implode(':', $compare), 'qty' => $_cnt] : false + ); + + + /**************/ + /* Extra Tabs */ + /**************/ + + // related sets (priority: 1: similar tag + class; 2: has event; 3: no tag + similar type, 4: similar type + profession) + $rel = []; + + if ($_ta && count($_cl) == 1) + { + $rel[] = ['id', $this->typeId, '!']; + $rel[] = ['classMask', 1 << ($_cl[0] - 1), '&']; + $rel[] = ['contentGroup', (int)$_ta]; + } + else if ($_evt) + { + $rel[] = ['id', $this->typeId, '!']; + $rel[] = ['eventId', 0, '!']; + } + else if ($_sk) + { + $rel[] = ['id', $this->typeId, '!']; + $rel[] = ['contentGroup', 0]; + $rel[] = ['skillId', 0, '!']; + $rel[] = ['type', $_ty]; + } + else if (!$_ta && $_ty) + { + $rel[] = ['id', $this->typeId, '!']; + $rel[] = ['contentGroup', 0]; + $rel[] = ['type', $_ty]; + $rel[] = ['skillId', 0]; + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + if ($rel) + { + $relSets = new ItemsetList($rel); + if (!$relSets->error) + { + $tabData = array( + 'data' => $relSets->getListviewData(), + 'id' => 'see-also', + 'name' => '$LANG.tab_seealso' + ); + + if (!$relSets->hasDiffFields('classMask')) + $tabData['hiddenCols'] = ['classes']; + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemsetList::$brickFile)); + + $this->extendGlobalData($relSets->getJSGlobals()); + } + } + + parent::generate(); + } +} + + + + +?> diff --git a/endpoints/itemset/itemset_power.php b/endpoints/itemset/itemset_power.php new file mode 100644 index 00000000..042354ae --- /dev/null +++ b/endpoints/itemset/itemset_power.php @@ -0,0 +1,49 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(string $id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $itemset = new ItemsetList(array(['id', $this->typeId])); + if ($itemset->error) + $this->cacheType = CACHE_TYPE_NONE; + else + $opts = array( + 'name' => $itemset->getField('name', true), + 'tooltip' => $itemset->renderTooltip(), + ); + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/itemsets/itemsets.php b/endpoints/itemsets/itemsets.php new file mode 100644 index 00000000..945e5100 --- /dev/null +++ b/endpoints/itemsets/itemsets.php @@ -0,0 +1,116 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new ItemsetListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucWords(Lang::game('itemsets')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + + /*************/ + /* Menu Path */ + /*************/ + + if ($cl = $this->filter->values['cl']) + $this->breadcrumb[] = $cl; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + + if ($cl = $this->filter->values['cl']) + array_unshift($this->title, Lang::game('cl', $cl)); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + $itemsets = new ItemsetList($conditions, ['calcTotal' => true]); + $this->extendGlobalData($itemsets->getJSGlobals()); + + $tabData = ['data' => $itemsets->getListviewData()]; + + if ($this->filter->fiExtraCols) + $tabData['extraCols'] = '$fi_getExtraCols(fi_extraCols, 0, 0)'; + + // create note if search limit was exceeded + if ($itemsets->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_itemsetsfound', $itemsets->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemsetList::$brickFile)); + + parent::generate(); + + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay']); + } + + public static function onBeforeDisplay() : void + { + // sort for dropdown-menus + Lang::sort('itemset', 'notes', SORT_NATURAL); + Lang::sort('game', 'cl'); + } +} + +?> diff --git a/endpoints/latest-comments/latest-comments.php b/endpoints/latest-comments/latest-comments.php new file mode 100644 index 00000000..fbb809db --- /dev/null +++ b/endpoints/latest-comments/latest-comments.php @@ -0,0 +1,46 @@ + Util > Latest Comments + + protected function generate() : void + { + $this->h1 = Lang::main('utilities', 2); + $this->h1Link = '?'.$this->pageName.'&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + $this->rss = Cfg::get('HOST_URL').'/?' . $this->pageName . '&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + + + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $comments = CommunityContent::getCommentPreviews(['comments' => true, 'replies' => false], resultLimit: Listview::DEFAULT_SIZE); + $this->lvTabs->addListviewTab(new Listview(['data' => $comments], 'commentpreview')); + + $replies = CommunityContent::getCommentPreviews(['comments' => false, 'replies' => true], resultLimit: Listview::DEFAULT_SIZE); + $this->lvTabs->addListviewTab(new Listview(['data' => $replies], 'replypreview')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/latest-comments/latest-comments_rss.php b/endpoints/latest-comments/latest-comments_rss.php new file mode 100644 index 00000000..20b61fa5 --- /dev/null +++ b/endpoints/latest-comments/latest-comments_rss.php @@ -0,0 +1,41 @@ + 1, 'replies' => 1], dateFmt: false, resultLimit: 100) as $comment) + { + if (empty($comment['commentid'])) + $url = Cfg::get('HOST_URL').'/?go-to-comment&id='.$comment['id']; + else + $url = Cfg::get('HOST_URL').'/?go-to-reply&id='.$comment['id']; + + // todo (low): preview should be html-formated + $this->feedData[] = array( + 'title' => [true, [], Lang::typeName($comment['type']).Lang::main('colon').htmlentities($comment['subject'])], + 'link' => [false, [], $url], + 'description' => [true, [], htmlentities($comment['preview'])."

".Lang::main('byUser', [$comment['user'], '']) . $now->formatDate($comment['date'], true)], + 'pubDate' => [false, [], date(DATE_RSS, $comment['date'])], + 'guid' => [false, [], $url] + // 'domain' => [false, [], null] + ); + } + + $this->result = $this->generateRSS(Lang::main('utilities', 2), 'latest-comments'); + } +} + +?> diff --git a/endpoints/latest-screenshots/latest-screenshots.php b/endpoints/latest-screenshots/latest-screenshots.php new file mode 100644 index 00000000..6e8ba355 --- /dev/null +++ b/endpoints/latest-screenshots/latest-screenshots.php @@ -0,0 +1,43 @@ + Util > Latest Screenshots + + protected function generate() : void + { + $this->h1 = Lang::main('utilities', 3); + $this->h1Link = '?'.$this->pageName.'&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + $this->rss = Cfg::get('HOST_URL').'/?' . $this->pageName . '&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + + + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $data = CommunityContent::getScreenshots(resultLimit: Listview::DEFAULT_SIZE); + $this->lvTabs->addListviewTab(new Listview(['data' => $data], 'screenshot')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/latest-screenshots/latest-screenshots_rss.php b/endpoints/latest-screenshots/latest-screenshots_rss.php new file mode 100644 index 00000000..50e6f215 --- /dev/null +++ b/endpoints/latest-screenshots/latest-screenshots_rss.php @@ -0,0 +1,42 @@ +'; + if ($screenshot['caption']) + $desc .= '
'.$screenshot['caption']; + $desc .= "

".Lang::main('byUser', [$screenshot['user'], '']) . $now->formatDate($screenshot['date'], true); + + // enclosure/length => filesize('static/uploads/screenshots/thumb/'.$screenshot['id'].'.jpg') .. always set to this placeholder value though + $this->feedData[] = array( + 'title' => [true, [], Lang::typeName($screenshot['type']).Lang::main('colon').htmlentities($screenshot['subject'])], + 'link' => [false, [], Cfg::get('HOST_URL').'/?'.Type::getFileString($screenshot['type']).'='.$screenshot['typeId'].'#screenshots:id='.$screenshot['id']], + 'description' => [true, [], $desc], + 'pubDate' => [false, [], date(DATE_RSS, $screenshot['date'])], + 'enclosure' => [false, ['url' => Cfg::get('STATIC_URL').'/uploads/screenshots/thumb/'.$screenshot['id'].'.jpg', 'length' => 12345, 'type' => 'image/jpeg'], null], + 'guid' => [false, [], Cfg::get('HOST_URL').'/?'.Type::getFileString($screenshot['type']).'='.$screenshot['typeId'].'#screenshots:id='.$screenshot['id']], + // 'domain' => [false, [], live|ptr] + ); + } + + $this->result = $this->generateRSS(Lang::main('utilities', 3), 'latest-screenshots'); + } +} + +?> diff --git a/endpoints/latest-videos/latest-videos.php b/endpoints/latest-videos/latest-videos.php new file mode 100644 index 00000000..610552e3 --- /dev/null +++ b/endpoints/latest-videos/latest-videos.php @@ -0,0 +1,43 @@ + Util > Latest Videos + + protected function generate() : void + { + $this->h1 = Lang::main('utilities', 11); + $this->h1Link = '?'.$this->pageName.'&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + $this->rss = Cfg::get('HOST_URL').'/?' . $this->pageName . '&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + + + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $data = CommunityContent::getVideos(resultLimit: Listview::DEFAULT_SIZE); + $this->lvTabs->addListviewTab(new Listview(['data' => $data], 'video')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/latest-videos/latest-videos_rss.php b/endpoints/latest-videos/latest-videos_rss.php new file mode 100644 index 00000000..5e3980ec --- /dev/null +++ b/endpoints/latest-videos/latest-videos_rss.php @@ -0,0 +1,42 @@ +'; + if ($video['caption']) + $desc .= '
'.$video['caption']; + $desc .= "

".Lang::main('byUser', [$video['user'], '']) . $now->formatDate($video['date'], true); + + // is enclosure/length .. is this even relevant..? + $this->feedData[] = array( + 'title' => [true, [], Lang::typeName($video['type']).Lang::main('colon').htmlentities($video['subject'])], + 'link' => [false, [], Cfg::get('HOST_URL').'/?'.Type::getFileString($video['type']).'='.$video['typeId'].'#videos:id='.$video['id']], + 'description' => [true, [], $desc], + 'pubDate' => [false, [], date(DATE_RSS, $video['date'])], + 'enclosure' => [false, ['url' => '//i3.ytimg.com/vi/'.$video['videoId'].'/default.jpg', 'length' => 12345, 'type' => 'image/jpeg'], null], + 'guid' => [false, [], Cfg::get('HOST_URL').'/?'.Type::getFileString($video['type']).'='.$video['typeId'].'#videos:id='.$video['id']], + // 'domain' => [false, [], live|ptr] + ); + } + + $this->result = $this->generateRSS(Lang::main('utilities', 11), 'latest-videos'); + } +} + +?> diff --git a/endpoints/locale/locale.php b/endpoints/locale/locale.php new file mode 100644 index 00000000..260ae500 --- /dev/null +++ b/endpoints/locale/locale.php @@ -0,0 +1,27 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkLocale']] + ); + + protected function generate() : void + { + if ($this->_get['locale']?->validate()) + { + User::$preferedLoc = $this->_get['locale']; + User::save(true); + } + + $this->redirectTo = $_SERVER['HTTP_REFERER'] ?? '.'; + } +} + +?> diff --git a/endpoints/mail/mail.php b/endpoints/mail/mail.php new file mode 100644 index 00000000..0b6fe31b --- /dev/null +++ b/endpoints/mail/mail.php @@ -0,0 +1,183 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new MailList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('mail'), Lang::mail('notFound')); + + $this->extendGlobalData($this->subject->getJSGlobals()); + + $this->h1 = Util::htmlEscape($this->subject->getField('name', true)); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->subject->getField('name', true) + ); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->subject->getField('name', true), Util::ucFirst(Lang::game('mail'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // sender + delay + if ($this->typeId < 0) // def. achievement + { + if ($npcId = DB::World()->selectCell('SELECT `Sender` FROM achievement_reward WHERE `ID` = %i', -$this->typeId)) + { + $infobox[] = Lang::mail('sender', ['[npc='.$npcId.']']); + $this->extendGlobalIds(Type::NPC, $npcId); + } + } + else if ($mlr = DB::World()->selectRow('SELECT * FROM mail_level_reward WHERE `mailTemplateId` = %i', $this->typeId)) // level rewards + { + if ($mlr['level']) + $infobox[] = Lang::game('level').Lang::main('colon').$mlr['level']; + + $jsg = []; + if ($r = Lang::getRaceString($mlr['raceMask'], $jsg, Lang::FMT_MARKUP)) + { + $this->extendGlobalIds(Type::CHR_RACE, ...$jsg); + $t = count($jsg) == 1 ? Lang::game('race') : Lang::game('races'); + $infobox[] = Util::ucFirst($t).Lang::main('colon').$r; + } + + $infobox[] = Lang::mail('sender', ['[npc='.$mlr['senderEntry'].']']); + $this->extendGlobalIds(Type::NPC, $mlr['senderEntry']); + } + else // achievement or quest + { + if ($q = DB::Aowow()->selectRow('SELECT `id`, `rewardMailDelay` FROM ::quests WHERE `rewardMailTemplateId` = %i', $this->typeId)) + { + if ($npcId= DB::World()->selectCell('SELECT `RewardMailSenderEntry` FROM quest_mail_sender WHERE `QuestId` = %i', $q['id'])) + { + $infobox[] = Lang::mail('sender', ['[npc='.$npcId.']']); + $this->extendGlobalIds(Type::NPC, $npcId); + } + else if ($npcId = DB::Aowow()->selectCell('SELECT `typeId` FROM ::quests_startend WHERE `questId` = %i AND `type` = %i AND `method` & %i', $q['id'], Type::NPC, 0x2)) + { + $infobox[] = Lang::mail('sender', ['[npc='.$npcId.']']); + $this->extendGlobalIds(Type::NPC, $npcId); + } + + if ($q['rewardMailDelay'] > 0) + $infobox[] = Lang::mail('delay', [DateTime::formatTimeElapsed($q['rewardMailDelay'] * 1000)]); + } + else if ($npcId = DB::World()->selectCell('SELECT `Sender` FROM achievement_reward WHERE `MailTemplateId` = %i', $this->typeId)) + { + $infobox[] = Lang::mail('sender', ['[npc='.$npcId.']']); + $this->extendGlobalIds(Type::NPC, $npcId); + } + } + + // id + $infobox[] = Lang::mail('id') . $this->typeId; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons = array( + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_WOWHEAD => false + ); + + $this->extraText = new Markup(Util::parseHtmlText($this->subject->getField('text', true), true), ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], 'text-generic'); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: attachment + if ($itemId = $this->subject->getField('attachment')) + { + $attachment = new ItemList(array(['id', $itemId])); + if (!$attachment->error) + { + $this->extendGlobalData($attachment->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $attachment->getListviewData(), + 'name' => Lang::mail('attachment'), + 'id' => 'attachment' + ), ItemList::$brickFile)); + } + } + + if ($this->typeId < 0 || // used by: achievement + ($acvId = DB::World()->selectCell('SELECT `ID` FROM achievement_reward WHERE `MailTemplateId` = %i', $this->typeId))) + { + $ubAchievements = new AchievementList(array(['id', $this->typeId < 0 ? -$this->typeId : $acvId])); + if (!$ubAchievements->error) + { + $this->extendGlobalData($ubAchievements->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubAchievements->getListviewData(), + 'id' => 'used-by-achievement' + ), AchievementList::$brickFile)); + } + } + else // used by: quest + { + $ubQuests = new QuestList(array(['rewardMailTemplateId', $this->typeId])); + if (!$ubQuests->error) + { + $this->extendGlobalData($ubQuests->getJsGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubQuests->getListviewData(), + 'id' => 'used-by-quest' + ), QuestList::$brickFile)); + } + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/mails/mails.php b/endpoints/mails/mails.php new file mode 100644 index 00000000..e1319aca --- /dev/null +++ b/endpoints/mails/mails.php @@ -0,0 +1,59 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('mails')); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $tabData = []; + $mails = new MailList(); + if (!$mails->error) + $tabData['data'] = $mails->getListviewData(); + + $this->extendGlobalData($mails->getJsGlobals()); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(['data' => $mails->getListviewData()], MailList::$brickFile, 'mail')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/maps/maps.php b/endpoints/maps/maps.php new file mode 100644 index 00000000..0f99f470 --- /dev/null +++ b/endpoints/maps/maps.php @@ -0,0 +1,29 @@ +h1 = Lang::maps('maps'); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/missing-screenshots/missing-screenshots.php b/endpoints/missing-screenshots/missing-screenshots.php new file mode 100644 index 00000000..8341008c --- /dev/null +++ b/endpoints/missing-screenshots/missing-screenshots.php @@ -0,0 +1,58 @@ + Util > Missing Screenshots + + protected function generate() : void + { + $this->h1 = Lang::main('utilities', 13); + + + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + // limit to 200 entries each (it generates faster, consumes less memory and should be enough options) + $cnd = [[['cuFlags', CUSTOM_HAS_SCREENSHOT, '&'], 0], 200]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $cnd[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + $hasTabs = false; + foreach (Type::getClassesFor(Type::FLAG_RANDOM_SEARCHABLE, 'contribute', CONTRIBUTE_SS) as $classStr) + { + $typeObj = new $classStr($cnd); + if ($typeObj->error) + continue; + + $this->extendGlobalData($typeObj->getJSGlobals(GLOBALINFO_ANY)); + $this->lvTabs->addListviewTab(new Listview(['data' => $typeObj->getListviewData()], $typeObj::$brickFile)); + $hasTabs = true; + } + + if (!$hasTabs) + $this->lvTabs->addListviewTab(new Listview(['data' => []], 'item')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/most-comments/most-comments.php b/endpoints/most-comments/most-comments.php new file mode 100644 index 00000000..20985809 --- /dev/null +++ b/endpoints/most-comments/most-comments.php @@ -0,0 +1,110 @@ + Util > Most Comments + + protected array $validCats = [1, 7, 30]; + + public function __construct($rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function onInvalidCategory() : never + { + $this->forward('?most-comments=1'); + } + + protected function generate() : void + { + $this->h1 = Lang::main('utilities', 12); + if ($this->category && $this->category[0] > 1) + $this->h1 .= Lang::main('colon') . Lang::main('mostComments', 1, $this->category); + else + $this->h1 .= Lang::main('colon') . Lang::main('mostComments', 0); + + $this->h1Link = '?' . $this->pageName.($this->category ? '='.$this->category[0] : '').'&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + $this->rss = Cfg::get('HOST_URL').'/?' . $this->pageName.($this->category ? '='.$this->category[0] : '') . '&rss' . (Lang::getLocale()->value ? '&locale='.Lang::getLocale()->value : ''); + + + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, $this->h1); + + + /**************/ + /* Breadcrumb */ + /**************/ + + $this->breadcrumb[] = $this->category[0] ?? 1; + + + /****************/ + /* Main Content */ + /****************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], __forceTabs: true); + + $tabBase = array( + 'extraCols' => ["\$Listview.funcBox.createSimpleCol('ncomments', 'tab_comments', '10%', 'ncomments')"], + 'sort' => ['-ncomments'] + ); + + $hasTabs = false; + foreach (Type::getClassesFor() as $type => $classStr) + { + $comments = DB::Aowow()->selectCol( + 'SELECT `typeId` AS ARRAY_KEY, COUNT(1) FROM ::comments + WHERE `replyTo` = 0 AND (`flags` & %i) = 0 AND `type`= %i AND `date` > (UNIX_TIMESTAMP() - %i) + GROUP BY `type`, `typeId` + LIMIT 100', + CC_FLAG_DELETED, + $type, + ($this->category[0] ?? 1) * DAY + ); + if (!$comments) + continue; + + $typeClass = new $classStr(array(['id', array_keys($comments)])); + if ($typeClass->error) + continue; + + $data = $typeClass->getListviewData(); + + foreach ($data as $typeId => &$d) + $d['ncomments'] = $comments[$typeId]; + + $addIn = ''; + if (in_array($type, [Type::AREATRIGGER, Type::ENCHANTMENT, Type::ENCHANTMENT, Type::EMOTE])) + { + $addIn = Type::getFileString($type); + $tabBase['name'] = '$LANG.types['.$type.'][2]'; + } + + $this->extendGlobalData($typeClass->getJSGlobals(GLOBALINFO_ANY)); + $this->lvTabs->addListviewTab(new Listview($tabBase + ['data' => $data], $typeClass::$brickFile, $addIn)); + $hasTabs = true; + } + + if (!$hasTabs) + $this->lvTabs->addListviewTab(new Listview(['data' => []], 'commentpreview')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/most-comments/most-comments_rss.php b/endpoints/most-comments/most-comments_rss.php new file mode 100644 index 00000000..1f99587d --- /dev/null +++ b/endpoints/most-comments/most-comments_rss.php @@ -0,0 +1,63 @@ +params && !in_array($this->params[0], $this->validCats)) + $this->forward('?most-comments=1&rss'); + } + + protected function generate() : void + { + foreach (Type::getClassesFor() as $type => $classStr) + { + $comments = DB::Aowow()->selectCol( + 'SELECT `typeId` AS ARRAY_KEY, COUNT(1) FROM ::comments + WHERE `replyTo` = 0 AND (`flags` & %i) = 0 AND `type`= %i AND `date` > (UNIX_TIMESTAMP() - %i) + GROUP BY `type`, `typeId` + LIMIT 100', + CC_FLAG_DELETED, + $type, + ($this->category[0] ?? 1) * DAY + ); + if (!$comments) + continue; + + $typeClass = new $classStr(array(['id', array_keys($comments)])); + if ($typeClass->error) + continue; + + $data = $typeClass->getListviewData(); + + foreach ($data as $typeId => &$d) + { + $this->feedData[] = array( + 'title' => [true, [], htmlentities(Type::getFileString($type) == 'item' ? mb_substr($d['name'], 1) : $d['name'])], + 'type' => [false, [], Type::getFileString($type)], + 'link' => [false, [], Cfg::get('HOST_URL').'/?'.Type::getFileString($type).'='.$d['id']], + 'ncomments' => [false, [], $comments[$typeId]] + ); + } + + } + + $this->result = $this->generateRSS(Lang::main('utilities', 12), 'most-comments' . ($this->params ? '='.$this->params[0] : '')); + } +} + +?> diff --git a/endpoints/my-guides/my-guides.php b/endpoints/my-guides/my-guides.php new file mode 100644 index 00000000..f335369f --- /dev/null +++ b/endpoints/my-guides/my-guides.php @@ -0,0 +1,52 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::guide('myGuides')); + + array_unshift($this->title, $this->h1); + + $this->redButtons = [BUTTON_GUIDE_NEW => User::canWriteGuide()]; + + $guides = new GuideList(array(['userId', User::$id])); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $guides->getListviewData(), + 'name' => Util::ucFirst(Lang::game('guides')), + 'hiddenCols' => ['patch', 'author'], + 'visibleCols' => ['status'], + 'extraCols' => ['$Listview.extraCols.date'] + ), GuideList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/npc/npc.php b/endpoints/npc/npc.php new file mode 100644 index 00000000..1ddf45e5 --- /dev/null +++ b/endpoints/npc/npc.php @@ -0,0 +1,1125 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new CreatureList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('npc'), Lang::npc('notFound')); + + $this->h1 = Util::htmlEscape($this->subject->getField('name', true)); + $this->subname = $this->subject->getField('subname', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->subject->getField('name', true) + ); + + $_typeFlags = $this->subject->getField('typeFlags'); + $_altIds = []; + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('type'); + if ($_ = $this->subject->getField('family')) + $this->breadcrumb[] = $_; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->subject->getField('name', true), mb_strtoupper(Lang::game('npc'))); + + + /***********************/ + /* Difficulty versions */ + /***********************/ + + if ($this->subject->getField('cuFlags') & NPC_CU_DIFFICULTY_DUMMY) + $this->placeholder = [$this->subject->getField('parentId'), $this->subject->getField('parent', true)]; + else + { + for ($i = 1; $i < 4; $i++) + if ($_ = $this->subject->getField('difficultyEntry'.$i)) + $_altIds[$_] = $i; + + if ($_altIds) + $this->altNPCs = new CreatureList(array(['id', array_keys($_altIds)])); + } + + if ($_ = DB::World()->selectCol('SELECT DISTINCT `entry` FROM vehicle_template_accessory WHERE `accessory_entry` = %i', $this->typeId)) + { + $vehicles = new CreatureList(array(['id', $_])); + foreach ($vehicles->iterate() as $id => $__) + $this->accessory[] = [$id, $vehicles->getField('name', true)]; + } + + + /**********************/ + /* Determine Map Type */ + /**********************/ + + $mapType = 0; + if ($maps = DB::Aowow()->selectCell('SELECT IF(COUNT(DISTINCT `areaId`) > 1, 0, `areaId`) FROM ::spawns WHERE `type` = %i AND `typeId` = %i', Type::NPC, $this->typeId)) + { + $mapType = match (DB::Aowow()->selectCell('SELECT `type` FROM ::zones WHERE `id` = %i', $maps)) + { + // MAP_TYPE_DUNGEON, + MAP_TYPE_DUNGEON_HC => 1, + // MAP_TYPE_RAID, + MAP_TYPE_MMODE_RAID, + MAP_TYPE_MMODE_RAID_HC => 2, + default => 0 + }; + } + // npc is difficulty dummy: get max difficulty from parent npc + if ($this->placeholder && ($mt = DB::Aowow()->selectCell('SELECT IF(`difficultyEntry1` = %i, 1, 2) FROM ::creature WHERE `difficultyEntry1` = %i OR `difficultyEntry2` = %i OR `difficultyEntry3` = %i', $this->typeId, $this->typeId, $this->typeId, $this->typeId))) + $mapType = max($mapType, $mt); + // npc has difficulty dummys: 2+ dummies -> definitely raid (10/25 + hc); 1 dummy -> may be heroic (used here), but may also be 10/25-raid + if ($_altIds) + $mapType = max($mapType, count($_altIds) > 1 ? 2 : 1); + // for event encounters a single npc may be reused over multiple difficulties but have different chests assigned + if ($d = DB::Aowow()->selectCell('SELECT MAX(`difficulty`) FROM ::loot_link WHERE `npcId` IN %in', array_merge($_altIds, [$this->typeId]))) + $mapType = max($mapType, $d > 2 ? 2 : 1); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // Event (ignore events, where the object only gets removed) + if ($_ = DB::World()->selectCol('SELECT DISTINCT ge.`eventEntry` FROM game_event ge, game_event_creature gec, creature c WHERE ge.`eventEntry` = gec.`eventEntry` AND c.`guid` = gec.`guid` AND c.`id` = %i', $this->typeId)) + { + $this->extendGlobalIds(Type::WORLDEVENT, ...$_); + $ev = []; + foreach ($_ as $i => $e) + $ev[] = ($i % 2 ? '[br]' : ' ') . '[event='.$e.']'; + + $infobox[] = Lang::game('eventShort', [implode(',', $ev)]); + } + + // Level + if ($this->subject->getField('rank') != NPC_RANK_BOSS) + { + $level = $this->subject->getField('minLevel'); + $maxLvl = $this->subject->getField('maxLevel'); + if ($level < $maxLvl) + $level .= ' - '.$maxLvl; + } + else // Boss Level + $level = '??'; + + $infobox[] = Lang::game('level').Lang::main('colon').$level; + + // Classification + if ($_ = $this->subject->getField('rank')) // != NPC_RANK_NORMAL + { + $str = $this->subject->isBoss() ? '[span class=icon-boss]'.Lang::npc('rank', $_).'[/span]' : Lang::npc('rank', $_); + $infobox[] = Lang::npc('classification', [$str]); + } + + // Reaction + $color = fn (int $r) : string => match($r) + { + 1 => 'q2', // q2 green + -1 => 'q10', // q10 red + default => 'q' // q yellow + }; + $infobox[] = Lang::npc('react', ['[color='.$color($this->subject->getField('A')).']A[/color] [color='.$color($this->subject->getField('H')).']H[/color]']); + + // Faction + $this->extendGlobalIds(Type::FACTION, $this->subject->getField('factionId')); + $infobox[] = Util::ucFirst(Lang::game('faction')).Lang::main('colon').'[faction='.$this->subject->getField('factionId').']'; + + // Tameable + if ($_typeFlags & NPC_TYPEFLAG_TAMEABLE) + if ($_ = $this->subject->getField('family')) + $infobox[] = Lang::npc('tameable', ['[url=pet='.$_.']'.Lang::game('fa', $_).'[/url]']); + + // Wealth + if ($_ = intVal(($this->subject->getField('minGold') + $this->subject->getField('maxGold')) / 2)) + $infobox[] = Lang::npc('worth', ['[tooltip=tooltip_avgmoneydropped][money='.$_.'][/tooltip]']); + + // is Vehicle + if ($this->subject->getField('vehicleId')) + $infobox[] = Lang::npc('vehicle'); + + // is visible as ghost (redundant to extraFlags) + if ($this->subject->getField('npcflag') & (NPC_FLAG_SPIRIT_HEALER | NPC_FLAG_SPIRIT_GUIDE)) + $infobox[] = Lang::npc('extraFlags', CREATURE_FLAG_EXTRA_GHOST_VISIBILITY); + + // id + $infobox[] = Lang::npc('id') . $this->typeId; + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if (User::isInGroup(U_GROUP_EMPLOYEE)) + { + $spawnData = DB::Aowow()->selectAssoc('SELECT `guid` AS "0", `ScriptName` AS "1", `StringId` AS "2" FROM ::spawns WHERE `type` = %i AND `typeId` = %i AND `ScriptName` IS NOT NULL ORDER BY `guid` ASC', Type::NPC, $this->typeId); + + // AI + $scripts = null; + if ($_ = $this->subject->getField('ScriptOrAI')) + $scripts = match($_) + { + 'NullAI', 'AggressorAI', + 'ReactorAI', 'GuardAI', + 'PetAI', 'TotemAI', + 'SmartAI' => 'AI'.Lang::main('colon').$_, + default => 'Script'.Lang::main('colon').$_ + }; + + + if ($moreAI = array_filter(array_column($spawnData, 1, 0))) + { + $scripts ??= 'Script'.Lang::main('colon').'…'; + $scripts = '[toggler=hidden id=scriptName]'.$scripts.'[/toggler][div=hidden id=scriptName][ul]'; + foreach ($moreAI as $guid => $script) + $scripts .= sprintf('[li]GUID: %d - %s[/li]', $guid, $script); + + $scripts .= '[/ul][/div]'; + } + + if ($scripts) + $infobox[] = $scripts; + + // StringId + $stringIDs = null; + if ($_ = $this->subject->getField('StringId')) + $stringIDs = 'StringID'.Lang::main('colon').$_; + + if ($moreStrings = array_filter(array_column($spawnData, 2, 0))) + { + $stringIDs ??= 'StringID'.Lang::main('colon').'…'; + $stringIDs = '[toggler=hidden id=stringId]'.$stringIDs.'[/toggler][div=hidden id=stringId][ul]'; + foreach ($moreStrings as $guid => $stringId) + $stringIDs .= sprintf('[li]GUID: %d - %s[/li]', $guid, $stringId); + + $stringIDs .= '[/ul][/div]'; + } + + if ($stringIDs) + $infobox[] = $stringIDs; + + // Mechanic immune + if ($immuneMask = $this->subject->getField('mechanicImmuneMask')) + { + $buff = []; + for ($i = 0; $i < 31; $i++) + if ($immuneMask & (1 << $i)) + $buff[] = (!fMod(count($buff), 3) ? "\n" : '').'[url=?spells&filter=me='.($i + 1).']'.Lang::game('me', $i + 1).'[/url]'; + + $infobox[] = Lang::npc('mechanicimmune', [implode(', ', $buff)]); + } + + // extra flags + if ($flagsExtra = $this->subject->getField('flagsExtra')) + { + $buff = []; + foreach (Lang::npc('extraFlags') as $idx => $str) + if ($flagsExtra & $idx) + $buff[] = $str; + + if ($buff) + $infobox[] = Lang::npc('_extraFlags').'[ul][li]'.implode('[/li][li]', $buff).'[/li][/ul]'; + } + + // Mode dummy references + if ($this->altNPCs) + { + $this->extendGlobalData($this->altNPCs->getJSGlobals()); + $buff = Lang::npc('versions').'[ul]'; + foreach ($this->altNPCs->iterate() as $id => $__) + $buff .= '[li][npc='.$id.'][/li]'; + $infobox[] = $buff.'[/ul]'; + } + } + + if ($stats = $this->getCreatureStats($mapType, $_altIds)) + $infobox[] = Lang::npc('stats').($_altIds ? ' ('.Lang::game('modes', $mapType, 0).')' : '').Lang::main('colon').'[ul][li]'.implode('[/li][li]', $stats).'[/li][/ul]'; + + if ($infobox) + { + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + $this->extendGlobalData($this->infobox->getJsGlobals()); + } + + + /****************/ + /* Main Content */ + /****************/ + + // get spawns and path + if ($spawns = $this->subject->getSpawns(SPAWNINFO_FULL)) + { + $this->addDataLoader('zones'); + $this->map = array( + ['parent' => 'mapper-generic'], // Mapper + $spawns, // mapperData + null, // ShowOnMap + [Lang::npc('foundIn')] // foundIn + ); + foreach ($spawns as $areaId => $_) + $this->map[3][$areaId] = ZoneList::getName($areaId); + } + + // smart AI + $sai = null; + if ($this->subject->getField('ScriptOrAI') == 'SmartAI') + { + $sai = new SmartAI(SmartAI::SRC_TYPE_CREATURE, $this->typeId); + if (!$sai->prepare()) // no smartAI found .. check per guid + { + // at least one of many + $guids = DB::World()->selectCol('SELECT `guid` FROM creature WHERE `id` = %i', $this->typeId); + while ($_ = array_pop($guids)) + { + $sai = new SmartAI(SmartAI::SRC_TYPE_CREATURE, -$_, ['baseEntry' => $this->typeId, 'title' => ' [small](for GUID: '.$_.')[/small]']); + if ($sai->prepare()) + break; + } + } + + if ($sai->prepare()) + { + $this->extendGlobalData($sai->getJSGlobals()); + $this->smartAI = $sai->getMarkup(); + } + else + trigger_error('Creature has `AIName`: SmartAI set in template but no SmartAI defined.'); + } + + // consider pooled spawns + $this->quotes = $this->getQuotes(); + $this->reputation = $this->getOnKillRep($_altIds, $mapType); + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_VIEW3D => ['type' => Type::NPC, 'typeId' => $this->typeId, 'displayId' => $this->subject->getRandomModelId()] + ); + + if ($this->subject->getField('humanoid')) + $this->redButtons[BUTTON_VIEW3D]['humanoid'] = 1; + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: abilities / tab_controlledabilities (dep: VehicleId) + $tplSpells = []; + $genSpells = []; + $spellClick = []; + $conditions = [DB::OR]; + + for ($i = 1; $i < 9; $i++) + if ($_ = $this->subject->getField('spell'.$i)) + $tplSpells[] = $_; + + if ($tplSpells) + $conditions[] = ['id', $tplSpells]; + + if ($smartSpells = SmartAI::getSpellCastsForOwner($this->typeId, SmartAI::SRC_TYPE_CREATURE)) + $genSpells = $smartSpells; + + if ($auras = DB::World()->selectCell('SELECT `auras` FROM creature_template_addon WHERE `entry` = %i', $this->typeId)) + { + $auras = preg_replace('/[^\d ]/', ' ', $auras); // remove erroneous chars from string + $genSpells = array_merge($genSpells, array_filter(explode(' ', $auras))); + } + + if ($genSpells) + $conditions[] = ['id', $genSpells]; + + if ($spellClick = DB::World()->selectAssoc('SELECT `spell_id` AS ARRAY_KEY, `cast_flags` AS "0", `user_type` AS "1" FROM npc_spellclick_spells WHERE `npc_entry` = %i', $this->typeId)) + { + $genSpells = array_merge($genSpells, array_keys($spellClick)); + $conditions[] = ['id', array_keys($spellClick)]; + } + + // Pet-Abilities + if (($_typeFlags & NPC_TYPEFLAG_TAMEABLE) && ($_ = $this->subject->getField('family'))) + { + $skill = 0; + $mask = 0x0; + foreach (Game::$skillLineMask[-1] as $idx => [$familyId, $skillLineId]) + { + if ($familyId != $_) + continue; + + $skill = $skillLineId; + $mask = 1 << $idx; + break; + } + $conditions[] = [ + DB::AND, + ['s.typeCat', -3], + [ + DB::OR, + ['skillLine1', $skill], + [DB::AND, ['skillLine1', 0, '>'], ['skillLine2OrMask', $skill]], + [DB::AND, ['skillLine1', -1], ['skillLine2OrMask', $mask, '&']] + ] + ]; + } + + if (count($conditions) > 1) + { + $abilities = new SpellList($conditions); + if (!$abilities->error) + { + $this->extendGlobalData($abilities->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + $controled = $abilities->getListviewData(); + $normal = []; + + foreach ($controled as $id => $values) + { + if (isset($spellClick[$id])) + $values['spellclick'] = $spellClick[$id]; + + if (in_array($id, $genSpells)) + { + $normal[$id] = $values; + if (!in_array($id, $tplSpells)) + unset($controled[$id]); + } + } + + $cnd = new Conditions(); + $cnd->getBySource(Conditions::SRC_VEHICLE_SPELL, group: $this->typeId)->prepare(); + if ($cnd->toListviewColumn($controled, $extraCols, $this->typeId, 'id')) + $this->extendGlobalData($cnd->getJsGlobals()); + + if ($normal) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $normal, + 'name' => '$LANG.tab_abilities', + 'id' => 'abilities' + ), SpellList::$brickFile)); + + if ($controled) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $controled, + 'name' => '$LANG.tab_controlledabilities', + 'id' => 'controlled-abilities', + 'extraCols' => $extraCols ?: null + ), SpellList::$brickFile)); + } + } + + // tab: summoned by [spell] + $conditions = array( + DB::OR, + [DB::AND, ['effect1Id', [SPELL_EFFECT_SUMMON, SPELL_EFFECT_SUMMON_PET, SPELL_EFFECT_SUMMON_DEMON]], ['effect1MiscValue', $this->typeId]], + [DB::AND, ['effect2Id', [SPELL_EFFECT_SUMMON, SPELL_EFFECT_SUMMON_PET, SPELL_EFFECT_SUMMON_DEMON]], ['effect2MiscValue', $this->typeId]], + [DB::AND, ['effect3Id', [SPELL_EFFECT_SUMMON, SPELL_EFFECT_SUMMON_PET, SPELL_EFFECT_SUMMON_DEMON]], ['effect3MiscValue', $this->typeId]] + ); + + $sbSpell = new SpellList($conditions); + if (!$sbSpell->error) + { + $this->extendGlobalData($sbSpell->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sbSpell->getListviewData(), + 'name' => '$LANG.tab_summonedby', + 'id' => 'summoned-by-spell' + ), SpellList::$brickFile)); + } + + // tab: summoned by [NPC] + $sb = SmartAI::getOwnerOfNPCSummon($this->typeId); + if (!empty($sb[Type::NPC])) + { + $sbNPC = new CreatureList(array(['id', $sb[Type::NPC]])); + if (!$sbNPC->error) + { + $this->extendGlobalData($sbNPC->getJSGlobals()); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sbNPC->getListviewData(), + 'name' => '$LANG.tab_summonedby', + 'id' => 'summoned-by-npc' + ), CreatureList::$brickFile)); + } + } + + // tab: summoned by [Object] + if (!empty($sb[Type::OBJECT])) + { + $sbGO = new GameObjectList(array(['id', $sb[Type::OBJECT]])); + if (!$sbGO->error) + { + $this->extendGlobalData($sbGO->getJSGlobals()); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sbGO->getListviewData(), + 'name' => '$LANG.tab_summonedby', + 'id' => 'summoned-by-object' + ), GameObjectList::$brickFile)); + } + } + + // tab: teaches + if ($this->subject->getField('npcflag') & NPC_FLAG_TRAINER) + { + $teachQuery = + 'SELECT ts.`SpellId` AS ARRAY_KEY, ts.`MoneyCost` AS "cost", ts.`ReqSkillLine` AS "reqSkillId", ts.`ReqSkillRank` AS "reqSkillValue", ts.`ReqLevel` AS "reqLevel", ts.`ReqAbility1` AS "reqSpellId1", ts.`reqAbility2` AS "reqSpellId2" + FROM trainer_spell ts + JOIN creature_default_trainer cdt ON cdt.`TrainerId` = ts.`TrainerId` + WHERE cdt.`Creatureid` = %i'; + + if ($tSpells = DB::World()->selectAssoc($teachQuery, $this->typeId)) + { + $teaches = new SpellList(array(['id', array_keys($tSpells)])); + if (!$teaches->error) + { + $this->extendGlobalData($teaches->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + $data = $teaches->getListviewData(); + + $extraCols = []; + $cnd = new Conditions(); + foreach ($tSpells as $sId => $train) + { + if (empty($data[$sId])) + continue; + + if ($_ = $train['reqSkillId']) + if (count($data[$sId]['skill']) == 1 && $_ != $data[$sId]['skill'][0]) + $cnd->addExternalCondition(Conditions::SRC_NONE, $sId, [Conditions::SKILL, $_, $train['reqSkillValue']]); + + for ($i = 1; $i < 3; $i++) + if ($_ = $train['reqSpellId'.$i]) + $cnd->addExternalCondition(Conditions::SRC_NONE, $sId, [Conditions::SPELL, $_]); + + if ($_ = $train['reqLevel']) + { + if (!isset($extraCols[1])) + $extraCols[1] = "\$Listview.funcBox.createSimpleCol('reqLevel', LANG.tooltip_reqlevel, '7%', 'reqLevel')"; + + $data[$sId]['reqLevel'] = $_; + } + + if ($_ = $train['cost']) + $data[$sId]['trainingcost'] = $_; + } + + if ($cnd->toListviewColumn($data, $extraCols)) + $this->extendGlobalData($cnd->getJsGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + 'name' => '$LANG.tab_teaches', + 'id' => 'teaches', + 'visibleCols' => ['trainingcost'], + 'extraCols' => $extraCols ?: null + ), SpellList::$brickFile)); + } + } + else + trigger_error('NPC '.$this->typeId.' is flagged as trainer, but doesn\'t have any spells set', E_USER_WARNING); + } + + // tab: sells + if ($sells = DB::World()->selectCol( + 'SELECT nv.`item` FROM npc_vendor nv WHERE nv.`entry` = %i UNION + SELECT nv1.`item` FROM npc_vendor nv1 JOIN npc_vendor nv2 ON -nv1.`entry` = nv2.`item` WHERE nv2.`entry` = %i UNION + SELECT genv.`item` FROM game_event_npc_vendor genv JOIN creature c ON genv.`guid` = c.`guid` WHERE c.`id` = %i', + $this->typeId, $this->typeId, $this->typeId) + ) + { + $soldItems = new ItemList(array(['id', $sells])); + if (!$soldItems->error) + { + $colAddIn = ''; + $extraCols = ["\$Listview.funcBox.createSimpleCol('stack', 'stack', '10%', 'stack')", '$Listview.extraCols.cost']; + + $lvData = $soldItems->getListviewData(ITEMINFO_VENDOR, [Type::NPC => [$this->typeId]]); + + if (array_column($lvData, 'condition')) + $extraCols[] = '$Listview.extraCols.condition'; + + if (array_filter(array_column($lvData, 'restock'))) + { + $extraCols[] = '$_'; + $colAddIn = 'vendorRestockCol'; + } + + $cnd = new Conditions(); + if ($cnd->getBySource(Conditions::SRC_NPC_VENDOR, group: $this->typeId)->prepare()) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $cnd->toListviewColumn($lvData, $extraCols, $this->typeId, 'id'); + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lvData, + 'name' => '$LANG.tab_sells', + 'id' => 'currency-for', + 'extraCols' => array_unique($extraCols) + ), ItemList::$brickFile, $colAddIn)); + + $this->extendGlobalData($soldItems->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + } + + // tabs: this creature contains.. + if ($this->subject->isGatherable()) + $skinTab = ['$LANG.tab_herbalism', 'herbalism', SKILL_HERBALISM]; + else if ($this->subject->isMineable()) + $skinTab = ['$LANG.tab_mining', 'mining', SKILL_MINING]; + else if ($this->subject->isSalvageable()) + $skinTab = ['$LANG.tab_engineering', 'engineering', SKILL_ENGINEERING]; + else + $skinTab = ['$LANG.tab_skinning', 'skinning', SKILL_SKINNING]; + + $sourceFor = array( + 0 => [Loot::CREATURE, [4 => $this->subject->getField('lootId')], '$LANG.tab_drops', 'drops', [ ], ''], + 1 => [Loot::GAMEOBJECT, [], '$LANG.tab_drops', 'drops-object', [ ], ''], + 2 => [Loot::PICKPOCKET, [4 => $this->subject->getField('pickpocketLootId')], '$LANG.tab_pickpocketing', 'pickpocketing', ['side', 'slot', 'reqlevel'], ''], + 3 => [Loot::SKINNING, [4 => $this->subject->getField('skinLootId')], $skinTab[0], $skinTab[1], ['side', 'slot', 'reqlevel'], ''] + ); + + /* loot tabs to sub tabs + * (1 << 0) => '$LANG.tab_heroic', + * (1 << 1) => '$LANG.tab_normal', + * (1 << 2) => '$LANG.tab_drops', + * (1 << 3) => '$$WH.sprintf(LANG.tab_normalX, 10)', + * (1 << 4) => '$$WH.sprintf(LANG.tab_normalX, 25)', + * (1 << 5) => '$$WH.sprintf(LANG.tab_heroicX, 10)', + * (1 << 6) => '$$WH.sprintf(LANG.tab_heroicX, 25)' + */ + + $getBit = function(int $type, int $difficulty) : int + { + if ($type == 1) // dungeon + return 1 << (2 - $difficulty); + if ($type == 2) // raid + return 1 << (2 + $difficulty); + return 4; // generic case + }; + + foreach (DB::Aowow()->selectAssoc('SELECT l.`difficulty` AS ARRAY_KEY, o.`id`, o.`lootId`, o.`name_loc0`, o.`name_loc2`, o.`name_loc3`, o.`name_loc4`, o.`name_loc6`, o.`name_loc8` FROM ::loot_link l JOIN ::objects o ON o.`id` = l.`objectId` WHERE l.`npcId` = %i ORDER BY `difficulty` ASC', $this->typeId) as $difficulty => $lgo) + { + $sourceFor[1][1][$getBit($mapType, $difficulty)] = $lgo['lootId']; + $sourceFor[1][5] = $sourceFor[1][5] ?: '$$WH.sprintf(LANG.lvnote_npcobjectsource, '.$lgo['id'].', "'.Util::localizedString($lgo, 'name').'")'; + } + + if ($_altIds) + { + if ($mapType == 1) // map generic loot to dungeon NH + { + $sourceFor[0][1] = [2 => $sourceFor[0][1][4]]; + $sourceFor[2][1] = [2 => $sourceFor[2][1][4]]; + $sourceFor[3][1] = [2 => $sourceFor[3][1][4]]; + } + if ($mapType == 2) // map generic loot to raid 10NH + { + $sourceFor[0][1] = [8 => $sourceFor[0][1][4]]; + $sourceFor[2][1] = [8 => $sourceFor[2][1][4]]; + $sourceFor[3][1] = [8 => $sourceFor[3][1][4]]; + } + + foreach ($this->altNPCs->iterate() as $id => $__) + { + foreach (DB::Aowow()->selectAssoc('SELECT l.`difficulty` AS ARRAY_KEY, o.`id`, o.`lootId`, o.`name_loc0`, o.`name_loc2`, o.`name_loc3`, o.`name_loc4`, o.`name_loc6`, o.`name_loc8` FROM ::loot_link l JOIN ::objects o ON o.`id` = l.`objectId` WHERE l.`npcId` = %i ORDER BY `difficulty` ASC', $id) as $difficulty => $lgo) + { + $sourceFor[1][1][$getBit($mapType, $difficulty)] = $lgo['lootId']; + $sourceFor[1][5] = $sourceFor[1][5] ?: '$$WH.sprintf(LANG.lvnote_npcobjectsource, '.$lgo['id'].', "'.Util::localizedString($lgo, 'name').'")'; + } + + if ($lootId = $this->altNPCs->getField('lootId')) + $sourceFor[0][1][$getBit($mapType, $_altIds[$id] + 1)] = $lootId; + if ($lootId = $this->altNPCs->getField('pickpocketLootId')) + $sourceFor[2][1][$getBit($mapType, $_altIds[$id] + 1)] = $lootId; + if ($lootId = $this->altNPCs->getField('skinLootId')) + $sourceFor[3][1][$getBit($mapType, $_altIds[$id] + 1)] = $lootId; + } + } + + foreach ($sourceFor as [$lootTpl, $lootEntries, $tabName, $tabId, $hiddenCols, $note]) + { + $creatureLoot = new LootByContainer(); + if ($creatureLoot->getByContainer($lootTpl, $lootEntries)) + { + $extraCols = $creatureLoot->extraCols; + array_push($extraCols, '$Listview.extraCols.count', '$Listview.extraCols.percent'); + if (count($lootEntries) > 1) + $extraCols[] = '$Listview.extraCols.mode'; + + $hiddenCols[] = 'count'; + + $this->extendGlobalData($creatureLoot->jsGlobals); + + $tabData = array( + 'data' => $creatureLoot->getResult(), + 'id' => $tabId, + 'name' => $tabName, + 'extraCols' => array_unique($extraCols), + 'hiddenCols' => $hiddenCols ?: null, + 'sort' => ['-percent', 'name'], + '_totalCount' => 10000, + 'computeDataFunc' => '$Listview.funcBox.initLootTable', + 'onAfterCreate' => '$Listview.funcBox.addModeIndicator', + ); + + if ($note) + $tabData['note'] = $note; + else if ($lootTpl == Loot::SKINNING) + $tabData['note'] = ''.Lang::formatSkillBreakpoints(Game::getBreakpointsForSkill($skinTab[2], $this->subject->getField('maxLevel') * 5), Lang::FMT_HTML).''; + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + } + } + + // tab: starts quest + // tab: ends quest + $startEnd = new QuestList(array(['qse.type', Type::NPC], ['qse.typeId', $this->typeId])); + if (!$startEnd->error) + { + $this->extendGlobalData($startEnd->getJSGlobals()); + $lvData = $startEnd->getListviewData(); + $start = $end = []; + + foreach ($startEnd->iterate() as $id => $__) + { + if ($startEnd->getField('method') & 0x1) + $start[] = $lvData[$id]; + if ($startEnd->getField('method') & 0x2) + $end[] = $lvData[$id]; + } + + if ($start) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $start, + 'name' => '$LANG.tab_starts', + 'id' => 'starts' + ), QuestList::$brickFile)); + + if ($end) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $end, + 'name' => '$LANG.tab_ends', + 'id' => 'ends' + ), QuestList::$brickFile)); + } + + // tab: objective of quest + $conditions = array( + DB::OR, + [DB::AND, ['reqNpcOrGo1', [$this->typeId]], ['reqNpcOrGoCount1', 0, '>']], + [DB::AND, ['reqNpcOrGo2', [$this->typeId]], ['reqNpcOrGoCount2', 0, '>']], + [DB::AND, ['reqNpcOrGo3', [$this->typeId]], ['reqNpcOrGoCount3', 0, '>']], + [DB::AND, ['reqNpcOrGo4', [$this->typeId]], ['reqNpcOrGoCount4', 0, '>']] + ); + foreach ([1, 2] as $i) + if (($_ = $this->subject->getField('KillCredit'.$i)) > 0) + for ($j = 1; $j < 5; $j++) + $conditions[$j][1][1][] = $_; + + $objectiveOf = new QuestList($conditions); + if (!$objectiveOf->error) + { + $this->extendGlobalData($objectiveOf->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $objectiveOf->getListviewData(), + 'name' => '$LANG.tab_objectiveof', + 'id' => 'objective-of' + ), QuestList::$brickFile)); + } + + // tab: criteria of [ACHIEVEMENT_CRITERIA_TYPE_KILL_CREATURE_TYPE have no data set to check for] + $conditions = array( + DB::AND, + ['ac.type', [ACHIEVEMENT_CRITERIA_TYPE_KILL_CREATURE, ACHIEVEMENT_CRITERIA_TYPE_KILLED_BY_CREATURE]], + ['ac.value1', $this->typeId] + ); + + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` = %i AND `value1` = %i', ACHIEVEMENT_CRITERIA_DATA_TYPE_T_CREATURE, $this->typeId)) + $conditions = [DB::OR, $conditions, ['ac.id', $extraCrt]]; + + $crtOf = new AchievementList($conditions); + if (!$crtOf->error) + { + $this->extendGlobalData($crtOf->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $crtOf->getListviewData(), + 'name' => '$LANG.tab_criteriaof', + 'id' => 'criteria-of' + ), AchievementList::$brickFile)); + } + + // tab: passengers + if ($_ = DB::World()->selectCol('SELECT `accessory_entry` AS ARRAY_KEY, GROUP_CONCAT(`seat_id` SEPARATOR ", ") FROM vehicle_template_accessory WHERE `entry` = %i GROUP BY `accessory_entry`', $this->typeId)) + { + $passengers = new CreatureList(array(['id', array_keys($_)])); + if (!$passengers->error) + { + $data = $passengers->getListviewData(); + + if (User::isInGroup(U_GROUP_STAFF)) + foreach ($data as $id => &$d) + $d['seat'] = $_[$id]; + + $this->extendGlobalData($passengers->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = array( + 'data' => $data, + 'name' => Lang::npc('accessory'), + 'id' => 'accessory' + ); + + if (User::isInGroup(U_GROUP_STAFF)) + $tabData['extraCols'] = ["\$Listview.funcBox.createSimpleCol('seat', '".Lang::npc('seat')."', '10%', 'seat')"]; + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile)); + } + } + + /* tab sounds: + * activity sounds => CreatureDisplayInfo.dbc => (CreatureModelData.dbc => ) CreatureSoundData.dbc + * AI => smart_scripts + * Dialogue VO => creature_text + * onClick VO => CreatureDisplayInfo.dbc => NPCSounds.dbc + */ + $this->soundIds = array_merge($this->soundIds, SmartAI::getSoundsPlayedForOwner($this->typeId, SmartAI::SRC_TYPE_CREATURE)); + + // up to 4 possible displayIds .. for the love of things betwixt, just use the first! + $activitySounds = DB::Aowow()->selectRow('SELECT * FROM ::creature_sounds WHERE `id` = %i', $this->subject->getField('displayId1')); + array_shift($activitySounds); // remove id-column + $this->soundIds = array_merge($this->soundIds, array_values($activitySounds)); + + if ($this->soundIds) + { + $sounds = new SoundList(array(['id', $this->soundIds])); + if (!$sounds->error) + { + $data = $sounds->getListviewData(); + foreach ($activitySounds as $activity => $id) + if (isset($data[$id])) + $data[$id]['activity'] = $activity; // no index, js wants a string :( + + $this->extendGlobalData($sounds->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + 'visibleCols' => $activitySounds ? 'activity' : null + ), SoundList::$brickFile)); + } + } + + // tab: conditions + $cnd = new Conditions(); + $cnd->getBySource(Conditions::SRC_CREATURE_TEMPLATE_VEHICLE, entry: $this->typeId) + ->getBySource(Conditions::SRC_SPELL_CLICK_EVENT, group: $this->typeId) + ->getByCondition(Type::NPC, $this->typeId) + ->prepare(); + if ($tab = $cnd->toListviewTab()) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } + + private function getRepForId(array $entries, array &$spillover) : array + { + $rows = DB::World()->selectAssoc( + 'SELECT `creature_id` AS "npc", `RewOnKillRepFaction1` AS "faction", `RewOnKillRepValue1` AS "qty", `MaxStanding1` AS "maxRank", `isTeamAward1` AS "spillover" + FROM creature_onkill_reputation WHERE `creature_id` IN %in AND `RewOnKillRepFaction1` > 0 UNION + SELECT `creature_id` AS "npc", `RewOnKillRepFaction2` AS "faction", `RewOnKillRepValue2` AS "qty", `MaxStanding2` AS "maxRank", `isTeamAward2` AS "spillover" + FROM creature_onkill_reputation WHERE `creature_id` IN %in AND `RewOnKillRepFaction2` > 0', + $entries, $entries + ); + + $factions = new FactionList(array(['id', array_column($rows, 'faction')])); + $result = []; + + foreach ($rows as $row) + { + if (!$factions->getEntry($row['faction'])) + continue; + + $set = array( + $row['faction'], // factionId + [$row['qty'], 0], // qty + $factions->getField('name', true), // name + $row['maxRank'] && $row['maxRank'] < REP_EXALTED ? Lang::game('rep', $row['maxRank']) : null, // cap + $row['npc'], // npcId + 0 // spilloverCat + ); + + $cuRate = DB::World()->selectCell('SELECT `creature_rate` FROM reputation_reward_rate WHERE `creature_rate` <> 1 AND `faction` = %i', $row['faction']); + if ($cuRate && User::isInGroup(U_GROUP_EMPLOYEE)) + $set[1][1] = $set[1][0] . sprintf(Util::$dfnString, Lang::faction('customRewRate'), ($set[1][0] > 0 ? '+' : '').($set[1][0] * ($cuRate - 1))); + else if ($cuRate) + $set[1][1] = $set[1][0] * $cuRate; + + if ($row['spillover']) + { + $spill = [[$set[1][0] / 2, 0], $row['maxRank']]; + if ($cuRate && User::isInGroup(U_GROUP_EMPLOYEE)) + $spill[0][1] = $spill[0][0] . sprintf(Util::$dfnString, Lang::faction('customRewRate'), ($set[1][0] > 0 ? '+' : '').($spill[0][0] * ($cuRate - 1) * 0.5)); + else if ($cuRate) + $spill[0][1] = $set[1][1] / 2; + + $spillover[$factions->getField('cat')] = $spill; + $set[5] = $factions->getField('cat'); // set spillover + } + + $result[] = $set; + } + + return $result; + } + + private function getOnKillRep(array $dummyIds, int $mapType) : array + { + $spilledParents = []; + $reputation = []; + + // base NPC + if ($base = $this->getRepForId([$this->typeId], $spilledParents)) + $reputation[] = [Lang::game('modes', 1, 0), $base]; + + // difficulty dummys + if ($dummyIds && ($mapType == 1 || $mapType == 2)) + { + $alt = []; + $rep = $this->getRepForId(array_keys($dummyIds), $spilledParents); + + // order by difficulty + foreach ($rep as $i => [, , , , $npcId]) + $alt[$dummyIds[$npcId]][] = $rep[$i]; + + // apply by difficulty + foreach ($alt as $mode => $dat) + $reputation[] = [Lang::game('modes', $mapType, $mode), $dat]; + } + + // get spillover factions and apply + if ($spilledParents) + { + $spilled = new FactionList(array(['parentFactionId', array_keys($spilledParents)])); + + foreach ($reputation as $i => [, $data]) + { + foreach ($data as [$factionId, , , , , $spillover]) + { + if (!$spillover) + continue; + + foreach ($spilled->iterate() as $spId => $__) + { + // find parent + if ($spilled->getField('parentFactionId') != $spillover) + continue; + + // don't readd parent + if ($factionId == $spId) + continue; + + $spMax = $spilledParents[$spillover][1]; + + $reputation[$i][1][] = array( + $spId, + $spilledParents[$spillover][0], + $spilled->getField('name', true), + $spMax && $spMax < REP_EXALTED ? Lang::game('rep', $spMax) : null + ); + } + } + } + } + + return $reputation; + } + + private function getQuotes() : ?array + { + [$quotes, $nQuotes, $soundIds] = Game::getQuotesForCreature($this->typeId, true, $this->subject->getField('name', true)); + + if ($soundIds) + $this->soundIds = array_merge($this->soundIds, $soundIds); + + return $quotes ? [$quotes, $nQuotes] : null; + } + + private function getCreatureStats(int $mapType, array $altIds) : array + { + $stats = []; + $modes = []; // get difficulty versions if set + $hint = '[tooltip name=%3$s][table cellspacing=10][tr]%1s[/tr][/table][/tooltip][span class=tip tooltip=%3$s]%2s[/span]'; + $modeRow = '[tr][td]%s  [/td][td]%s[/td][/tr]'; + // Health + $health = $this->subject->getBaseStats('health'); + $stats['health'] = Util::ucFirst(Lang::spell('powerTypes', -2)).Lang::main('colon').($health[0] < $health[1] ? Lang::nf($health[0]).' - '.Lang::nf($health[1]) : Lang::nf($health[0])); + + // Mana (may be 0) + $mana = $this->subject->getBaseStats('power'); + $stats['mana'] = $mana[0] ? Lang::spell('powerTypes', 0).Lang::main('colon').($mana[0] < $mana[1] ? Lang::nf($mana[0]).' - '.Lang::nf($mana[1]) : Lang::nf($mana[0])) : ''; + + // Armor + $armor = $this->subject->getBaseStats('armor'); + $stats['armor'] = Lang::npc('armor').($armor[0] < $armor[1] ? Lang::nf($armor[0]).' - '.Lang::nf($armor[1]) : Lang::nf($armor[0])); + + // Resistances + $resNames = [null, 'hol', 'fir', 'nat', 'fro', 'sha', 'arc']; + $tmpRes = []; + $res = $this->subject->getBaseStats('resistance'); // $sc => $amt + $stats['resistance'] = ''; + foreach ($resNames as $idx => $sc) + { + if (!$sc) + continue; + + if ((1 << $idx) & $this->subject->getField('schoolImmuneMask')) + $tmpRes[] = '[tooltip=tooltip_immune][span class="tip moneyschool'.$sc.'"]∞[/span][/tooltip]'; + else if ($res[$idx]) + $tmpRes[] = '[span class="moneyschool'.$sc.'"]'.$res[$idx].'[/span]'; + } + + if ($tmpRes) + { + $stats['resistance'] = Lang::npc('resistances').'[br]'; + if (count($tmpRes) > 3) + $stats['resistance'] .= implode(' ', array_slice($tmpRes, 0, 3)).'[br]'.implode(' ', array_slice($tmpRes, 3)); + else + $stats['resistance'] .= implode(' ', $tmpRes); + } + + // Melee Damage + $melee = $this->subject->getBaseStats('melee'); + if ($_ = $this->subject->getField('dmgSchool')) // magic damage + $stats['melee'] = Lang::npc('melee').Lang::nf($melee[0]).' - '.Lang::nf($melee[1]).' ('.Lang::game('sc', $_).')'; + else // phys. damage + $stats['melee'] = Lang::npc('melee').Lang::nf($melee[0]).' - '.Lang::nf($melee[1]); + + // Ranged Damage + $ranged = $this->subject->getBaseStats('ranged'); + $stats['ranged'] = Lang::npc('ranged').Lang::nf($ranged[0]).' - '.Lang::nf($ranged[1]); + + foreach ($altIds as $id => $mode) + { + if (!$this->altNPCs->getEntry($id)) + continue; + + $m = Lang::game('modes', $mapType, $mode); + + // Health + $health = $this->altNPCs->getBaseStats('health'); + $modes['health'][] = sprintf($modeRow, $m, $health[0] < $health[1] ? Lang::nf($health[0]).' - '.Lang::nf($health[1]) : Lang::nf($health[0])); + + // Mana (may be 0) + $mana = $this->altNPCs->getBaseStats('power'); + $modes['mana'][] = $mana[0] ? sprintf($modeRow, $m, $mana[0] < $mana[1] ? Lang::nf($mana[0]).' - '.Lang::nf($mana[1]) : Lang::nf($mana[0])) : null; + + // Armor + $armor = $this->altNPCs->getBaseStats('armor'); + $modes['armor'][] = sprintf($modeRow, $m, $armor[0] < $armor[1] ? Lang::nf($armor[0]).' - '.Lang::nf($armor[1]) : Lang::nf($armor[0])); + + // Resistances + if (array_filter($this->altNPCs->getBaseStats('resistance'))) + { + if (!isset($modes['resistance'])) // init table head + $modes['resistance'][] = '[td][/td][td][span class="moneyschoolhol" style="margin: 0px 5px"][/span][/td][td][span class="moneyschoolfir" style="margin: 0px 5px"][/span][/td][td][span class="moneyschoolnat" style="margin: 0px 5px"][/span][/td][td][span class="moneyschoolfro" style="margin: 0px 5px"][/span][/td][td][span class="moneyschoolsha" style="margin: 0px 5px"][/span][/td][td][span class="moneyschoolarc" style="margin: 0px 5px"][/span][/td]'; + + if (!$stats['resistance']) // base creature has no resistance. -> display list item. + $stats['resistance'] = Lang::npc('resistances').'…'; + + $tmpRes = ''; + $res = $this->altNPCs->getBaseStats('resistance'); + foreach ($resNames as $idx => $sc) + { + if (!$sc) + continue; + + if ((1 << $idx) & $this->altNPCs->getField('schoolImmuneMask')) + $tmpRes .= '[td][span style="margin: 0px 5px"]∞[/span][/td]'; + else if ($res[$idx]) + $tmpRes .= '[td][span style="margin: 0px 5px"]'.$res[$idx].'[/span][/td]'; + } + + $modes['resistance'][] = '[td]'.$m.'    [/td]'.$tmpRes; + } + + // Melee Damage + $melee = $this->altNPCs->getBaseStats('melee'); + if ($_ = $this->altNPCs->getField('dmgSchool')) // magic damage + $modes['melee'][] = sprintf($modeRow, $m, Lang::nf($melee[0]).' - '.Lang::nf($melee[1]).' ('.Lang::game('sc', $_).')'); + else // phys. damage + $modes['melee'][] = sprintf($modeRow, $m, Lang::nf($melee[0]).' - '.Lang::nf($melee[1])); + + // Ranged Damage + $ranged = $this->altNPCs->getBaseStats('ranged'); + $modes['ranged'][] = sprintf($modeRow, $m, Lang::nf($ranged[0]).' - '.Lang::nf($ranged[1])); + } + + // todo: resistances can be present/missing in either $stats or $modes + // should be handled separately..? + + if ($modes) + foreach ($stats as $k => $v) + if ($v) + $stats[$k] = isset($modes[$k]) ? sprintf($hint, implode('[/tr][tr]', $modes[$k]), $v, $k) : $v; + + return $stats; + } +} + + +?> diff --git a/endpoints/npc/npc_power.php b/endpoints/npc/npc_power.php new file mode 100644 index 00000000..56fb95af --- /dev/null +++ b/endpoints/npc/npc_power.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(string $id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $creature = new CreatureList(array(['id', $this->typeId])); + if ($creature->error) + $this->cacheType = CACHE_TYPE_NONE; + else + $opts = array( + 'name' => $creature->getField('name', true), + 'tooltip' => $creature->renderTooltip(), + 'map' => $creature->getSpawns(SPAWNINFO_SHORT) + ); + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/npcs/npcs.php b/endpoints/npcs/npcs.php new file mode 100644 index 00000000..de03c9a4 --- /dev/null +++ b/endpoints/npcs/npcs.php @@ -0,0 +1,139 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + + public bool $petFamPanel = false; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new CreatureListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Lang::game('npcs'); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + if ($this->category) + { + $conditions[] = ['type', $this->category[0]]; + $this->petFamPanel = $this->category[0] == 1; + } + + $fiForm = $this->filter->values; + $fiRepCols = $this->filter->fiReputationCols; + + + /*************/ + /* Menu Path */ + /*************/ + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + if (count($fiForm['fa']) == 1) + $this->breadcrumb[] = $fiForm['fa'][0]; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::npc('cat', $this->category[0])); + + if (count($fiForm['fa']) == 1) + array_unshift($this->title, Lang::game('fa', $fiForm['fa'][0])); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + // beast subtypes are selected via filter + $tabData = ['data' => []]; + $npcs = new CreatureList($conditions, ['extraOpts' => $this->filter->extraOpts, 'calcTotal' => true]); + if (!$npcs->error) + { + $tabData['data'] = $npcs->getListviewData($fiRepCols ? NPCINFO_REP : 0x0); + if ($fiRepCols) // never use pretty-print + $tabData['extraCols'] = '$fi_getReputationCols('.Util::toJSON($fiRepCols, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE).')'; + else if ($this->filter->fiExtraCols) + $tabData['extraCols'] = '$fi_getExtraCols(fi_extraCols, 0, 0)'; + + if ($this->category) + $tabData['hiddenCols'] = ['type']; + + // create note if search limit was exceeded + if ($npcs->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_npcsfound', $npcs->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile)); + + parent::generate(); + + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay']); + } + + public static function onBeforeDisplay() : void + { + // sort for dropdown-menus + Lang::sort('game', 'fa'); + } +} + +?> diff --git a/endpoints/object/object.php b/endpoints/object/object.php new file mode 100644 index 00000000..60d9281e --- /dev/null +++ b/endpoints/object/object.php @@ -0,0 +1,650 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new GameObjectList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('object'), Lang::gameObject('notFound')); + + $this->h1 = Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_HTML); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('typeCat'); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_RAW), Util::ucFirst(Lang::game('object'))); + + + /**********************/ + /* Determine Map Type */ + /**********************/ + + if ($objectdifficulty = DB::Aowow()->selectAssoc( // has difficulty versions of itself + 'SELECT `normal10` AS "0", `normal25` AS "1", + `heroic10` AS "2", `heroic25` AS "3", + `mapType` AS ARRAY_KEY + FROM ::objectdifficulty + WHERE `normal10` = %i OR `normal25` = %i OR + `heroic10` = %i OR `heroic25` = %i', + $this->typeId, $this->typeId, $this->typeId, $this->typeId + )) + { + $this->mapType = key($objectdifficulty); + $this->difficulties = array_pop($objectdifficulty); + } + else if ($maps = DB::Aowow()->selectCell('SELECT IF(COUNT(DISTINCT `areaId`) > 1, 0, `areaId`) FROM ::spawns WHERE `type` = %i AND `typeId` = %i', Type::OBJECT, $this->typeId)) + { + $this->mapType = match ((int)DB::Aowow()->selectCell('SELECT `type` FROM ::zones WHERE `id` = %i', $maps)) + { + // MAP_TYPE_DUNGEON, + MAP_TYPE_DUNGEON_HC => 1, + // MAP_TYPE_RAID, + MAP_TYPE_MMODE_RAID, + MAP_TYPE_MMODE_RAID_HC => 2, + default => 0 + }; + } + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // Event (ignore events, where the object only gets removed) + if ($_ = DB::World()->selectCol('SELECT DISTINCT ge.`eventEntry` FROM game_event ge, game_event_gameobject geg, gameobject g WHERE ge.`eventEntry` = geg.`eventEntry` AND g.`guid` = geg.`guid` AND g.`id` = %i', $this->typeId)) + { + $this->extendGlobalIds(Type::WORLDEVENT, ...$_); + $ev = []; + foreach ($_ as $i => $e) + $ev[] = ($i % 2 ? '[br]' : ' ') . '[event='.$e.']'; + + $infobox[] = Lang::game('eventShort', [implode(',', $ev)]); + } + + // Faction + if ($_ = DB::Aowow()->selectCell('SELECT `factionId` FROM ::factiontemplate WHERE `id` = %i', $this->subject->getField('faction'))) + { + $this->extendGlobalIds(Type::FACTION, $_); + $infobox[] = Util::ucFirst(Lang::game('faction')).Lang::main('colon').'[faction='.$_.']'; + } + + // Reaction + $color = fn (int $r) : string => match($r) + { + 1 => 'q2', // q2 green + -1 => 'q10', // q10 red + default => 'q' // q yellow + }; + $infobox[] = Lang::npc('react', ['[color='.$color($this->subject->getField('A')).']A[/color] [color='.$color($this->subject->getField('H')).']H[/color]']); + + // reqSkill + difficulty + switch ($this->subject->getField('typeCat')) + { + case -3: // Herbalism + $infobox[] = Lang::game('requires', [Lang::spell('lockType', 2).' ('.$this->subject->getField('reqSkill').')']); + $infobox[] = Lang::formatSkillBreakpoints(Game::getBreakpointsForSkill(SKILL_HERBALISM, $this->subject->getField('reqSkill'))); + break; + case -4: // Mining + $infobox[] = Lang::game('requires', [Lang::spell('lockType', 3).' ('.$this->subject->getField('reqSkill').')']); + $infobox[] = Lang::formatSkillBreakpoints(Game::getBreakpointsForSkill(SKILL_MINING, $this->subject->getField('reqSkill'))); + break; + case -5: // Lockpicking + $infobox[] = Lang::game('requires', [Lang::spell('lockType', 1).' ('.$this->subject->getField('reqSkill').')']); + $infobox[] = Lang::formatSkillBreakpoints(Game::getBreakpointsForSkill(SKILL_LOCKPICKING, $this->subject->getField('reqSkill'))); + break; + default: // requires key .. maybe + { + $locks = Lang::getLocks($this->subject->getField('lockId'), $ids, true, Lang::FMT_MARKUP); + $l = []; + + foreach ($ids as $type => $typeIds) + $this->extendGlobalIds($type, ...$typeIds); + + foreach ($locks as $idx => $str) + { + if ($idx > 0) + $l[] = Lang::gameObject('key').Lang::main('colon').$str; + else if ($idx < 0) + $l[] = Lang::game('requires', [$str]); + } + + if ($l) + $infobox[] = implode('[br]', $l); + } + } + + // linked trap + if ($_ = $this->subject->getField('linkedTrap')) + { + $this->extendGlobalIds(Type::OBJECT, $_); + $infobox[] = Lang::gameObject('trap').Lang::main('colon').'[object='.$_.']'; + } + + // trap for X (note: moved to lv-tabs) + + // SpellFocus + if ($_ = $this->subject->getField('spellFocusId')) + { + if ($sfo = DB::Aowow()->selectRow('SELECT * FROM ::spellfocusobject WHERE `id` = %i', $_)) + { + $n = Util::localizedString($sfo, 'name'); + if (!is_null(GameObjectListFilter::getCriteriaIndex(50, $_))) + $n = '[url=?objects&filter=cr=50;crs='.$_.';crv=0]'.$n.'[/url]'; + + $infobox[] = '[tooltip name=focus]'.Lang::gameObject('focusDesc').'[/tooltip][span class=tip tooltip=focus]'.Lang::gameObject('focus').Lang::main('colon').$n.'[/span]'; + } + } + + // lootinfo: [min, max, restock] + if (([$min, $max, $restock] = $this->subject->getField('lootStack')) && $min) + { + $buff = Lang::spell('spellModOp', 4).Lang::main('colon').Util::createNumRange($min, $max); + + // ore veins don't have charges in 335a, but the functionality is still there + $infobox[] = $restock > 1 ? '[tooltip name=restock]'.Lang::gameObject('restock', [DateTime::formatTimeElapsed($restock * 1000)]).'[/tooltip][span class=tip tooltip=restock]'.$buff.'[/span]' : $buff; + } + + // meeting stone (only on type: OBJECT_MEETINGSTONE) + if ([$minLevel, $maxLevel, $zone] = $this->subject->getField('mStone')) + { + $this->extendGlobalIds(Type::ZONE, $zone); + $m = Lang::game('meetingStone').'[zone='.$zone.']'; + $l = Util::createNumRange($minLevel, min($maxLevel, MAX_LEVEL)); + + $infobox[] = $l ? '[tooltip name=meetingstone]'.Lang::game('reqLevel', [$l]).'[/tooltip][span class=tip tooltip=meetingstone]'.$m.'[/span]' : $m; + } + + // capture area (only on type: OBJECT_CAPTURE_POINT) + if ([$minPlayer, $maxPlayer, $minTime, $maxTime, $radius] = $this->subject->getField('capture')) + { + $buff = Lang::gameObject('capturePoint'); + + if ($minTime > 1 || $minPlayer || $radius) + $buff .= Lang::main('colon').'[ul]'; + + if ($minTime > 1) // sign shenannigans reverse the display order + $buff .= '[li]'.Lang::game('duration').Lang::main('colon').Util::createNumRange(-$maxTime, -$minTime, fn: fn($x) => DateTime::formatTimeElapsed(-$x * 1000)).'[/li]'; + + if ($minPlayer) + $buff .= '[li]'.Lang::main('players').Lang::main('colon').Util::createNumRange($minPlayer, $maxPlayer).'[/li]'; + + if ($radius) + $buff .= '[li]'.Lang::spell('range', [$radius]).'[/li]'; + + if ($minTime > 1 || $minPlayer || $radius) + $buff .= '[/ul]'; + + $infobox[] = $buff; + } + + // id + $infobox[] = Lang::gameObject('id') . $this->typeId; + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + // used in mode + foreach ($this->difficulties as $n => $id) + if ($id == $this->typeId) + $infobox[] = Lang::game('mode').Lang::game('modes', $this->mapType, $n); + + if (User::isInGroup(U_GROUP_EMPLOYEE)) + { + $spawnData = DB::Aowow()->selectAssoc('SELECT `guid` AS "0", `ScriptName` AS "1", `StringId` AS "2" FROM ::spawns WHERE `type` = %i AND `typeId` = %i AND `ScriptName` IS NOT NULL ORDER BY `guid` ASC', Type::OBJECT, $this->typeId); + + // AI + $scripts = null; + if ($_ = $this->subject->getField('ScriptOrAI')) + $scripts = ($_ == 'SmartGameObjectAI' ? 'AI' : 'Script').Lang::main('colon').$_; + + if ($moreAI = array_filter(array_column($spawnData, 1, 0))) + { + $scripts ??= 'Script'.Lang::main('colon').'…'; + $scripts = '[toggler=hidden id=scriptName]'.$scripts.'[/toggler][div=hidden id=scriptName][ul]'; + foreach ($moreAI as $guid => $script) + $scripts .= sprintf('[li]GUID: %d - %s[/li]', $guid, $script); + + $scripts .= '[/ul][/div]'; + } + + if ($scripts) + $infobox[] = $scripts; + + // StringId + $stringIDs = null; + if ($_ = $this->subject->getField('StringId')) + $stringIDs = 'StringID'.Lang::main('colon').$_; + + if ($moreStrings = array_filter(array_column($spawnData, 2, 0))) + { + $stringIDs ??= 'StringID'.Lang::main('colon').'…'; + $stringIDs = '[toggler=hidden id=stringId]'.$stringIDs.'[/toggler][div=hidden id=stringId][ul]'; + foreach ($moreStrings as $guid => $stringId) + $stringIDs .= sprintf('[li]GUID: %d - %s[/li]', $guid, $stringId); + + $stringIDs .= '[/ul][/div]'; + } + } + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + // pageText / book + if ($this->book = Game::getBook($this->subject->getField('pageTextId'))) + $this->addScript( + [SC_JS_FILE, 'js/Book.js'], + [SC_CSS_FILE, 'css/Book.css'] + ); + + // get spawns and path + if ($spawns = $this->subject->getSpawns(SPAWNINFO_FULL)) + { + $this->addDataLoader('zones'); + $this->map = array( + ['parent' => 'mapper-generic'], // Mapper + $spawns, // mapperData + null, // ShowOnMap + [Lang::gameObject('foundIn')] // foundIn + ); + foreach ($spawns as $areaId => $_) + $this->map[3][$areaId] = ZoneList::getName($areaId); + } + + + // todo (low): consider pooled spawns + + + if ($ll = DB::Aowow()->selectRow('SELECT * FROM ::loot_link WHERE `objectId` = %i ORDER BY `priority` DESC LIMIT 1', $this->typeId)) + { + // group encounter + if ($ll['encounterId']) + $this->relBoss = [$ll['npcId'], Lang::profiler('encounterNames', $ll['encounterId'])]; + // difficulty dummy + else if ($c = DB::Aowow()->selectRow('SELECT `id`, `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8` FROM ::creature WHERE `difficultyEntry1` = %i OR `difficultyEntry2` = %i OR `difficultyEntry3` = %i', $ll['npcId'], $ll['npcId'], $ll['npcId'])) + $this->relBoss = [$c['id'], Util::localizedString($c, 'name')]; + // base creature + else if ($c = DB::Aowow()->selectRow('SELECT `id`, `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8` FROM ::creature WHERE `id` = %i', $ll['npcId'])) + $this->relBoss = [$c['id'], Util::localizedString($c, 'name')]; + } + + // Smart AI + $sai = null; + if ($this->subject->getField('ScriptOrAI') == 'SmartGameObjectAI') + { + $sai = new SmartAI(SmartAI::SRC_TYPE_OBJECT, $this->typeId); + if (!$sai->prepare()) // no smartAI found .. check per guid + { + // at least one of many + $guids = DB::World()->selectCol('SELECT `guid` FROM gameobject WHERE `id` = %i', $this->typeId); + while ($_ = array_pop($guids)) + { + $sai = new SmartAI(SmartAI::SRC_TYPE_OBJECT, -$_, ['title' => ' [small](for GUID: '.$_.')[/small]']); + if ($sai->prepare()) + break; + } + } + + if ($sai->prepare()) + { + $this->extendGlobalData($sai->getJSGlobals()); + $this->smartAI = $sai->getMarkup(); + } + else + trigger_error('Gameobject has `AIName`: SmartGameObjectAI set in template but no SmartAI defined.'); + } + + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId], + BUTTON_VIEW3D => ['displayId' => $this->subject->getField('displayId'), 'type' => Type::OBJECT, 'typeId' => $this->typeId] + ); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: summoned by + $summonEffects = array( + SPELL_EFFECT_TRANS_DOOR, + SPELL_EFFECT_SUMMON_OBJECT_WILD, + SPELL_EFFECT_SUMMON_OBJECT_SLOT1, + SPELL_EFFECT_SUMMON_OBJECT_SLOT2, + SPELL_EFFECT_SUMMON_OBJECT_SLOT3, + SPELL_EFFECT_SUMMON_OBJECT_SLOT4 + ); + $conditions = array( + DB::OR, + [DB::AND, ['effect1Id', $summonEffects], ['effect1MiscValue', $this->typeId]], + [DB::AND, ['effect2Id', $summonEffects], ['effect2MiscValue', $this->typeId]], + [DB::AND, ['effect3Id', $summonEffects], ['effect3MiscValue', $this->typeId]] + ); + + $summons = new SpellList($conditions); + if (!$summons->error) + { + $this->extendGlobalData($summons->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $summons->getListviewData(), + 'id' => 'summoned-by', + 'name' => '$LANG.tab_summonedby' + ), SpellList::$brickFile)); + } + + // tab: related spells + if ($_ = $this->subject->getField('spells')) + { + $relSpells = new SpellList(array(['id', $_])); + if (!$relSpells->error) + { + $this->extendGlobalData($relSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + $data = $relSpells->getListviewData(); + + foreach ($data as $relId => $d) + $data[$relId]['trigger'] = array_search($relId, $_); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + 'id' => 'spells', + 'name' => '$LANG.tab_spells', + 'hiddenCols' => ['skill'], + 'extraCols' => ["\$Listview.funcBox.createSimpleCol('trigger', 'Condition', '10%', 'trigger')"] + ), SpellList::$brickFile)); + } + } + + // tab: criteria of + $acvs = new AchievementList(array(['ac.type', [ACHIEVEMENT_CRITERIA_TYPE_USE_GAMEOBJECT, ACHIEVEMENT_CRITERIA_TYPE_FISH_IN_GAMEOBJECT]], ['ac.value1', $this->typeId])); + if (!$acvs->error) + { + $this->extendGlobalData($acvs->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $acvs->getListviewData(), + 'id' => 'criteria-of', + 'name' => '$LANG.tab_criteriaof' + ), AchievementList::$brickFile)); + } + + // tab: starts quest + // tab: ends quest + $startEnd = new QuestList(array(['qse.type', Type::OBJECT], ['qse.typeId', $this->typeId])); + if (!$startEnd->error) + { + $this->extendGlobalData($startEnd->getJSGlobals()); + $lvData = $startEnd->getListviewData(); + $start = $end = []; + + foreach ($startEnd->iterate() as $id => $__) + { + if ($startEnd->getField('method') & 0x1) + $start[] = $lvData[$id]; + if ($startEnd->getField('method') & 0x2) + $end[] = $lvData[$id]; + } + + if ($start) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $start, + 'name' => '$LANG.tab_starts', + 'id' => 'starts' + ), QuestList::$brickFile)); + + if ($end) + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $end, + 'name' => '$LANG.tab_ends', + 'id' => 'ends' + ), QuestList::$brickFile)); + } + + // tab: related quests + if ($_ = $this->subject->getField('reqQuest')) + { + $relQuest = new QuestList(array(['id', $_])); + if (!$relQuest->error) + { + $this->extendGlobalData($relQuest->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $relQuest->getListviewData(), + 'name' => '$LANG.tab_quests', + 'id' => 'quests' + ), QuestList::$brickFile)); + } + } + + // tab: contains + if ($_ = $this->subject->getField('lootId')) + { + // check if loot_link entry exists (only difficulty: 1) + if ($npcId = DB::Aowow()->selectCell('SELECT `npcId` FROM ::loot_link WHERE `objectId` = %i AND `difficulty` = 1', $this->typeId)) + { + // get id set of npc + $lootEntries = DB::Aowow()->selectCol( + 'SELECT ll.`difficulty` AS ARRAY_KEY, o.`lootId` + FROM ::creature c + LEFT JOIN ::loot_link ll ON ll.`npcId` IN (c.`id`, c.`difficultyEntry1`, c.`difficultyEntry2`, c.`difficultyEntry3`) + LEFT JOIN ::objects o ON o.`id` = ll.`objectId` + WHERE c.`id` = %i + ORDER BY ll.`difficulty` ASC', + $npcId + ); + + if ($this->mapType == 2 || count($lootEntries) > 2) // always raid + $lootEntries = array_combine(array_map(fn($x) => 1 << (2 + $x), array_keys($lootEntries)), array_values($lootEntries)); + else if ($this->mapType == 1 || count($lootEntries) == 2) // dungeon or raid, assume dungeon + $lootEntries = array_combine(array_map(fn($x) => 1 << (2 - $x), array_keys($lootEntries)), array_values($lootEntries)); + } + else + $lootEntries = [4 => $_]; + + $goLoot = new LootByContainer(); + if ($goLoot->getByContainer(Loot::GAMEOBJECT, $lootEntries)) + { + $extraCols = $goLoot->extraCols; + array_push($extraCols, '$Listview.extraCols.count', '$Listview.extraCols.percent'); + if (count($lootEntries) > 1) + $extraCols[] = '$Listview.extraCols.mode'; + + $hiddenCols = ['source', 'side', 'slot', 'reqlevel', 'count']; + + $this->extendGlobalData($goLoot->jsGlobals); + $lootResult = $goLoot->getResult(); + + foreach ($hiddenCols as $k => $str) + { + if ($k == 1 && array_filter(array_column($lootResult, $str), fn ($x) => $x != SIDE_BOTH)) + unset($hiddenCols[$k]); + else if ($k != 1 && !array_filter(array_column($lootResult, $str))) + unset($hiddenCols[$k]); + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lootResult, + 'id' => 'contains', + 'name' => '$LANG.tab_contains', + 'sort' => ['-percent', 'name'], + 'extraCols' => array_unique($extraCols), + 'hiddenCols' => $hiddenCols ?: null, + 'sort' => ['-percent', 'name'], + '_totalCount' => 10000, + 'computeDataFunc' => '$Listview.funcBox.initLootTable', + 'onAfterCreate' => '$Listview.funcBox.addModeIndicator', + ), ItemList::$brickFile)); + } + } + + // tab: Spell Focus for + if ($sfId = $this->subject->getField('spellFocusId')) + { + $focusSpells = new SpellList(array(Listview::DEFAULT_SIZE, ['spellFocusObject', $sfId]), ['calcTotal' => true]); + if (!$focusSpells->error) + { + $tabData = array( + 'data' => $focusSpells->getListviewData(), + 'name' => Lang::gameObject('focus'), + 'id' => 'focus-for' + ); + + $this->extendGlobalData($focusSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + // create note if search limit was exceeded + if ($focusSpells->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_spellsfound', $focusSpells->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + } + + // tab: trap for X + $trigger = new GameObjectList(array(['linkedTrap', $this->typeId])); + if (!$trigger->error) + { + $this->extendGlobalData($trigger->getJSGlobals()); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $trigger->getListviewData(), + 'name' => Lang::gameObject('triggeredBy'), + 'id' => 'triggerd-by', + 'note' => sprintf(Util::$filterResultString, '?objects=6') + ), GameObjectList::$brickFile)); + } + + // tab: see also + if ($this->difficulties) + { + $conditions = array( + DB::AND, + ['id', $this->difficulties], + ['id', $this->typeId, '!'] + ); + + $saObjects = new GameObjectList($conditions); + if (!$saObjects->error) + { + $data = $saObjects->getListviewData(); + if ($this->difficulties) + { + $saE = ['$Listview.extraCols.mode']; + + foreach ($data as $id => &$d) + { + if (($modeBit = array_search($id, $this->difficulties)) !== false) + { + if ($this->mapType) + $d['modes'] = ['mode' => 1 << ($modeBit + 3)]; + else + $d['modes'] = ['mode' => 2 - $modeBit]; + } + else + $d['modes'] = ['mode' => 0]; + } + } + + $tabData = array( + 'data' => $data, + 'id' => 'see-also', + 'name' => '$LANG.tab_seealso', + 'visibleCols' => ['level'], + ); + + if (isset($saE)) + $tabData['extraCols'] = $saE; + + $this->lvTabs->addListviewTab(new Listview($tabData, GameObjectList::$brickFile)); + } + } + + // tab: Same model as + $sameModel = new GameObjectList(array(['displayId', $this->subject->getField('displayId')], ['id', $this->typeId, '!'])); + if (!$sameModel->error) + { + $this->extendGlobalData($sameModel->getJSGlobals()); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $sameModel->getListviewData(), + 'name' => '$LANG.tab_samemodelas', + 'id' => 'same-model-as' + ), GameObjectList::$brickFile)); + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::OBJECT, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/object/object_power.php b/endpoints/object/object_power.php new file mode 100644 index 00000000..bfd73a60 --- /dev/null +++ b/endpoints/object/object_power.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(string $id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $object = new GameObjectList(array(['id', $this->typeId])); + if ($object->error) + $this->cacheType = CACHE_TYPE_NONE; + else + $opts = array( + 'name' => Lang::unescapeUISequences($object->getField('name', true), Lang::FMT_RAW), + 'tooltip' => $object->renderTooltip(), + 'map' => $object->getSpawns(SPAWNINFO_SHORT) + ); + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/objects/objects.php b/endpoints/objects/objects.php new file mode 100644 index 00000000..cf4d8976 --- /dev/null +++ b/endpoints/objects/objects.php @@ -0,0 +1,113 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = [-2, -3, -4, -5, -6, 0, 3, 6, 9, 25]; + + public bool $petFamPanel = false; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new GameObjectListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('objects')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + if ($this->category) + $conditions[] = ['typeCat', (int)$this->category[0]]; + + + /*************/ + /* Menu Path */ + /*************/ + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::gameObject('cat', $this->category[0])); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + $tabData = ['data' => []]; + $objects = new GameObjectList($conditions, ['extraOpts' => $this->filter->extraOpts, 'calcTotal' => true]); + if (!$objects->error) + { + $tabData['data'] = $objects->getListviewData(); + if ($objects->hasSetFields('reqSkill')) + $tabData['visibleCols'] = ['skill']; + + // create note if search limit was exceeded + if ($objects->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_objectsfound', $objects->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, GameObjectList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/pages/pet.php b/endpoints/pet/pet.php similarity index 51% rename from pages/pet.php rename to endpoints/pet/pet.php index 29d68ebf..70064a1d 100644 --- a/pages/pet.php +++ b/endpoints/pet/pet.php @@ -1,49 +1,64 @@ typeId = intVal($id); + $this->typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + protected function generate() : void + { $this->subject = new PetList(array(['id', $this->typeId])); if ($this->subject->error) - $this->notFound(Lang::game('pet'), Lang::pet('notFound')); + $this->generateNotFound(Lang::game('pet'), Lang::pet('notFound')); - $this->name = $this->subject->getField('name', true); - } + $this->h1 = $this->subject->getField('name', true); - protected function generatePath() - { - $this->path[] = $this->subject->getField('type'); - } + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); - protected function generateTitle() - { - array_unshift($this->title, $this->name, Util::ucFirst(Lang::game('pet'))); - } - protected function generateContent() - { - $this->addScript([JS_FILE, '?data=zones&locale='.User::$localeId.'&t='.$_SESSION['dataKey']]); + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('type'); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('pet'))); + /***********/ /* Infobox */ @@ -58,18 +73,28 @@ class PetPage extends GenericPage if ($this->subject->getField('exotic')) $infobox[] = '[url=?spell=53270]'.Lang::pet('exotic').'[/url]'; + // id + $infobox[] = Lang::pet('id') . $this->typeId; + // icon if ($_ = $this->subject->getField('iconId')) { - $infobox[] = Util::ucFirst(lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; $this->extendGlobalIds(Type::ICON, $_); } + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + /****************/ /* Main Content */ /****************/ - $this->infobox = '[ul][li]'.implode('[/li][li]', $infobox).'[/li][/ul]'; $this->headIcons = [$this->subject->getField('iconString')]; $this->expansion = Util::$expansionString[$this->subject->getField('expansion')]; $this->redButtons = array( @@ -78,34 +103,37 @@ class PetPage extends GenericPage BUTTON_TALENT => ['href' => '?petcalc#'.Util::$tcEncoding[(int)($this->typeId / 10)] . Util::$tcEncoding[(2 * ($this->typeId % 10) + ($this->subject->getField('exotic') ? 1 : 0))], 'pet' => true] ); + /**************/ /* Extra Tabs */ /**************/ + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + // tab: tameable & gallery $condition = array( ['ct.type', 1], // Beast - ['ct.typeFlags', 0x1, '&'], // tameable + ['ct.typeFlags', NPC_TYPEFLAG_TAMEABLE, '&'], ['ct.family', $this->typeId], // displayed petType [ - 'OR', // at least neutral to at least one faction + DB::OR, // at least neutral to at least one faction ['ft.A', 1, '<'], ['ft.H', 1, '<'] ] ); $tng = new CreatureList($condition); - $this->lvTabs[] = ['creature', array( - 'data' => array_values($tng->getListviewData(NPCINFO_TAMEABLE)), + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $tng->getListviewData(NPCINFO_TAMEABLE), 'name' => '$LANG.tab_tameable', 'hiddenCols' => ['type'], 'visibleCols' => ['skin'], 'note' => sprintf(Util::$filterResultString, '?npcs=1&filter=fa=38'), 'id' => 'tameable' - )]; - $this->lvTabs[] = ['model', array( - 'data' => array_values($tng->getListviewData(NPCINFO_MODEL)) - )]; + ), CreatureList::$brickFile)); + + $this->lvTabs->addListviewTab(new Listview(['data' => $tng->getListviewData(NPCINFO_MODEL)], 'model')); // tab: diet $list = []; @@ -114,22 +142,22 @@ class PetPage extends GenericPage if ($mask & (1 << ($i - 1))) $list[] = $i; - $food = new ItemList(array(['i.subClass', [5, 8]], ['i.FoodType', $list], CFG_SQL_LIMIT_NONE)); + $food = new ItemList(array(['i.subClass', [ITEM_SUBCLASS_FOOD, ITEM_SUBCLASS_MISC_CONSUMABLE]], ['i.FoodType', $list])); $this->extendGlobalData($food->getJSGlobals()); - $this->lvTabs[] = ['item', array( - 'data' => array_values($food->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $food->getListviewData(), 'name' => '$LANG.diet', 'hiddenCols' => ['source', 'slot', 'side'], 'sort' => ['level'], 'id' => 'diet' - )]; + ), ItemList::$brickFile)); // tab: spells $mask = 0x0; - foreach (Game::$skillLineMask[-1] as $idx => $pair) + foreach (Game::$skillLineMask[-1] as $idx => [$familyId,]) { - if ($pair[0] == $this->typeId) + if ($familyId == $this->typeId) { $mask = 1 << $idx; break; @@ -138,54 +166,56 @@ class PetPage extends GenericPage $conditions = [ ['s.typeCat', -3], // Pet-Ability [ - 'OR', + DB::OR, // match: first skillLine ['skillLine1', $this->subject->getField('skillLineId')], // match: second skillLine (if not mask) - ['AND', ['skillLine1', 0, '>'], ['skillLine2OrMask', $this->subject->getField('skillLineId')]], + [DB::AND, ['skillLine1', 0, '>'], ['skillLine2OrMask', $this->subject->getField('skillLineId')]], // match: skillLineMask (if mask) - ['AND', ['skillLine1', -1], ['skillLine2OrMask', $mask, '&']] + [DB::AND, ['skillLine1', -1], ['skillLine2OrMask', $mask, '&']] ] ]; $spells = new SpellList($conditions); $this->extendGlobalData($spells->getJSGlobals(GLOBALINFO_SELF)); - $this->lvTabs[] = ['spell', array( - 'data' => array_values($spells->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $spells->getListviewData(), 'name' => '$LANG.tab_abilities', 'visibleCols' => ['schools', 'level'], 'id' => 'abilities' - )]; + ), SpellList::$brickFile)); // tab: talents $conditions = array( ['s.typeCat', -7], [ // last rank or unranked - 'OR', + DB::OR, ['s.cuFlags', SPELL_CU_LAST_RANK, '&'], ['s.rankNo', 0] ] ); - switch ($this->subject->getField('type')) + $conditions[] = match($this->subject->getField('type')) { - case 0: $conditions[] = ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE0, '&']; break; - case 1: $conditions[] = ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE1, '&']; break; - case 2: $conditions[] = ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE2, '&']; break; - } + PET_TALENT_TYPE_FEROCITY => ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE0, '&'], + PET_TALENT_TYPE_TENACITY => ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE1, '&'], + PET_TALENT_TYPE_CUNNING => ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE2, '&'] + }; $talents = new SpellList($conditions); $this->extendGlobalData($talents->getJSGlobals(GLOBALINFO_SELF)); - $this->lvTabs[] = ['spell', array( - 'data' => array_values($talents->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $talents->getListviewData(), 'visibleCols' => ['tier', 'level'], 'name' => '$LANG.tab_talents', 'id' => 'talents', 'sort' => ['tier', 'name'], '_petTalents' => 1 - )]; + ), SpellList::$brickFile)); + + parent::generate(); } } diff --git a/endpoints/petcalc/petcalc.php b/endpoints/petcalc/petcalc.php new file mode 100644 index 00000000..cff9539b --- /dev/null +++ b/endpoints/petcalc/petcalc.php @@ -0,0 +1,40 @@ +h1 = Lang::main('petCalc'); + $this->chooseType = Lang::main('chooseFamily'); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/pets/pets.php b/endpoints/pets/pets.php new file mode 100644 index 00000000..c95fcc5c --- /dev/null +++ b/endpoints/pets/pets.php @@ -0,0 +1,88 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('pets')); + + + /*************/ + /* Menu Path */ + /*************/ + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::pet('cat', $this->category[0])); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = [Listview::DEFAULT_SIZE]; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($this->category) + $conditions[] = ['type', $this->category[0]]; + + $tabData = []; + $pets = new PetList($conditions); + if (!$pets->error) + { + $this->extendGlobalData($pets->getJSGlobals(GLOBALINFO_RELATED)); + + $tabData = array( + 'data' => $pets->getListviewData(), + 'visibleCols' => ['abilities'], + 'computeDataFunc' => '$_', + 'hiddenCols' => !$pets->hasDiffFields('type') ? ['type'] : null + ); + }; + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, PetList::$brickFile, 'petFoodCol')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/privilege/privilege.php b/endpoints/privilege/privilege.php new file mode 100644 index 00000000..9065193e --- /dev/null +++ b/endpoints/privilege/privilege.php @@ -0,0 +1,71 @@ + 'REP_REQ_COMMENT', // write comments + 2 => 'REP_REQ_EXT_LINKS', // post external links + // 4 => 'REP_REQ_NO_CAPTCHA', // NYI no captcha + 5 => 'REP_REQ_SUPERVOTE', // votes count for more + 9 => 'REP_REQ_VOTEMORE_BASE', // more votes per day + 10 => 'REP_REQ_UPVOTE', // can upvote + 11 => 'REP_REQ_DOWNVOTE', // can downvote + 12 => 'REP_REQ_REPLY', // can reply + 13 => 'REP_REQ_BORDER_UNCOMMON', // uncommon avatar border + 14 => 'REP_REQ_BORDER_RARE', // rare avatar border + 15 => 'REP_REQ_BORDER_EPIC', // epic avatar border + 16 => 'REP_REQ_BORDER_LEGENDARY', // legendary avatar border + 17 => 'REP_REQ_PREMIUM' // premium status + ); + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if (!$rawParam) + $this->generateError(); + + // apply actual values + $this->repVal = Cfg::get($this->req2priv[$rawParam]); + } + + protected function generate() : void + { + $this->h1 = Lang::privileges('_privileges', $this->category[0]); + + array_unshift($this->title, $this->h1); + + $this->breadcrumb[] = $this->category[0]; + + $this->privReqPoints = Lang::privileges('reqPoints', [Lang::nf($this->repVal)]); + + parent::generate(); + + $this->result->registerDisplayHook('article', [self::class, 'articleHook']); + } + + public static function articleHook(Template\PageTemplate &$pt, Markup &$article) : void + { + $article->apply(Cfg::applyToString(...)); + } +} + +?> diff --git a/endpoints/privileges/privileges.php b/endpoints/privileges/privileges.php new file mode 100644 index 00000000..83857f04 --- /dev/null +++ b/endpoints/privileges/privileges.php @@ -0,0 +1,65 @@ + 'REP_REQ_COMMENT', // write comments + 2 => 'REP_REQ_EXT_LINKS', // post external links + // 4 => 'REP_REQ_NO_CAPTCHA', // NYI no captcha + 5 => 'REP_REQ_SUPERVOTE', // votes count for more + 9 => 'REP_REQ_VOTEMORE_BASE', // more votes per day + 10 => 'REP_REQ_UPVOTE', // can upvote + 11 => 'REP_REQ_DOWNVOTE', // can downvote + 12 => 'REP_REQ_REPLY', // can reply + 13 => 'REP_REQ_BORDER_UNCOMMON', // uncommon avatar border + 14 => 'REP_REQ_BORDER_RARE', // rare avatar border + 15 => 'REP_REQ_BORDER_EPIC', // epic avatar border + 16 => 'REP_REQ_BORDER_LEGENDARY', // legendary avatar border + 17 => 'REP_REQ_PREMIUM' // premium status + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if ($rawParam) + $this->generateError(); + + // apply actual values and order by requirement ASC + foreach ($this->req2priv as &$var) + $var = Cfg::get($var); + + asort($this->req2priv); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + foreach (array_filter($this->req2priv) as $id => $val) + $this->privileges[$id] = array( + User::getReputation() >= $val, + Lang::privileges('_privileges', $id), + $val + ); + + parent::generate(); + } +} + +?> diff --git a/endpoints/profile/avatar.php b/endpoints/profile/avatar.php new file mode 100644 index 00000000..aa8a9ed2 --- /dev/null +++ b/endpoints/profile/avatar.php @@ -0,0 +1,68 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^\d+\.jpg$/'] ], + 'size' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + size: [optional] + return: + + */ + protected function generate() : void + { + if (!$this->assertGET('id')) + $this->generate404(); + + $profileId = substr($this->_get['id'], 0, -4); + + $charData = DB::Aowow()->selectRow('SELECT `race`, `gender` FROM ::profiler_profiles WHERE id = %i', $profileId); + if (!$charData) + $this->generate404(); + + $gender = $charData['gender'] ? 'female' : 'male'; + $race = ChrRace::tryFrom($charData['race'])?->json() ?? 'human'; + $size = match($this->_get['size']) + { + 'small', + 'medium', + 'large' => $this->_get['size'], + default => 'medium' + }; + + $this->redirectTo = sprintf('%s/images/armory/%s/default_%s_%s.jpg', Cfg::get('STATIC_URL'), $size, $race, $gender); + } +} + +?> diff --git a/endpoints/profile/delete.php b/endpoints/profile/delete.php new file mode 100644 index 00000000..ed7572fd --- /dev/null +++ b/endpoints/profile/delete.php @@ -0,0 +1,47 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList']], + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + return + null + */ + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('ProfileDeleteResponse - profileId empty', E_USER_WARNING); + return; + } + + $where = [['`id` IN %in', $this->_get['id']], ['`custom` = 1']]; + if (!User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $where[] = ['`user` = %i', User::$id]; + + // only flag as deleted; only custom profiles + DB::Aowow()->qry('UPDATE ::profiler_profiles SET `deleted` = 1 WHERE %and', $where); + } +} + +?> diff --git a/endpoints/profile/link.php b/endpoints/profile/link.php new file mode 100644 index 00000000..ad15f385 --- /dev/null +++ b/endpoints/profile/link.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_VALIDATE_INT] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + return: + null + */ + protected function generate() : void // links char with account + { + if (!$this->assertGET('id')) + { + trigger_error('ProfileLinkResponse - profileId empty', E_USER_ERROR); + return; + } + + // only link characters, not custom profiles + $newId = DB::Aowow()->qry( + 'REPLACE INTO ::account_profiles (`accountId`, `profileId`, `extraFlags`) + SELECT %i, p.`id`, 0 FROM ::profiler_profiles p WHERE p.`id` = %i AND `custom` = 0', + User::$id, $this->_get['id'] + ); + + if (!is_int($newId)) + trigger_error('ProfileLinkResponse - some of the profileIds were custom or do not exist', E_USER_ERROR); + } +} + +?> diff --git a/endpoints/profile/load.php b/endpoints/profile/load.php new file mode 100644 index 00000000..17277037 --- /dev/null +++ b/endpoints/profile/load.php @@ -0,0 +1,302 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'items' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkItemList']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: profileId + items: string [itemIds.join(':')] + unnamed: unixtime [only to force the browser to reload instead of cache] + return + lots... + */ + protected function generate() : void + { + // titles, achievements, characterData, talents, pets + // and some onLoad-hook to .. load it registerProfile($data) + // everything else goes through data.php .. strangely enough + + if (!$this->assertGET('id')) + { + trigger_error('ProfileLoadResponse - profileId empty', E_USER_ERROR); + return; + } + + $pBase = DB::Aowow()->selectRow('SELECT pg.`name` AS "guildname", p.* FROM ::profiler_profiles p LEFT JOIN ::profiler_guild pg ON pg.`id` = p.`guild` WHERE p.`id` = %i', $this->_get['id'][0]); + if (!$pBase) + { + trigger_error('ProfileLoadResponse - called with invalid profileId #'.$this->_get['id'][0], E_USER_WARNING); + return; + } + + if ($pBase['deleted'] && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + return; + + + $rData = []; + foreach (Profiler::getRealms() as $rId => $rData) + if ($rId == $pBase['realm']) + break; + + if ($pBase['realm'] && !$rData) // realm doesn't exist or access is restricted + return; + + $profile = array( + 'id' => $pBase['id'], + 'source' => $pBase['id'], + 'level' => $pBase['level'], + 'classs' => $pBase['class'], + 'race' => $pBase['race'], + 'faction' => ChrRace::tryFrom($pBase['race'])?->getTeam() ?? TEAM_NEUTRAL, + 'gender' => $pBase['gender'], + 'skincolor' => $pBase['skincolor'], + 'hairstyle' => $pBase['hairstyle'], + 'haircolor' => $pBase['haircolor'], + 'facetype' => $pBase['facetype'], + 'features' => $pBase['features'], + 'title' => $pBase['title'], + 'name' => $pBase['name'], + 'guild' => "$'".$pBase['guildname']."'", + 'published' => !!($pBase['cuFlags'] & PROFILER_CU_PUBLISHED), + 'pinned' => !!($pBase['cuFlags'] & PROFILER_CU_PINNED), + 'nomodel' => $pBase['nomodelMask'], + 'playedtime' => $pBase['playedtime'], + 'lastupdated' => $pBase['lastupdated'] * 1000, + 'talents' => array( + 'builds' => array( // notice the bullshit to prevent the talent-string from becoming a float! NOTICE IT!! + ['talents' => '$"'.$pBase['talentbuild1'].'"', 'glyphs' => $pBase['glyphs1']], + ['talents' => '$"'.$pBase['talentbuild2'].'"', 'glyphs' => $pBase['glyphs2']] + ), + 'active' => $pBase['activespec'] + ), + // set later + 'inventory' => [], + 'bookmarks' => [], // list of userIds who claimed this profile (claiming and owning are two different things) + + // completion lists: [subjectId => amount/timestamp/1] + 'skills' => [], // skillId => [curVal, maxVal] + 'reputation' => [], // factionId => curVal + 'titles' => [], // titleId => 1 + 'spells' => [], // spellId => 1; recipes, vanity pets, mounts + 'achievements' => [], // achievementId => timestamp + 'quests' => [], // questId => 1 + 'achievementpoints' => 0, // max you have + 'statistics' => [], // all raid activity [achievementId => killCount] + 'activity' => [], // recent raid activity [achievementId => 1] (is a subset of statistics) + ); + + if ($pBase['custom']) + { + // this parameter is _really_ strange .. probably still not doing this right + $profile['source'] = $pBase['realm'] ? $pBase['sourceId'] : 0; + + $profile['sourcename'] = $pBase['sourceName']; + $profile['description'] = $pBase['description']; + $profile['user'] = $pBase['user']; + $profile['username'] = DB::Aowow()->selectCell('SELECT `username` FROM ::account WHERE `id` = %i', $pBase['user']); + } + + // custom profiles inherit this when copied from real char :( + if ($pBase['realm']) + { + $profile['region'] = [$rData['region'], Lang::profiler('regions', $rData['region'])]; + $profile['battlegroup'] = [Profiler::urlize(Cfg::get('BATTLEGROUP')), Cfg::get('BATTLEGROUP')]; + $profile['realm'] = [Profiler::urlize($rData['name'], true), $rData['name']]; + } + + // bookmarks + if ($_ = DB::Aowow()->selectCol('SELECT `accountId` FROM ::account_profiles WHERE `profileId` = %i', $pBase['id'])) + $profile['bookmarks'] = $_; + + // arena teams - [size(2|3|5) => name]; name gets urlized to use as link + if ($at = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, `name` FROM ::profiler_arena_team at JOIN ::profiler_arena_team_member atm ON atm.`arenaTeamId` = at.`id` WHERE atm.`profileId` = %i', $pBase['id'])) + $profile['arenateams'] = $at; + + // pets if hunter fields: [name:name, family:petFamily, npc:npcId, displayId:modelId, talents:talentString] + if ($pets = DB::Aowow()->selectAssoc('SELECT `name`, `family`, `npc`, `displayId`, CONCAT(\'$"\', `talents`, \'"\') AS "talents" FROM ::profiler_pets WHERE `owner` = %i', $pBase['id'])) + $profile['pets'] = $pets; + + // source for custom profiles; profileId => [name, ownerId, iconString(optional)] + if ($customs = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `name`, `user`, `icon` FROM ::profiler_profiles WHERE `sourceId` = %i AND `sourceId` <> `id` AND `deleted` IN %in', $pBase['id'], User::isInGroup(U_GROUP_STAFF) ? [0, 1] : [0])) + { + foreach ($customs as $id => $cu) + { + if (!$cu['icon']) + unset($cu['icon']); + + $profile['customs'][$id] = array_values($cu); + } + } + + + /* $profile[] + // CUSTOM + 'auras' => [], // custom list of buffs, debuffs [spellId] + + // UNUSED + 'glyphs' => [], // provided list of already known glyphs (post cataclysm feature) + */ + + + // questId => [cat1, cat2] + $profile['quests'] = []; + if ($quests = DB::Aowow()->selectCol('SELECT `questId` FROM ::profiler_completion_quests WHERE `id` = %i', $pBase['id'])) + { + $qList = new QuestList(array(['id', $quests])); + if (!$qList->error) + foreach ($qList->iterate() as $id => $__) + $profile['quests'][$id] = [$qList->getField('cat1'), $qList->getField('cat2')]; + } + + // skillId => [value, max] + $profile['skills'] = DB::Aowow()->selectAssoc('SELECT `skillId` AS ARRAY_KEY, `value` AS "0", `max` AS "1" FROM ::profiler_completion_skills WHERE `id` = %i', $pBase['id']); + + // factionId => amount + $profile['reputation'] = DB::Aowow()->selectCol('SELECT `factionId` AS ARRAY_KEY, `standing` FROM ::profiler_completion_reputation WHERE `id` = %i', $pBase['id']); + + // titleId => 1 + $profile['titles'] = DB::Aowow()->selectCol('SELECT `titleId` AS ARRAY_KEY, 1 FROM ::profiler_completion_titles WHERE `id` = %i', $pBase['id']); + + // achievementId => js date object + $profile['achievements'] = DB::Aowow()->selectCol('SELECT `achievementId` AS ARRAY_KEY, CONCAT("$new Date(", `date` * 1000, ")") FROM ::profiler_completion_achievements WHERE `id` = %i', $pBase['id']); + + // just points + $profile['achievementpoints'] = $profile['achievements'] ? DB::Aowow()->selectCell('SELECT SUM(`points`) FROM ::achievement WHERE `id` IN %in', array_keys($profile['achievements'])) : 0; + + // achievementId => counter + $profile['statistics'] = DB::Aowow()->selectCol('SELECT `achievementId` AS ARRAY_KEY, `counter` FROM ::profiler_completion_statistics WHERE `id` = %i', $pBase['id']); + + // achievementId => 1 + $profile['activity'] = DB::Aowow()->selectCol('SELECT `achievementId` AS ARRAY_KEY, 1 FROM ::profiler_completion_statistics WHERE `id` = %i AND `date` > %i', $pBase['id'], time() - MONTH); + + // spellId => 1 + $profile['spells'] = DB::Aowow()->selectCol('SELECT `spellId` AS ARRAY_KEY, 1 FROM ::profiler_completion_spells WHERE `id` = %i', $pBase['id']); + + + $gItems = []; + + $usedSlots = []; + if ($this->_get['items']) + { + $phItems = new ItemList(array(['id', $this->_get['items']], ['slot', INVTYPE_NON_EQUIP, '!'])); + if (!$phItems->error) + { + $data = $phItems->getListviewData(ITEMINFO_JSON | ITEMINFO_SUBITEMS); + foreach ($phItems->iterate() as $iId => $__) + { + $sl = $phItems->getField('slot'); + foreach (Profiler::$slot2InvType as $slot => $invTypes) + { + if (in_array($sl, $invTypes) && !in_array($slot, $usedSlots)) + { + // get and apply inventory + $gItems[$iId] = array( + 'name_'.Lang::getLocale()->json() => $phItems->getField('name', true), + 'quality' => $phItems->getField('quality'), + 'icon' => $phItems->getField('iconString'), + 'jsonequip' => $data[$iId] + ); + $profile['inventory'][$slot] = [$iId, 0, 0, 0, 0, 0, 0, 0]; + + $usedSlots[] = $slot; + break; + } + } + } + } + } + + if ($items = DB::Aowow()->selectAssoc('SELECT * FROM ::profiler_items WHERE `id` = %i', $pBase['id'])) + { + $itemz = new ItemList(array(['id', array_column($items, 'item')])); + if (!$itemz->error) + { + $data = $itemz->getListviewData(ITEMINFO_JSON | ITEMINFO_SUBITEMS); + + foreach ($items as $i) + { + if ($itemz->getEntry($i['item']) && !in_array($i['slot'], $usedSlots)) + { + // get and apply inventory + $gItems[$i['item']] = array( + 'name_'.Lang::getLocale()->json() => $itemz->getField('name', true), + 'quality' => $itemz->getField('quality'), + 'icon' => $itemz->getField('iconString'), + 'jsonequip' => $data[$i['item']] + ); + $profile['inventory'][$i['slot']] = [$i['item'], $i['subItem'], $i['permEnchant'], $i['tempEnchant'], $i['gem1'], $i['gem2'], $i['gem3'], $i['gem4']]; + } + } + } + } + + $buff = ''; + foreach ($gItems as $id => $item) + $buff .= 'g_items.add('.$id.', '.Util::toJSON($item, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE).");\n"; + + + // if ($au = $char->getField('auras')) + // { + // $auraz = new SpellList(array(['id', $char->getField('auras')])); + // $dataz = $auraz->getListviewData(); + // $modz = $auraz->getProfilerMods(); + + // // get and apply aura-mods + // foreach ($dataz as $id => $data) + // { + // $mods = []; + // if (!empty($modz[$id])) + // { + // foreach ($modz[$id] as $k => $v) + // { + // if (is_array($v)) + // $mods[] = $v; + // else if ($str = @Game::$itemMods[$k]) + // $mods[$str] = $v; + // } + // } + + // $buff .= 'g_spells.add('.$id.", {id:".$id.", name:'".Util::jsEscape(mb_substr($data['name'], 1))."', icon:'".$data['icon']."', callback:".Util::toJSON($mods)."});\n"; + // } + // $buff .= "\n"; + // } + + + // load available titles + Util::loadStaticFile('p-titles-'.$pBase['gender'], $buff, true); + + // add profile to buffer + $buff .= "\n\n\$WowheadProfiler.registerProfile(".Util::toJSON($profile).");"; + + $this->result = $buff."\n"; + } + + protected static function checkItemList(string $val) : array + { + // expecting item-list + if (preg_match('/\d+(:\d+)*/', $val)) + return array_map('intVal', explode(':', $val)); + + return []; + } +} + +?> diff --git a/endpoints/profile/pin.php b/endpoints/profile/pin.php new file mode 100644 index 00000000..9b871a44 --- /dev/null +++ b/endpoints/profile/pin.php @@ -0,0 +1,58 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateUsername']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional] + return: null + */ + protected function generate() : void // (un)favorite + { + if (!$this->assertGET('id')) + { + trigger_error('ProfilePinResponse - profileId empty', E_USER_ERROR); + return; + } + + $uid = 0; + if (!$this->_get['user'] || User::$username == $this->_get['user']) + $uid = User::$id; + else if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $uid = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user']); + + if (!$uid) + { + trigger_error('ProfilePinResponse - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); + return; + } + + // since only one character can be pinned at a time we can reset everything + DB::Aowow()->qry('UPDATE ::account_profiles SET `extraFlags` = `extraFlags` & ~%i WHERE `accountId` = %i', PROFILER_CU_PINNED, $uid); + // and set a single char if necessary (Replace, because this entry may not exist yet) + DB::Aowow()->qry('REPLACE INTO ::account_profiles (`accountId`, `profileId`, `extraFlags`) VALUES (%i, %i, %i)', $uid, $this->_get['id'][0], PROFILER_CU_PINNED); + } +} + +?> diff --git a/endpoints/profile/private.php b/endpoints/profile/private.php new file mode 100644 index 00000000..c42a50c2 --- /dev/null +++ b/endpoints/profile/private.php @@ -0,0 +1,64 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateUsername']], + // 'bookmarked' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ] // something with signatures? (must have bookmarked profile to create signature from) + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional] // user page this is may be executed from + return: + null + */ + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('ProfilePrivateResponse - profileId empty', E_USER_ERROR); + return; + } + + if ($this->_get['user'] && User::$username != $this->_get['user'] && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + { + trigger_error('ProfilePrivateResponse - user #'.User::$id.' tried to mark profiles of "'.$this->_get['user'].'" as private.', E_USER_ERROR); + return; + } + + $uid = 0; + if (!$this->_get['user'] || User::$username == $this->_get['user']) + $uid = User::$id; + else if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $uid = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user']); + + if (!$uid) + { + trigger_error('ProfilePrivateResponse - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); + return; + } + + DB::Aowow()->qry('UPDATE ::account_profiles SET `extraFlags` = `extraFlags` & ~%i WHERE `profileId` IN %in AND `accountId` = %i', PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); + DB::Aowow()->qry('UPDATE ::profiler_profiles SET `cuFlags` = `cuFlags` & ~%i WHERE `id` IN %in AND `user` = %i', PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); + } +} + +?> diff --git a/endpoints/profile/profile.php b/endpoints/profile/profile.php new file mode 100644 index 00000000..53a7f8be --- /dev/null +++ b/endpoints/profile/profile.php @@ -0,0 +1,187 @@ + Profiler > New + + protected array $dataLoader = ['enchants', 'gems', 'glyphs', 'itemsets', 'pets', 'pet-talents', 'quick-excludes', 'realms', 'statistics', 'weight-presets', 'achievements']; + protected array $scripts = array( + [SC_JS_FILE, 'js/filters.js'], + [SC_JS_FILE, 'js/TalentCalc.js'], + [SC_JS_FILE, 'js/profile_all.js'], + [SC_JS_FILE, 'js/profile.js'], + [SC_JS_FILE, 'js/Profiler.js'], + [SC_CSS_FILE, 'css/talentcalc.css'], + [SC_CSS_FILE, 'css/Profiler.css'] + ); + protected array $expectedGET = array( + 'new' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + public int $type = Type::PROFILE; + public bool $gDataKey = true; + + public function __construct(string $idOrProfile) + { + parent::__construct($idOrProfile); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generateError(); + + // neither param nor &new > error + if (!$idOrProfile && !$this->_get['new']) + $this->generateError(); + + // display empty/new profile editor > ok + if (!$idOrProfile && $this->_get['new']) + return; + + $this->getSubjectFromUrl($idOrProfile); + + // we have an ID > ok + if ($this->typeId) + return; + + // param was incomplete profile > error + if (!$this->subjectName) + $this->notFound(); + + $rnItr = 0; + // pending rename + if (preg_match('/^([^\-]+)-(\d+)$/i', $this->subjectName, $m)) + { + $this->subjectName = $m[1]; + $rnItr = $m[2]; + } + + // 3 possibilities + // 1) already synced to aowow + if ($subject = DB::Aowow()->selectRow('SELECT `id`, `realmGUID`, `stub` FROM ::profiler_profiles WHERE `realm` = %i AND `custom` = 0 AND `name` = %s AND `renameItr` = %i', $this->realmId, Util::ucFirst($this->subjectName), $rnItr)) + { + $this->typeId = $subject['id']; + + if ($subject['stub']) + $this->handleIncompleteData(Type::PROFILE, $subject['realmGUID']); + + return; + } + + // can not be used to look up char on realm + if ($rnItr) + $this->notFound(); + + // 2) not yet synced but exists on realm (and not a gm character) + $subjects = DB::Characters($this->realmId)->selectAssoc( + 'SELECT c.`guid` AS "realmGUID", c.`name`, c.`race`, c.`class`, c.`level`, c.`gender`, c.`at_login`, g.`guildid` AS "guildGUID", IFNULL(g.`name`, "") AS "guildName", IFNULL(gm.`rank`, 0) AS "guildRank" + FROM characters c + LEFT JOIN guild_member gm ON gm.`guid` = c.`guid` + LEFT JOIN guild g ON g.`guildid` = gm.`guildid` + WHERE c.`name` = %s AND `level` <= %i AND (`extra_flags` & %i) = 0', + Util::ucFirst($this->subjectName), MAX_LEVEL, Profiler::CHAR_GMFLAGS + ); + if ($subject = array_find($subjects ?: [], fn($x) => Util::lower($x['name']) == Util::lower($this->subjectName))) + { + $subject['realm'] = $this->realmId; + $subject['stub'] = 1; + + if ($subject['at_login'] & 0x1) + $subject['renameItr'] = DB::Aowow()->selectCell('SELECT MAX(`renameItr`) FROM ::profiler_profiles WHERE `realm` = %i AND `custom` = 0 AND `name` = %s', $this->realmId, $subject['name']); + + if ($subject['guildGUID']) + { + // create empty guild if necessary to satisfy foreign keys + $subject['guild'] = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_guild WHERE `realm` = %i AND `realmGUID` = %i', $this->realmId, $subject['guildGUID']); + if (!$subject['guild']) + $subject['guild'] = DB::Aowow()->qry('INSERT INTO ::profiler_guild (`realm`, `realmGUID`, `stub`, `name`, `nameUrl`) VALUES (%i, %i, 1, %s, %s)', $this->realmId, $subject['guildGUID'], $subject['guildName'], Profiler::urlize($subject['guildName'])); + } + + unset($subject['guildGUID'], $subject['guildName'], $subject['at_login']); + + // create entry from realm with enough basic info to disply tooltips + DB::Aowow()->qry('REPLACE INTO ::profiler_profiles %v', $subject); + $this->typeId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_profiles WHERE `realm` = %i AND `realmGUID` = %i', $this->realmId, $subject['realmGUID']); + + $this->handleIncompleteData(Type::PROFILE, $subject['realmGUID']); + return; + } + + // 3) does not exist at all + $this->notFound(); + } + + protected function generate() : void + { + if ($this->doResync) + { + parent::generate(); + return; + } + + if ($this->typeId) + { + $subject = new LocalProfileList(array(['id', $this->typeId])); + if ($subject->error) + $this->notFound(); + + if (!$subject->isVisibleToUser()) + $this->notFound(); + + // character profile accessed by id + if (!$subject->isCustom() && !$this->subjectName) + $this->forward($subject->getProfileUrl()); + } + + parent::generate(); + + array_unshift($this->title, Util::ucFirst(Lang::game('profile'))); + + + // as demanded by the raid activity tracker + $bossIds = array( + // ruby: Halion + 39863, + // icc: Valanar, Lana'thel, Saurfang, Festergut, Deathwisper, Marrowgar, Putricide, Rotface, Sindragosa, Valithria, Lich King + 37970, 37955, 37813, 36626, 36855, 36612, 36678, 36627, 36853, 36789, 36597, + // toc: Jaraxxus, Anub'arak + 34780, 34564, + // ony: Onyxia + 10184, + // uld: Flame Levi, Ignis, Razorscale, XT-002, Kologarn, Auriaya, Freya, Hodir, Mimiron, Thorim, Vezaxx, Yogg, Algalon + 33113, 33118, 33186, 33293, 32930, 33515, 32906, 32845, 33350, 32864, 33271, 33288, 32871, + // nax: Anub, Faerlina, Maexxna, Noth, Heigan, Loatheb, Razuvious, Gothik, Patchwerk, Grobbulus, Gluth, Thaddius, Sapphiron, Kel'Thuzad + 15956, 15953, 15952, 15954, 15936, 16011, 16061, 16060, 16028, 15931, 15932, 15928, 15989, 15990 + ); + $this->extendGlobalIds(Type::NPC, ...$bossIds); + + // dummy title from dungeon encounter + foreach (Lang::profiler('encounterNames') as $id => $name) + $this->extendGlobalData([Type::NPC => [$id => ['name_'.Lang::getLocale()->json() => $name]]]); + } + + private function notFound() : never + { + if ($this->subjectName && $this->realm) + $head = Lang::profiler('firstUseTitle', [Util::ucFirst($this->subjectName), $this->realm]); + else + $head = Lang::profiler('profiler'); + + // unsetting typeId to prevent it from being added to the title string in the input-box is jank galore + // but it isn't needed for the not-found case anyway, right...? + unset($this->typeId); + + parent::generateNotFound($head, Lang::profiler('notFound', 'profile')); + } +} + +?> diff --git a/endpoints/profile/profile_power.php b/endpoints/profile/profile_power.php new file mode 100644 index 00000000..cbc0d0e6 --- /dev/null +++ b/endpoints/profile/profile_power.php @@ -0,0 +1,88 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(private string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->getSubjectFromUrl($rawParam); + + if ($this->subjectName) // rawParam is fully defined profiler string + { + // pending rename + if (preg_match('/([^\-]+)-(\d+)/i', $this->subjectName, $m)) + [, $this->subjectName, $renameItr] = $m; + + if ($x = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_profiles WHERE `realm` = %i AND `custom` = 0 AND `name` = %s AND `renameItr` = %i', $this->realmId, Util::ucWords($this->subjectName), $renameItr ?? 0)) + $this->typeId = $x; + } + + if (!$this->typeId) + $this->generate404(); + } + + protected function generate() : void + { + $profile = new LocalProfileList(array(['id', $this->typeId])); + if ($profile->error || !$profile->isVisibleToUser()) + $this->cacheType = CACHE_TYPE_NONE; + else + { + $n = $profile->getField('name'); + $r = $profile->getField('race'); + $c = $profile->getField('class'); + $g = $profile->getField('gender'); + $l = $profile->getField('level'); + + if (!$this->subjectName) // implicit isCustom + $n .= Lang::profiler('customProfile'); + else if ($_ = $profile->getField('title')) + if ($title = (new TitleList(array(['id', $_])))?->getField($g ? 'female' : 'male', true)) + $n = sprintf($title, $n); + + $opts = array( + 'name' => $n, + 'tooltip' => $profile->renderTooltip(), + 'icon' => '$$WH.g_getProfileIcon('.$r.', '.$c.', '.$g.', '.$l.', \''.$profile->getIcon().'\')' + ); + } + + if ($_ = $profile->getField('renameItr')) + $ri = '-'.$_; + + // the 'id' must be exactly as the js requested it or the tooltip won't register + if ($this->subjectName) + $id = urlencode($this->rawParam) . ($ri ?? ''); + else + $id = $this->typeId; + + $this->result = new Tooltip(self::POWER_TEMPLATE, $id, $opts ?? []); + } +} + +?> diff --git a/endpoints/profile/public.php b/endpoints/profile/public.php new file mode 100644 index 00000000..98f70eae --- /dev/null +++ b/endpoints/profile/public.php @@ -0,0 +1,64 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateUsername']], + // 'bookmarked' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet'] ] // something with signatures? (must have bookmarked profile to create signature from) + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional] // user page this is may be executed from + return: + null + */ + protected function generate() : void + { + if (!$this->assertGET('id')) + { + trigger_error('ProfilePublicResponse - profileId empty', E_USER_ERROR); + return; + } + + if ($this->_get['user'] && User::$username != $this->_get['user'] && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + { + trigger_error('ProfilePublicResponse - user #'.User::$id.' tried to mark profiles of "'.$this->_get['user'].'" as public.', E_USER_ERROR); + return; + } + + $uid = 0; + if (!$this->_get['user'] || User::$username == $this->_get['user']) + $uid = User::$id; + else if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $uid = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user']); + + if (!$uid) + { + trigger_error('ProfilePublicResponse - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); + return; + } + + DB::Aowow()->qry('UPDATE ::account_profiles SET `extraFlags` = `extraFlags` | %i WHERE `profileId` IN %in AND `accountId` = %i', PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); + DB::Aowow()->qry('UPDATE ::profiler_profiles SET `cuFlags` = `cuFlags` | %i WHERE `id` IN %in AND `user` = %i', PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); + } +} + +?> diff --git a/endpoints/profile/purge.php b/endpoints/profile/purge.php new file mode 100644 index 00000000..05d63a64 --- /dev/null +++ b/endpoints/profile/purge.php @@ -0,0 +1,22 @@ + + data: [string, tabName] + return + null + */ + protected function generate() : void { } // removes completion data (as uploaded by the wowhead client) Just fail silently if someone triggers this manually +} + +?> diff --git a/endpoints/profile/resync.php b/endpoints/profile/resync.php new file mode 100644 index 00000000..0b93276a --- /dev/null +++ b/endpoints/profile/resync.php @@ -0,0 +1,42 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + return: + 1 + */ + protected function generate() : void + { + if ($chars = DB::Aowow()->selectAssoc('SELECT `realm`, `realmGUID` FROM ::profiler_profiles WHERE `id` IN %in', $this->_get['id'])) + { + foreach ($chars as $c) + Profiler::scheduleResync(Type::PROFILE, $c['realm'], $c['realmGUID']); + } + else + trigger_error('ProfileResyncResponse - profiles '.implode(', ', $this->_get['id']).' not found in db', E_USER_ERROR); + + $this->result = 1; + } +} + +?> diff --git a/endpoints/profile/save.php b/endpoints/profile/save.php new file mode 100644 index 00000000..70a4e9f7 --- /dev/null +++ b/endpoints/profile/save.php @@ -0,0 +1,204 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList']], + ); + protected array $expectedPOST = array( + 'name' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'level' => ['filter' => FILTER_VALIDATE_INT ], + 'class' => ['filter' => FILTER_VALIDATE_INT ], + 'race' => ['filter' => FILTER_VALIDATE_INT ], + 'gender' => ['filter' => FILTER_VALIDATE_INT ], + 'nomodel' => ['filter' => FILTER_VALIDATE_INT ], + 'talenttree1' => ['filter' => FILTER_VALIDATE_INT ], + 'talenttree2' => ['filter' => FILTER_VALIDATE_INT ], + 'talenttree3' => ['filter' => FILTER_VALIDATE_INT ], + 'activespec' => ['filter' => FILTER_VALIDATE_INT ], + 'talentbuild1' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTalentString']], + 'glyphs1' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkGlyphString'] ], + 'talentbuild2' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTalentString']], + 'glyphs2' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkGlyphString'] ], + 'icon' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'description' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextBlob'] ], + 'source' => ['filter' => FILTER_VALIDATE_INT ], + 'copy' => ['filter' => FILTER_VALIDATE_INT ], + 'public' => ['filter' => FILTER_VALIDATE_INT ], + 'gearscore' => ['filter' => FILTER_VALIDATE_INT ], + 'inv' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'], 'flags' => FILTER_REQUIRE_ARRAY] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params (get)) + id: [0: new profile] + params (post) + [see below] + return: + proileId [onSuccess] + -1 [onError] + */ + protected function generate() : void + { + $cuProfile = array( + 'user' => User::$id, + // 'userName' => User::$username, + 'name' => $this->_post['name'], + 'level' => $this->_post['level'], + 'class' => $this->_post['class'], + 'race' => $this->_post['race'], + 'gender' => $this->_post['gender'], + 'nomodelMask' => $this->_post['nomodel'], + 'talenttree1' => $this->_post['talenttree1'], + 'talenttree2' => $this->_post['talenttree2'], + 'talenttree3' => $this->_post['talenttree3'], + 'talentbuild1' => $this->_post['talentbuild1'], + 'talentbuild2' => $this->_post['talentbuild2'], + 'activespec' => $this->_post['activespec'], + 'glyphs1' => $this->_post['glyphs1'], + 'glyphs2' => $this->_post['glyphs2'], + 'gearscore' => $this->_post['gearscore'], + 'icon' => $this->_post['icon'], + 'custom' => 1, + 'cuFlags' => $this->_post['public'] ? PROFILER_CU_PUBLISHED : 0 + ); + + // remnant of a conflict between wotlk generic icons and cata+ auto-generated, char-based icons (see profile=avatar) + if (strstr($cuProfile['icon'], 'profile=avatar')) + $cuProfile['icon'] = ''; + + if ($_ = $this->_post['description']) + $cuProfile['description'] = $_; + + if ($_ = $this->_post['source']) // should i also set sourcename? + $cuProfile['sourceId'] = $_; + + if ($_ = $this->_post['copy']) // gets set to source profileId when "save as" is clicked. Whats the difference to 'source' though? + { + // get character origin info if possible + if ($r = DB::Aowow()->selectCell('SELECT `realm` FROM ::profiler_profiles WHERE `id` = %i AND `custom` = 0', $_)) + $cuProfile['realm'] = $r; + + $cuProfile['sourceId'] = $_; + } + + if (!empty($cuProfile['sourceId'])) + $cuProfile['sourceName'] = DB::Aowow()->selectCell('SELECT `name` FROM ::profiler_profiles WHERE `id` = %i', $cuProfile['sourceId']); + + $charId = -1; + if ($id = $this->_get['id'][0]) // update + { + if ($charId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_profiles WHERE `id` = %i', $id)) + DB::Aowow()->qry('UPDATE ::profiler_profiles SET %a WHERE `id` = %i', $cuProfile, $id); + } + else // new + { + $nProfiles = DB::Aowow()->selectCell('SELECT COUNT(*) FROM ::profiler_profiles WHERE `user` = %i AND `deleted` = 0 AND `custom` = 1', User::$id); + if ($nProfiles < 10 || User::isPremium()) + if ($newId = DB::Aowow()->qry('INSERT INTO ::profiler_profiles %v', $cuProfile)) + $charId = $newId; + } + + // update items + if ($charId != -1) + { + // ok, 'funny' thing: whether an item has en extra prismatic socket is determined contextually + // either the socket is -1 or it has an itemId on an index where there shouldn't be one + $keys = ['id', 'slot', 'item', 'subitem', 'permEnchant', 'tempEnchant', 'gem1', 'gem2', 'gem3', 'gem4']; + + // validate Enchantments + $enchIds = array_merge( + array_column($this->_post['inv'], 3), // perm enchantments + array_column($this->_post['inv'], 4) // temp enchantments (not used..?) + ); + $enchs = new EnchantmentList(array(['id', $enchIds])); + + // validate items + $itemIds = array_merge( + array_column($this->_post['inv'], 1), // base item + array_column($this->_post['inv'], 5), // gem slot 1 + array_column($this->_post['inv'], 6), // gem slot 2 + array_column($this->_post['inv'], 7), // gem slot 3 + array_column($this->_post['inv'], 8) // gem slot 4 + ); + + $items = new ItemList(array(['id', $itemIds])); + if (!$items->error) + { + foreach ($this->_post['inv'] as $slot => $itemData) + { + if (!$itemData) + { + trigger_error('ProfileSaveResponse::generate - skipping malformed inventory definition for slot #'.$slot.': '.Util::toString($itemData), E_USER_NOTICE); + continue; + } + + if ($slot + 1 == array_sum($itemData)) // only slot definition set => empty slot + { + DB::Aowow()->qry('DELETE FROM ::profiler_items WHERE `id` = %i AND `slot` = %i', $charId, $itemData[0]); + continue; + } + + // item does not exist + if (!$items->getEntry($itemData[1])) + continue; + + // sub-item check + if (!$items->getRandEnchantForItem($itemData[1])) + $itemData[2] = 0; + + // item sockets are fubar + $nSockets = $items->json[$itemData[1]]['nsockets'] ?? 0; + $nSockets += in_array($slot, [SLOT_WAIST, SLOT_WRISTS, SLOT_HANDS]) ? 1 : 0; + for ($i = 5; $i < 9; $i++) + if ($itemData[$i] > 0 && (!$items->getEntry($itemData[$i]) || $i >= (5 + $nSockets))) + $itemData[$i] = 0; + + // item enchantments are borked + if ($itemData[3] && !$enchs->getEntry($itemData[3])) + $itemData[3] = 0; + + if ($itemData[4] && !$enchs->getEntry($itemData[4])) + $itemData[4] = 0; + + // looks good + array_unshift($itemData, $charId); + DB::Aowow()->qry('REPLACE INTO ::profiler_items %v', array_combine($keys, $itemData)); + } + } + } + + $this->result = $charId; + } + + protected static function checkTalentString(string $val) : string + { + if (preg_match('/^\d+$/', $val)) + return $val; + + return ''; + } + + protected static function checkGlyphString(string $val) : string + { + if (preg_match('/^\d+(:\d+)*$/', $val)) + return $val; + + return ''; + } +} + +?> diff --git a/endpoints/profile/status.php b/endpoints/profile/status.php new file mode 100644 index 00000000..ac086f84 --- /dev/null +++ b/endpoints/profile/status.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'guild' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']], + 'arena-team' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkEmptySet']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + return + + */ + protected function generate() : void + { + // roster resync for this guild was requested -> get char list + if ($this->_get['guild']) + $ids = DB::Aowow()->selectCol('SELECT `id` FROM ::profiler_profiles WHERE `guild` IN %in', $this->_get['id']); + else if ($this->_get['arena-team']) + $ids = DB::Aowow()->selectCol('SELECT `profileId` FROM ::profiler_arena_team_member WHERE `arenaTeamId` IN %in', $this->_get['id']); + else + $ids = $this->_get['id']; + + if (!$ids) + { + trigger_error('ProfileStatusResponse - no profileIds to resync'.($this->_get['guild'] ? ' for guild #' : ($this->_get['arena-team'] ? ' for areana team #' : ' #')).Util::toString($this->_get['id']), E_USER_WARNING); + $this->result = Util::toJSON([1, [PR_QUEUE_STATUS_ERROR, 0, 0, PR_QUEUE_ERROR_CHAR]]); + } + + $this->result = Profiler::resyncStatus(Type::PROFILE, $ids); + } +} + +?> diff --git a/endpoints/profile/summary.php b/endpoints/profile/summary.php new file mode 100644 index 00000000..26eddd1d --- /dev/null +++ b/endpoints/profile/summary.php @@ -0,0 +1,17 @@ + diff --git a/endpoints/profile/unlink.php b/endpoints/profile/unlink.php new file mode 100644 index 00000000..322521e3 --- /dev/null +++ b/endpoints/profile/unlink.php @@ -0,0 +1,62 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateUsername']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional] // user page this is may be executed from + return: + null + */ + protected function generate() : void // links char with account + { + if (!$this->assertGET('id')) + { + trigger_error('ProfileUnlinkResponse - profileId empty', E_USER_ERROR); + return; + } + + if ($this->_get['user'] && User::$username != $this->_get['user'] && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + { + trigger_error('ProfileUnlinkResponse - user #'.User::$id.' tried to unlink profiles from "'.$this->_get['user'], E_USER_ERROR); + return; + } + + $uid = 0; + if (!$this->_get['user'] || User::$username == $this->_get['user']) + $uid = User::$id; + else if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $uid = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user']); + + if (!$uid) + { + trigger_error('ProfileUnlinkResponse - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); + return; + } + + DB::Aowow()->qry('DELETE FROM ::account_profiles WHERE `accountId` = %i AND `profileId` IN %in', $uid, $this->_get['id']); + } +} + +?> diff --git a/endpoints/profile/unpin.php b/endpoints/profile/unpin.php new file mode 100644 index 00000000..dceb00f8 --- /dev/null +++ b/endpoints/profile/unpin.php @@ -0,0 +1,55 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdList'] ], + 'user' => ['filter' => FILTER_CALLBACK, 'options' => [Util::class, 'validateUsername']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generate404(); + } + + /* params + id: + user: [optional] + return: null + */ + protected function generate() : void // (un)favorite + { + if (!$this->assertGET('id')) + { + trigger_error('ProfileUnpinResponse - profileId empty', E_USER_ERROR); + return; + } + + $uid = 0; + if (!$this->_get['user'] || User::$username == $this->_get['user']) + $uid = User::$id; + else if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $uid = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE LOWER(`username`) = LOWER(%s)', $this->_get['user']); + + if (!$uid) + { + trigger_error('ProfileUnpinResponse - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); + return; + } + + DB::Aowow()->qry('UPDATE ::account_profiles SET `extraFlags` = `extraFlags` & ~%i WHERE `accountId` = %i', PROFILER_CU_PINNED, $uid); + } +} + +?> diff --git a/endpoints/profiler/profiler.php b/endpoints/profiler/profiler.php new file mode 100644 index 00000000..eb6f79e0 --- /dev/null +++ b/endpoints/profiler/profiler.php @@ -0,0 +1,52 @@ +generateError(); + } + + protected function generate() : void + { + // just so the form does not break. There won't be any results. + $usedRegions = array_column(Profiler::getRealms(), 'region') ?: ['us']; + foreach (Util::$regions as $idx => $id) + if (in_array($id, $usedRegions)) + $this->regions[$id] = [Lang::profiler('regions', $id), $idx + 1]; + + if (!in_array($this->rg, $usedRegions)) + $this->rg = key($this->regions); + + array_unshift($this->title, Util::ucFirst(Lang::profiler('profiler'))); + + parent::generate(); + } +} + +?> diff --git a/endpoints/profiles/profiles.php b/endpoints/profiles/profiles.php new file mode 100644 index 00000000..8821cb0c --- /dev/null +++ b/endpoints/profiles/profiles.php @@ -0,0 +1,224 @@ + Profiler > Characters + + protected array $dataLoader = ['weight-presets', 'realms']; + protected array $scripts = array( + [SC_JS_FILE, 'js/filters.js'], + [SC_JS_FILE, 'js/profile_all.js'], + [SC_JS_FILE, 'js/profile.js'], + [SC_CSS_FILE, 'css/Profiler.css'] + ); + protected array $expectedGET = array( + 'filter' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]], + // 1 guild; 2,3,4 arenateam (4 => 5-man): puts a resync button on the lv (was probably used before arenateams and guilds had a dedicated page) + 'roster' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_value' => 1, 'max_value' => 4]] + ); + + public int $type = Type::PROFILE; + public string $roster = ''; + + private int $sumSubjects = 0; + + public function __construct(string $rawParam) + { + $this->getSubjectFromUrl($rawParam); + + parent::__construct($rawParam); + + if (!Cfg::get('PROFILER_ENABLE')) + $this->generateError(); + + $realms = []; + foreach (Profiler::getRealms() as $idx => $r) + { + if ($this->region && $r['region'] != $this->region) + continue; + + if ($this->realm && $r['name'] != $this->realm) + continue; + + $this->sumSubjects += DB::Characters($idx)->selectCell('SELECT COUNT(*) FROM characters WHERE `deleteInfos_Name` IS NULL AND `level` <= %i AND (`extra_flags` & ?) = 0', MAX_LEVEL, Profiler::CHAR_GMFLAGS); + $realms[] = $idx; + } + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new ProfileListFilter($this->_get['filter'] ?? '', ['realms' => $realms]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('profiles')); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->followBreadcrumbPath(); + + + /**************/ + /* Page Title */ + /**************/ + + if ($this->realm) + array_unshift($this->title, $this->realm,/* Cfg::get('BATTLEGROUP'),*/ Lang::profiler('regions', $this->region), Lang::game('profiles')); + else if ($this->region) + array_unshift($this->title, Lang::profiler('regions', $this->region), Lang::game('profiles')); + else + array_unshift($this->title, Lang::game('profiles')); + + + /****************/ + /* Main Content */ + /****************/ + + $conditions = [Listview::DEFAULT_SIZE]; + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + $fiExtraCols = $this->filter->fiExtraCols; + + $lvData = []; + $lvExtraCols = []; + $lvVisibleCols = ['race', 'classs', 'level', 'talents', 'achievementpoints', 'gearscore']; + $lvHiddenCols = []; + $lvNote = ''; + $lv_truncated = 0; + + $this->getRegions(); + + foreach ($fiExtraCols as $skill => $idx) + $lvExtraCols[] = "\$Listview.funcBox.createSimpleCol('skill-' + ".$skill.", g_spell_skills[".$skill."], '7%', 'skill-' + ".$skill.")"; + + if (!$this->filter->useLocalList) + { + $conditions[] = ['deleteInfos_Name', null]; + $conditions[] = ['level', MAX_LEVEL, '<=']; // prevents JS errors + $conditions[] = [['extra_flags', Profiler::CHAR_GMFLAGS, '&'], 0]; + } + + $miscParams = ['calcTotal' => true]; + if ($this->realm) + $miscParams['sv'] = $this->realm; + if ($this->region) + $miscParams['rg'] = $this->region; + if ($_ = $this->filter->extraOpts) + $miscParams['extraOpts'] = $_; + + if ($this->filter->useLocalList) + $profiles = new LocalProfileList($conditions, $miscParams); + else + $profiles = new RemoteProfileList($conditions, $miscParams); + + if (!$profiles->error) + { + // init these chars on our side and get local ids + if (!$this->filter->useLocalList) + $profiles->initializeLocalEntries(); + + // Roster only if single realm selected + $roster = $this->realmId ? $this->_get['roster'] : 0; + if (!$roster && $this->realmId) + if (count($r = $this->filter->getSetCriteria(9, 12, 15, 18)) == 1) + $roster = ($r[0] - 6) / 3; // 1, 2, 3, or 4 + + $addInfoMask = PROFILEINFO_CHARACTER; + + // team rating filters + if ($this->filter->getSetCriteria(13, 16, 19)) + { + $lvVisibleCols[] = 'rating'; + $addInfoMask |= PROFILEINFO_ARENA; + } + + // init roster-listview + if ($roster == 1 && !$profiles->hasDiffFields('guild') && $profiles->getField('guild')) + { + $lvVisibleCols[] = 'guildrank'; + $lvHiddenCols[] = 'guild'; + + $this->roster = Lang::profiler('guildRoster', [$profiles->getField('guildname')]); + } + else if ($roster && !$profiles->hasDiffFields('arenateam') && $profiles->getField('arenateam')) + { + $lvVisibleCols[] = 'rating'; + + $addInfoMask |= PROFILEINFO_ARENA; + $this->roster = Lang::profiler('arenaRoster', [$profiles->getField('arenateam')]); + } + + $lvData = $profiles->getListviewData($addInfoMask, $fiExtraCols); + + if ($this->filter->getSetCriteria(10) && !in_array('guildrank', $lvHiddenCols)) + $lvVisibleCols[] = 'guildrank'; + + // create note if search limit was exceeded + if ($this->filter->query && $profiles->getMatches() > Listview::DEFAULT_SIZE) + { + $lvNote = sprintf(Util::$tryFilteringString, 'LANG.lvnote_charactersfound2', $this->sumSubjects, $profiles->getMatches()); + $lv_truncated = 1; + } + else if ($profiles->getMatches() > Listview::DEFAULT_SIZE) + $lvNote = sprintf(Util::$tryFilteringString, 'LANG.lvnote_charactersfound', $this->sumSubjects, 0); + + if ($this->filter->useLocalList) + { + if (!empty($lvNote)) + $lvNote .= ' + "
'.Lang::profiler('complexFilter').'"'; + else + $lvNote = ''.Lang::profiler('complexFilter').''; + } + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated'); + + $this->lvTabs->addListviewTab(new Listview(array( + 'id' => 'characters', + 'data' => $lvData, + 'hideCount' => 1, + 'onBeforeCreate' => '$pr_initRosterListview', // puts a resync button on the lv + 'extraCols' => $lvExtraCols ?: null, + 'visibleCols' => $lvVisibleCols, + 'hiddenCols' => $lvHiddenCols ?: null, + 'note' => $lvNote ?: null, + '_truncated' => $lv_truncated ?: null + ), ProfileList::$brickFile)); + + parent::generate(); + + $this->result->registerDisplayHook('filter', [self::class, 'filterFormHook']); + } + + public static function filterFormHook(Template\PageTemplate &$pt, ProfileListFilter $filter) : void + { + // sort for dropdown-menus + Lang::sort('game', 'ra'); + Lang::sort('game', 'cl'); + } +} + +?> diff --git a/pages/quest.php b/endpoints/quest/quest.php similarity index 53% rename from pages/quest.php rename to endpoints/quest/quest.php index fd26b1a7..b3d1d74e 100644 --- a/pages/quest.php +++ b/endpoints/quest/quest.php @@ -1,85 +1,93 @@ ['filter' => FILTER_CALLBACK, 'options' => 'GenericPage::checkDomain']]; + protected string $template = 'quest'; + protected string $pageName = 'quest'; + protected ?int $activeTab = parent::TAB_DATABASE; + protected array $breadcrumb = [0, 3]; - private $catExtra = array( - 3526 => 3524, - 363 => 14, - 220 => 215, - 188 => 141, - 1769 => 361, - 25 => 46, - 132 => 1, - 3431 => 3430, - 154 => 85, - 9 => 12 - ); + protected array $scripts = [[SC_JS_FILE, 'js/ShowOnMap.js']]; - private $powerTpl = '$WowheadPower.registerQuest(%d, %d, %s);'; + public int $type = Type::QUEST; + public int $typeId = 0; + public array $objectiveList = []; + public ?IconElement $providedItem = null; + public array $mail = []; + public ?array $gains = null; // why array|null ? because destructuring an array with less elements than expected is an error, destructuring null just returns false + public ?array $rewards = null; // so " if ([$spells, $items, $choice, $money] = $this->rewards): " will either work or cleanly branch to else + public string $objectives = ''; + public string $details = ''; + public string $offerReward = ''; + public string $requestItems = ''; + public string $completed = ''; + public string $end = ''; + public int $suggestedPl = 1; + public bool $unavailable = false; - public function __construct($pageCall, $id) + private QuestList $subject; + + public function __construct(string $id) { - parent::__construct($pageCall, $id); + parent::__construct($id); - // temp locale - if ($this->mode == CACHE_TYPE_TOOLTIP && $this->_get['domain']) - Util::powerUseLocale($this->_get['domain']); - - $this->typeId = intVal($id); + $this->typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + protected function generate() : void + { $this->subject = new QuestList(array(['id', $this->typeId])); if ($this->subject->error) - $this->notFound(Lang::game('quest'), Lang::quest('notFound')); + $this->generateNotFound(Lang::game('quest'), Lang::quest('notFound')); - // may contain htmlesque tags - $this->name = Util::htmlEscape($this->subject->getField('name', true)); - } + $this->h1 = Lang::unescapeUISequences(Util::htmlEscape($this->subject->getField('name', true)), Lang::FMT_HTML); - protected function generatePath() - { - // recreate path - $this->path[] = $this->subject->getField('cat2'); - if ($_ = $this->subject->getField('cat1')) - { - if (isset($this->catExtra[$_])) - $this->path[] = $this->catExtra[$_]; + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_HTML) + ); - $this->path[] = $_; - } - } - - protected function generateTitle() - { - // page title already escaped - array_unshift($this->title, $this->subject->getField('name', true), Util::ucFirst(Lang::game('quest'))); - } - - protected function generateContent() - { $_level = $this->subject->getField('level'); $_minLevel = $this->subject->getField('minLevel'); $_flags = $this->subject->getField('flags'); $_specialFlags = $this->subject->getField('specialFlags'); - $_side = Game::sideByRaceMask($this->subject->getField('reqRaceMask')); + $_side = ChrRace::sideFromMask($this->subject->getField('reqRaceMask')); + $hasCompletion = !($_flags & QUEST_FLAG_UNAVAILABLE || $this->subject->getField('cuFlags') & CUSTOM_EXCLUDE_FOR_LISTVIEW); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('cat2'); + if ($cat = $this->subject->getField('cat1')) + { + foreach (Game::$questSubCats as $parent => $children) + if (in_array($cat, $children)) + $this->breadcrumb[] = $parent; + + $this->breadcrumb[] = $cat; + } + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, Lang::unescapeUISequences($this->subject->getField('name', true), Lang::FMT_RAW), Util::ucFirst(Lang::game('quest'))); + /***********/ /* Infobox */ @@ -91,7 +99,7 @@ class QuestPage extends GenericPage if ($_ = $this->subject->getField('eventId')) { $this->extendGlobalIds(Type::WORLDEVENT, $_); - $infobox[] = Lang::game('eventShort').Lang::main('colon').'[event='.$_.']'; + $infobox[] = Lang::game('eventShort', ['[event='.$_.']']); } // level @@ -105,29 +113,29 @@ class QuestPage extends GenericPage if ($_ = $this->subject->getField('maxLevel')) $lvl .= ' - '.$_; - $infobox[] = sprintf(Lang::game('reqLevel'), $lvl); + $infobox[] = Lang::game('reqLevel', [$lvl]); } // loremaster (i dearly hope those flags cover every case...) - if ($this->subject->getField('zoneOrSortBak') > 0 && !$this->subject->isRepeatable()) + if ($this->subject->getField('questSortIdBak') > 0 && !$this->subject->isRepeatable()) { $conditions = array( ['ac.type', ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_QUESTS_IN_ZONE], - ['ac.value1', $this->subject->getField('zoneOrSortBak')], + ['ac.value1', $this->subject->getField('questSortIdBak')], ['a.faction', $_side, '&'] ); $loremaster = new AchievementList($conditions); $this->extendGlobalData($loremaster->getJSGlobals(GLOBALINFO_SELF)); - switch ($loremaster->getMatches()) + switch (count($loremaster->getFoundIds())) { case 0: break; case 1: - $infobox[] = Lang::quest('loremaster').Lang::main('colon').'[achievement='.$loremaster->id.']'; + $infobox[] = Lang::quest('loremaster').'[achievement='.$loremaster->id.']'; break; default: - $lm = Lang::quest('loremaster').Lang::main('colon').'[ul]'; + $lm = Lang::quest('loremaster').'[ul]'; foreach ($loremaster->iterate() as $id => $__) $lm .= '[li][achievement='.$id.'][/li]'; @@ -145,24 +153,23 @@ class QuestPage extends GenericPage else if ($_specialFlags & QUEST_FLAG_SPECIAL_MONTHLY) $_[] = Lang::quest('monthly'); - if ($t = $this->subject->getField('type')) + if ($t = $this->subject->getField('questInfoId')) $_[] = Lang::quest('questInfo', $t); if ($_) - $infobox[] = Lang::game('type').Lang::main('colon').implode(' ', $_); + $infobox[] = Lang::game('type').implode(' ', $_); // side - $_ = Lang::main('side').Lang::main('colon'); - switch ($_side) + $infobox[] = Lang::main('side') . match ($_side) { - case 3: $infobox[] = $_.Lang::game('si', 3); break; - case 2: $infobox[] = $_.'[span class=icon-horde]'.Lang::game('si', 2).'[/span]'; break; - case 1: $infobox[] = $_.'[span class=icon-alliance]'.Lang::game('si', 1).'[/span]'; break; - } + SIDE_ALLIANCE => '[span class=icon-alliance]'.Lang::game('si', SIDE_ALLIANCE).'[/span]', + SIDE_HORDE => '[span class=icon-horde]'.Lang::game('si', SIDE_HORDE).'[/span]', + default => Lang::game('si', SIDE_BOTH) // 0, 3 + }; - $jsg = []; // races - if ($_ = Lang::getRaceString($this->subject->getField('reqRaceMask'), $jsg, false)) + $jsg = []; + if (($_ = Lang::getRaceString($this->subject->getField('reqRaceMask'), $jsg, Lang::FMT_MARKUP)) && $jsg) { $this->extendGlobalIds(Type::CHR_RACE, ...$jsg); $t = count($jsg) == 1 ? Lang::game('race') : Lang::game('races'); @@ -170,7 +177,8 @@ class QuestPage extends GenericPage } // classes - if ($_ = Lang::getClassString($this->subject->getField('reqClassMask'), $jsg, false)) + $jsg = []; + if ($_ = Lang::getClassString($this->subject->getField('reqClassMask'), $jsg, Lang::FMT_MARKUP)) { $this->extendGlobalIds(Type::CHR_CLASS, ...$jsg); $t = count($jsg) == 1 ? Lang::game('class') : Lang::game('classes'); @@ -185,17 +193,17 @@ class QuestPage extends GenericPage if ($_ = $this->subject->getField('reqSkillPoints')) $sk .= ' ('.$_.')'; - $infobox[] = Lang::quest('profession').Lang::main('colon').$sk; + $infobox[] = Lang::quest('profession').$sk; } // timer if ($_ = $this->subject->getField('timeLimit')) - $infobox[] = Lang::quest('timer').Lang::main('colon').Util::formatTime($_ * 1000); + $infobox[] = Lang::quest('timer').DateTime::formatTimeElapsedFloat($_ * 1000); - $startEnd = DB::Aowow()->select('SELECT * FROM ?_quests_startend WHERE questId = ?d', $this->typeId); + $startEnd = DB::Aowow()->selectAssoc('SELECT * FROM ::quests_startend WHERE `questId` = %i', $this->typeId); // start - $start = '[icon name=quest_start'.($this->subject->isRepeatable() ? '_daily' : '').']'.Lang::event('start').Lang::main('colon').'[/icon]'; + $start = '[icon name=quest_start'.($this->subject->isRepeatable() ? '_daily' : '').']'.Lang::event('start').'[/icon]'; $s = []; foreach ($startEnd as $se) { @@ -210,7 +218,7 @@ class QuestPage extends GenericPage $infobox[] = implode('[br]', $s); // end - $end = '[icon name=quest_end'.($this->subject->isRepeatable() ? '_daily' : '').']'.Lang::event('end').Lang::main('colon').'[/icon]'; + $end = '[icon name=quest_end'.($this->subject->isRepeatable() ? '_daily' : '').']'.Lang::event('end').'[/icon]'; $e = []; foreach ($startEnd as $se) { @@ -225,7 +233,7 @@ class QuestPage extends GenericPage $infobox[] = implode('[br]', $e); // auto accept - if ($_flags & QUEST_FLAG_AUTO_ACCEPT) + if ($this->subject->isAutoAccept()) $infobox[] = Lang::quest('autoaccept'); // Repeatable @@ -236,7 +244,7 @@ class QuestPage extends GenericPage $infobox[] = $_flags & QUEST_FLAG_SHARABLE ? Lang::quest('sharable') : Lang::quest('notSharable'); // Keeps you PvP flagged - if ($this->subject->isPvPEnabled()) + if ($_flags & QUEST_FLAG_FLAGS_PVP) $infobox[] = Lang::quest('keepsPvpFlag'); // difficulty (todo (low): formula unclear. seems to be [minLevel,] -4, -2, (level), +3, +(9 to 15)) @@ -262,129 +270,34 @@ class QuestPage extends GenericPage $_[] = '[color=r4]'.($_level + 3 + ceil(12 * $_level / MAX_LEVEL)).'[/color]'; if ($_) - $infobox[] = Lang::game('difficulty').Lang::main('colon').implode('[small]  [/small]', $_); + $infobox[] = Lang::game('difficulty').implode('[small]  [/small]', $_); } - $this->infobox = '[ul][li]'.implode('[/li][li]', $infobox).'[/li][/ul]'; + // id + $infobox[] = Lang::quest('id') . $this->typeId; - /**********/ - /* Series */ - /**********/ - - // Quest Chain (are there cases where quests go in parallel?) - $chain = array( - array( - array( - 'side' => $_side, - 'typeStr' => Type::getFileString(Type::QUEST), - 'typeId' => $this->typeId, - 'name' => $this->name, - '_next' => $this->subject->getField('nextQuestIdChain') - ) - ) - ); - - $_ = $chain[0][0]; - while ($_) + // profiler relateed (note that this is part of the cache. I don't think this is important enough to calc for every view) + if (Cfg::get('PROFILER_ENABLE') && $hasCompletion) { - if ($_ = DB::Aowow()->selectRow('SELECT id AS typeId, IF(id = nextQuestIdChain, 1, 0) AS error, name_loc0, name_loc2, name_loc3, name_loc6, name_loc8, reqRaceMask FROM ?_quests WHERE nextQuestIdChain = ?d', $_['typeId'])) - { - if ($_['error']) - { - trigger_error('Quest '.$_['typeId'].' is in a chain with itself'); - break; - } + $x = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_completion_quests WHERE `questId` = %i', $this->typeId); + $y = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_profiles WHERE `custom` = 0 AND `stub` = 0'); + $infobox[] = Lang::profiler('attainedBy', [round(($x ?: 0) * 100 / ($y ?: 1))]); - $n = Util::localizedString($_, 'name'); - array_unshift($chain, array( - array( - 'side' => Game::sideByRaceMask($_['reqRaceMask']), - 'typeStr' => Type::getFileString(Type::QUEST), - 'typeId' => $_['typeId'], - 'name' => Util::htmlEscape(Lang::trimTextClean($n, 40)), - ) - )); - } + // completion row added by InfoboxMarkup } - $_ = end($chain)[0]; - while ($_) - { - if ($_ = DB::Aowow()->selectRow('SELECT id AS typeId, IF(id = nextQuestIdChain, 1, 0) AS error, name_loc0, name_loc2, name_loc3, name_loc6, name_loc8, reqRaceMask, nextQuestIdChain AS _next FROM ?_quests WHERE id = ?d', $_['_next'])) - { - if ($_['error']) // error already triggered - break; + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; - $n = Util::localizedString($_, 'name'); - array_push($chain, array( - array( - 'side' => Game::sideByRaceMask($_['reqRaceMask']), - 'typeStr' => Type::getFileString(Type::QUEST), - 'typeId' => $_['typeId'], - 'name' => Util::htmlEscape(Lang::trimTextClean($n, 40)), - '_next' => $_['_next'], - ) - )); - } - } + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0', $hasCompletion); - if (count($chain) > 1) - $this->series[] = [$chain, null]; - - - // todo (low): sensibly merge the following lists into 'series' - $listGen = function($cnd) - { - $chain = []; - $list = new QuestList($cnd); - if ($list->error) - return null; - - foreach ($list->iterate() as $id => $__) - { - $n = $list->getField('name', true); - $chain[] = array(array( - 'side' => Game::sideByRaceMask($list->getField('reqRaceMask')), - 'typeStr' => Type::getFileString(Type::QUEST), - 'typeId' => $id, - 'name' => Util::htmlEscape(Lang::trimTextClean($n, 40)) - )); - } - - return $chain; - }; - - $extraLists = array( - // Requires all of these quests (Quests that you must follow to get this quest) - ['reqQ', array('OR', ['AND', ['nextQuestId', $this->typeId], ['exclusiveGroup', 0, '<']], ['AND', ['id', $this->subject->getField('prevQuestId')], ['nextQuestIdChain', $this->typeId, '!']])], - - // Requires one of these quests (Requires one of the quests to choose from) - ['reqOneQ', array('OR', ['AND', ['exclusiveGroup', 0, '>'], ['nextQuestId', $this->typeId]], ['breadCrumbForQuestId', $this->typeId])], - - // Opens Quests (Quests that become available only after complete this quest (optionally only one)) - ['opensQ', array('OR', ['AND', ['prevQuestId', $this->typeId], ['id', $this->subject->getField('nextQuestIdChain'), '!']], ['id', $this->subject->getField('nextQuestId')], ['id', $this->subject->getField('breadcrumbForQuestId')])], - - // Closes Quests (Quests that become inaccessible after completing this quest) - ['closesQ', array(['exclusiveGroup', 0, '>'], ['exclusiveGroup', $this->subject->getField('exclusiveGroup')], ['id', $this->typeId, '!'])], - - // During the quest available these quests (Quests that are available only at run time this quest) - ['enablesQ', array(['prevQuestId', -$this->typeId])], - - // Requires an active quest (Quests during the execution of which is available on the quest) - ['enabledByQ', array(['id', -$this->subject->getField('prevQuestId')])] - ); - - foreach ($extraLists as $el) - if ($_ = $listGen($el[1])) - $this->series[] = [$_, sprintf(Util::$dfnString, Lang::quest($el[0].'Desc'), Lang::quest($el[0]))]; /*******************/ /* Objectives List */ /*******************/ - $this->objectiveList = []; - $this->providedItem = []; - // gather ids for lookup $olItems = $olNPCs = $olGOs = $olFactions = []; $olItemData = $olNPCData = $olGOData = null; @@ -406,7 +319,7 @@ class QuestPage extends GenericPage $olItems[$i] = [$id, $qty, $id == $olItems[0][0]]; } - if ($ids = array_column($olItems, 0)) + if ($ids = array_filter(array_column($olItems, 0))) { $olItemData = new ItemList(array(['id', $ids])); $this->extendGlobalData($olItemData->getJSGlobals(GLOBALINFO_SELF)); @@ -414,31 +327,45 @@ class QuestPage extends GenericPage $providedRequired = false; foreach ($olItems as $i => [$itemId, $qty, $provided]) { - if (!$i || !$itemId || !in_array($itemId, $olItemData->getFoundIDs())) + if (!$i || !$itemId) continue; if ($provided) $providedRequired = true; - $this->objectiveList[] = array( - 'typeStr' => Type::getFileString(Type::ITEM), - 'id' => $itemId, - 'name' => $olItemData->json[$itemId]['name'], - 'qty' => $qty > 1 ? $qty : 0, - 'quality' => 7 - $olItemData->json[$itemId]['quality'], - 'extraText' => $provided ? ' ('.Lang::quest('provided').')' : '' + if (!$olItemData->getEntry($itemId)) + { + $this->objectiveList[] = new IconElement(0, 0, Util::ucFirst(Lang::game('item')).' #'.$itemId, $qty > 1 ? $qty : '', size: IconElement::SIZE_SMALL, extraText: $provided ? Lang::quest('provided') : null); + continue; + } + + $this->objectiveList[] = new IconElement( + Type::ITEM, + $itemId, + Lang::unescapeUISequences($olItemData->json[$itemId]['name'], Lang::FMT_HTML), + num: $qty > 1 ? $qty : '', + quality: 7 - $olItemData->json[$itemId]['quality'], + size: IconElement::SIZE_SMALL, + element: 'iconlist-icon', + extraText: $provided ? Lang::quest('provided') : null ); } // if providd item is not required by quest, list it below other requirements - if (!$providedRequired && $olItems[0][0] && in_array($olItems[0][0], $olItemData->getFoundIDs())) + if (!$providedRequired && $olItems[0][0]) { - $this->providedItem = array( - 'id' => $olItems[0][0], - 'name' => $olItemData->json[$olItems[0][0]]['name'], - 'qty' => $olItems[0][1] > 1 ? $olItems[0][1] : 0, - 'quality' => 7 - $olItemData->json[$olItems[0][0]]['quality'] - ); + if (!$olItemData->getEntry($olItems[0][0])) + $this->providedItem = new IconElement(0, 0, Util::ucFirst(Lang::game('item')).' #'.$itemId, $olItems[0][1] > 1 ? $olItems[0][1] : ''); + else + $this->providedItem = new IconElement( + Type::ITEM, + $olItems[0][0], + Lang::unescapeUISequences($olItemData->json[$olItems[0][0]]['name'], Lang::FMT_HTML), + num: $olItems[0][1] > 1 ? $olItems[0][1] : '', + quality: 7 - $olItemData->json[$olItems[0][0]]['quality'], + size: IconElement::SIZE_SMALL, + element: 'iconlist-icon' + ); } } @@ -457,7 +384,7 @@ class QuestPage extends GenericPage // .. creature kills if ($ids = array_keys($olNPCs)) { - $olNPCData = new CreatureList(array('OR', ['id', $ids], ['killCredit1', $ids], ['killCredit2', $ids])); + $olNPCData = new CreatureList(array(DB::OR, ['id', $ids], ['killCredit1', $ids], ['killCredit2', $ids])); $this->extendGlobalData($olNPCData->getJSGlobals(GLOBALINFO_SELF)); // create proxy-references @@ -472,24 +399,40 @@ class QuestPage extends GenericPage $olNPCs[$p][2][$id] = $olNPCData->getField('name', true); } - foreach ($olNPCs as $i => $pair) + foreach ($olNPCs as $i => [$qty, $altText, $proxies]) { - if (!$i || !in_array($i, $olNPCData->getFoundIDs())) + if (!$i) continue; - $ol = array( - 'typeStr' => Type::getFileString(Type::NPC), - 'id' => $i, - 'name' => $pair[1] ?: Util::localizedString($olNPCData->getEntry($i), 'name'), - 'qty' => $pair[0] > 1 ? $pair[0] : 0, - 'extraText' => (($_specialFlags & QUEST_FLAG_SPECIAL_SPELLCAST) || $pair[1]) ? '' : ' '.Lang::achievement('slain'), - 'proxy' => $pair[2] - ); + if ($proxies) // has proxies assigned, add yourself as another proxy + { + $proxies[$i] = Util::localizedString($olNPCData->getEntry($i), 'name'); - if ($pair[2]) // has proxies assigned, add yourself as another proxy - $ol['proxy'][$i] = Util::localizedString($olNPCData->getEntry($i), 'name'); + // split in two blocks for display + $proxies = array( + array_slice($proxies, 0, ceil(count($proxies) / 2), true), + array_slice($proxies, ceil(count($proxies) / 2), null, true) + ); - $this->objectiveList[] = $ol; + $this->objectiveList[] = array( + 'id' => $i, + 'text' => ($altText ?: Util::localizedString($olNPCData->getEntry($i), 'name')) . ((($_specialFlags & QUEST_FLAG_SPECIAL_SPELLCAST) || $altText) ? '' : ' '.Lang::achievement('slain')), + 'qty' => $qty > 1 ? $qty : 0, + 'proxy' => array_filter($proxies) + ); + } + else if (!$olNPCData->getEntry($i)) + $this->objectiveList[] = new IconElement(0, 0, Util::ucFirst(Lang::game('npc')).' #'.$i, $qty > 1 ? $qty : ''); + else + $this->objectiveList[] = new IconElement( + Type::NPC, + $i, + $altText ?: Util::localizedString($olNPCData->getEntry($i), 'name'), + $qty > 1 ? $qty : '', + size: IconElement::SIZE_SMALL, + element: 'iconlist-icon', + extraText: (($_specialFlags & QUEST_FLAG_SPECIAL_SPELLCAST) || $altText) ? '' : Lang::achievement('slain'), + ); } } @@ -499,18 +442,22 @@ class QuestPage extends GenericPage $olGOData = new GameObjectList(array(['id', $ids])); $this->extendGlobalData($olGOData->getJSGlobals(GLOBALINFO_SELF)); - foreach ($olGOs as $i => $pair) + foreach ($olGOs as $i => [$qty, $altText]) { - if (!$i || !in_array($i, $olGOData->getFoundIDs())) + if (!$i) continue; - $this->objectiveList[] = array( - 'typeStr' => Type::getFileString(Type::OBJECT), - 'id' => $i, - 'name' => $pair[1] ?: Util::localizedString($olGOData->getEntry($i), 'name'), - 'qty' => $pair[0] > 1 ? $pair[0] : 0, - 'extraText' => '' - ); + if (!$olGOData->getEntry($i)) + $this->objectiveList[] = new IconElement(0, 0, Util::ucFirst(Lang::game('object')).' #'.$i, $qty > 1 ? $qty : '', size: IconElement::SIZE_SMALL); + else + $this->objectiveList[] = new IconElement( + Type::OBJECT, + $i, + $altText ?: Lang::unescapeUISequences(Util::localizedString($olGOData->getEntry($i), 'name'), Lang::FMT_HTML), + $qty > 1 ? $qty : '', + size: IconElement::SIZE_SMALL, + element: 'iconlist-icon', + ); } } @@ -535,12 +482,13 @@ class QuestPage extends GenericPage if (!$i || !in_array($i, $olFactionsData->getFoundIDs())) continue; - $this->objectiveList[] = array( - 'typeStr' => Type::getFileString(Type::FACTION), - 'id' => $i, - 'name' => Util::localizedString($olFactionsData->getEntry($i), 'name'), - 'qty' => sprintf(Util::$dfnString, $val.' '.Lang::achievement('points'), Lang::getReputationLevelForPoints($val)), - 'extraText' => '' + $this->objectiveList[] = new IconElement( + Type::FACTION, + $i, + Util::localizedString($olFactionsData->getEntry($i), 'name'), + size: IconElement::SIZE_SMALL, + element: 'iconlist-icon', + extraText: sprintf(Util::$dfnString, $val.' '.Lang::achievement('points'), '('.Lang::getReputationLevelForPoints($val).')') ); } } @@ -549,41 +497,34 @@ class QuestPage extends GenericPage if ($_ = $this->subject->getField('sourceSpellId')) { $this->extendGlobalIds(Type::SPELL, $_); - $this->objectiveList[] = array( - 'typeStr' => Type::getFileString(Type::SPELL), - 'id' => $_, - 'name' => SpellList::getName($_), - 'qty' => 0, - 'extraText' => ' ('.Lang::quest('provided').')' - ); + $this->objectiveList[] = new IconElement(Type::SPELL, $_, SpellList::getName($_), extraText: Lang::quest('provided'), element: 'iconlist-icon', size: IconElement::SIZE_SMALL); } // required money if ($this->subject->getField('rewardOrReqMoney') < 0) - $this->objectiveList[] = ['text' => Lang::quest('reqMoney').Lang::main('colon').Util::formatMoney(abs($this->subject->getField('rewardOrReqMoney')))]; + $this->objectiveList[] = Lang::quest('reqMoney', [Util::formatMoney(abs($this->subject->getField('rewardOrReqMoney')))]); // required pvp kills if ($_ = $this->subject->getField('reqPlayerKills')) - $this->objectiveList[] = ['text' => Lang::quest('playerSlain').' ('.$_.')']; + $this->objectiveList[] = Lang::quest('playerSlain', [$_]); + /**********/ /* Mapper */ /**********/ - $this->addScript([JS_FILE, '?data=zones&locale='.User::$localeId.'&t='.$_SESSION['dataKey']]); - // gather points of interest $mapNPCs = $mapGOs = []; // [typeId, start|end|objective, startItemId] // todo (med): this double list creation very much sucks ... $getItemSource = function ($itemId, $method = 0) use (&$mapNPCs, &$mapGOs) { - $lootTabs = new Loot(); - if ($lootTabs->getByItem($itemId)) + $lootTabs = new LootByItem($itemId); + if ($lootTabs->getByItem()) { /* todo (med): sanity check: - there are loot templates that are absolute tosh, containing hundrets of random items (e.g. Peacebloom for Quest "The Horde Needs Peacebloom!") + there are loot templates that are absolute tosh, containing hundreds of random items (e.g. Peacebloom for Quest "The Horde Needs Peacebloom!") even without these .. consider quests like "A Donation of Runecloth" .. oh my ..... should we... .. display only a maximum of sources? @@ -591,22 +532,22 @@ class QuestPage extends GenericPage for the moment: if an item has >10 sources, only display sources with >80% chance - always filter sources with <1% chance + always filter sources with <5% chance */ $nSources = 0; foreach ($lootTabs->iterate() as [$type, $data]) if ($type == 'creature' || $type == 'object') - $nSources += count(array_filter($data['data'], function($val) { return $val['percent'] >= 1.0; })); + $nSources += count(array_filter($data['data'], fn($x) => $x['percent'] >= 5.0)); - foreach ($lootTabs->iterate() as $idx => [$file, $tabData]) + foreach ($lootTabs->iterate() as [$file, $tabData]) { if (!$tabData['data']) continue; foreach ($tabData['data'] as $data) { - if ($data['percent'] < 1.0) + if ($data['percent'] < 5.0) continue; if ($nSources > 10 && $data['percent'] < 80.0) @@ -614,7 +555,7 @@ class QuestPage extends GenericPage switch ($file) { - case 'creature': + case 'npc': $mapNPCs[] = [$data['id'], $method, $itemId]; break; case 'object': @@ -630,10 +571,11 @@ class QuestPage extends GenericPage // also there's vendors... // dear god, if you are one of the types who puts queststarter-items in container-items, in conatiner-items, in container-items, in container-GOs .. you should kill yourself by killing yourself! // so yeah .. no recursion checking - $vendors = DB::World()->selectCol(' - SELECT nv.entry FROM npc_vendor nv WHERE nv.item = ?d UNION - SELECT c.id FROM game_event_npc_vendor genv JOIN creature c ON c.guid = genv.guid WHERE genv.item = ?d', - $itemId, $itemId + $vendors = DB::World()->selectCol( + 'SELECT nv.`entry` FROM npc_vendor nv WHERE nv.`item` = %i UNION + SELECT nv1.`entry` FROM npc_vendor nv1 JOIN npc_vendor nv2 ON -nv1.`item` = nv2.`entry` WHERE nv2.`item` = %i UNION + SELECT c.`id` FROM game_event_npc_vendor genv JOIN creature c ON c.`guid` = genv.`guid` WHERE genv.`item` = %i', + $itemId, $itemId, $itemId ); foreach ($vendors as $v) $mapNPCs[] = [$v, $method, $itemId]; @@ -659,7 +601,7 @@ class QuestPage extends GenericPage $mObjectives[$zoneId]['levels'][$floor][] = $processing($objId, $objData); } } - }; + }; // POI: start + end @@ -692,21 +634,24 @@ class QuestPage extends GenericPage // PSA: 'redundant' data is on purpose (e.g. creature required for kill, also dropps item required to collect) // external events - $endTextWrapper = '%s'; + $endText = $this->subject->parseText('end', false); if ($_specialFlags & QUEST_FLAG_SPECIAL_EXT_COMPLETE) { // areatrigger - if ($atir = DB::Aowow()->selectCol('SELECT id FROM ?_areatrigger WHERE type = ?d AND quest = ?d', AT_TYPE_OBJECTIVE, $this->typeId)) + if ($atir = DB::Aowow()->selectCol('SELECT `id` FROM ::areatrigger WHERE `type` = %i AND `quest` = %i', AT_TYPE_OBJECTIVE, $this->typeId)) { - if ($atSpawns = DB::AoWoW()->select('SELECT typeId AS ARRAY_KEY, posX, posY, floor, areaId FROM ?_spawns WHERE `type` = ?d AND `typeId` IN (?a)', Type::AREATRIGGER, $atir)) + if ($atSpawns = DB::AoWoW()->selectAssoc('SELECT `typeId` AS ARRAY_KEY, `posX`, `posY`, `floor`, `areaId` FROM ::spawns WHERE `type` = %i AND `typeId` IN %in', Type::AREATRIGGER, $atir)) { + if (User::isInGroup(U_GROUP_STAFF)) + $endText = ''.($endText ?: Lang::areatrigger('unnamed', [$atir[0]])).''; + foreach ($atSpawns as $atId => $atsp) { $atSpawn = array ( 'type' => User::isInGroup(U_GROUP_STAFF) ? Type::AREATRIGGER : -1, 'id' => $atId, 'point' => 'requirement', - 'name' => $this->subject->parseText('end', false), + 'name' => $this->subject->parseText('end', false) ?: Lang::areatrigger('unnamed', [$atir[0]]), 'coord' => [$atsp['posX'], $atsp['posY']], 'coords' => [[$atsp['posX'], $atsp['posY']]], 'objective' => $objectiveIdx++ @@ -727,9 +672,9 @@ class QuestPage extends GenericPage } } // complete-spell - else if ($endSpell = new SpellList(array('OR', ['AND', ['effect1Id', 16], ['effect1MiscValue', $this->typeId]], ['AND', ['effect2Id', 16], ['effect2MiscValue', $this->typeId]], ['AND', ['effect3Id', 16], ['effect3MiscValue', $this->typeId]]))) + else if ($endSpell = new SpellList(array(DB::OR, [DB::AND, ['effect1Id', SPELL_EFFECT_QUEST_COMPLETE], ['effect1MiscValue', $this->typeId]], [DB::AND, ['effect2Id', SPELL_EFFECT_QUEST_COMPLETE], ['effect2MiscValue', $this->typeId]], [DB::AND, ['effect3Id', SPELL_EFFECT_QUEST_COMPLETE], ['effect3MiscValue', $this->typeId]]))) if (!$endSpell->error) - $endTextWrapper = '%s'; + $endText = ''.($endText ?: $endSpell->getField('name', true)).''; } // ..adding creature kill requirements @@ -885,16 +830,26 @@ class QuestPage extends GenericPage // ..process zone data if ($mObjectives) { + // sort zones by amount of mapper points most -> least + $zoneOrder = []; + foreach ($mObjectives as $zoneId => $data) + $zoneOrder[$zoneId] = array_reduce($data['levels'], function($carry, $spawns) { foreach ($spawns as $s) { $carry += count($s['coords']); } return $carry; }); + + arsort($zoneOrder); + $zoneOrder = array_flip(array_keys($zoneOrder)); + $areas = new ZoneList(array(['id', array_keys($mObjectives)])); if (!$areas->error) { - $someIDX = 0; // todo (low): UNK value ... map priority, floor, mapId..? values seen: 0 - 3; doesn't seem to affect anything foreach ($areas->iterate() as $id => $__) { + // [zoneId, selectionPriority] - determines which map link is preselected. (highest index) + $mZones[$zoneOrder[$id]] = [$id, count($zoneOrder) - $zoneOrder[$id]]; $mObjectives[$id]['zone'] = $areas->getField('name', true); - $mZones[] = [$id, ++$someIDX]; } } + + ksort($mZones); } // has start & end? @@ -913,31 +868,37 @@ class QuestPage extends GenericPage } } - $this->map = $mObjectives ? array( - 'mapperData' => [], // always empty - 'data' => array( - 'parent' => 'mapper-generic', - 'objectives' => $mObjectives, - 'zoneparent' => 'mapper-zone-generic', - 'zones' => $mZones, - 'missing' => count($mZones) > 1 || $hasStartEnd != 0x3 ? 1 : 0 // 0 if everything happens in one zone, else 1 - ) - ) : null; + if ($mObjectives) + { + $this->addDataLoader('zones'); + $this->map = array( + array( // Mapper + 'parent' => 'mapper-generic', + 'objectives' => $mObjectives, + 'zoneparent' => 'mapper-zone-generic', + 'zones' => $mZones, + 'missing' => count($mZones) > 1 || $hasStartEnd != 0x3 ? 1 : 0 // 0 if everything happens in one zone, else 1 + ), + new \StdClass(), // mapperData + null, // ShowOnMap + null // foundIn + ); + } /****************/ /* Main Content */ /****************/ - $this->gains = $this->createGains(); - $this->mail = $this->createMail($startEnd); - $this->rewards = $this->createRewards($_side); + $this->series = $this->createSeries(); + $this->gains = $this->createGains($_side); + $this->rewards = $this->createRewards(); $this->objectives = $this->subject->parseText('objectives', false); $this->details = $this->subject->parseText('details', false); $this->offerReward = $this->subject->parseText('offerReward', false); $this->requestItems = $this->subject->parseText('requestItems', false); $this->completed = $this->subject->parseText('completed', false); - $this->end = sprintf($endTextWrapper, $this->subject->parseText('end', false)); + $this->end = $endText; $this->suggestedPl = $this->subject->getField('suggestedPlayers'); $this->unavailable = $_flags & QUEST_FLAG_UNAVAILABLE || $this->subject->getField('cuFlags') & CUSTOM_EXCLUDE_FOR_LISTVIEW; $this->redButtons = array( @@ -945,42 +906,47 @@ class QuestPage extends GenericPage BUTTON_LINKS => array( 'linkColor' => 'ffffff00', 'linkId' => 'quest:'.$this->typeId.':'.$_level, - 'linkName' => $this->name, + 'linkName' => Util::jsEscape($this->subject->getField('name', true)), 'type' => $this->type, 'typeId' => $this->typeId ) ); + if ($this->createMail($startEnd)) + $this->addScript([SC_CSS_FILE, 'css/Book.css']); + // factionchange-equivalent - if ($pendant = DB::World()->selectCell('SELECT IF(horde_id = ?d, alliance_id, -horde_id) FROM player_factionchange_quests WHERE alliance_id = ?d OR horde_id = ?d', $this->typeId, $this->typeId, $this->typeId)) + if ($pendant = DB::World()->selectCell('SELECT IF(`horde_id` = %i, `alliance_id`, -`horde_id`) FROM player_factionchange_quests WHERE `alliance_id` = %i OR `horde_id` = %i', $this->typeId, $this->typeId, $this->typeId)) { $altQuest = new QuestList(array(['id', abs($pendant)])); if (!$altQuest->error) { - $this->transfer = sprintf( - Lang::quest('_transfer'), + $this->transfer = Lang::quest('_transfer', array( $altQuest->id, $altQuest->getField('name', true), $pendant > 0 ? 'alliance' : 'horde', - $pendant > 0 ? Lang::game('si', 1) : Lang::game('si', 2) - ); + $pendant > 0 ? Lang::game('si', SIDE_ALLIANCE) : Lang::game('si', SIDE_HORDE) + )); } } + /**************/ /* Extra Tabs */ /**************/ + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + // tab: see also - $seeAlso = new QuestList(array(['name_loc'.User::$localeId, '%'.$this->name.'%'], ['id', $this->typeId, '!'])); + $seeAlso = new QuestList(array(['name_loc'.Lang::getLocale()->value, Util::htmlEscape($this->subject->getField('name', true))], ['id', $this->typeId, '!'])); if (!$seeAlso->error) { $this->extendGlobalData($seeAlso->getJSGlobals()); - $this->lvTabs[] = ['quest', array( - 'data' => array_values($seeAlso->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $seeAlso->getListviewData(), 'name' => '$LANG.tab_seealso', 'id' => 'see-also' - )]; + ), QuestList::$brickFile)); } // tab: criteria of @@ -988,94 +954,72 @@ class QuestPage extends GenericPage if (!$criteriaOf->error) { $this->extendGlobalData($criteriaOf->getJSGlobals()); - $this->lvTabs[] = ['achievement', array( - 'data' => array_values($criteriaOf->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $criteriaOf->getListviewData(), 'name' => '$LANG.tab_criteriaof', 'id' => 'criteria-of' - )]; + ), AchievementList::$brickFile)); } // tab: spawning pool (for the swarm) - if ($qp = DB::World()->selectCol('SELECT qpm2.questId FROM quest_pool_members qpm1 JOIN quest_pool_members qpm2 ON qpm1.poolId = qpm2.poolId WHERE qpm1.questId = ?d', $this->typeId)) + if ($qp = DB::World()->selectCol('SELECT qpm2.`questId` FROM quest_pool_members qpm1 JOIN quest_pool_members qpm2 ON qpm1.`poolId` = qpm2.`poolId` WHERE qpm1.`questId` = %i', $this->typeId)) { - $max = DB::World()->selectCell('SELECT numActive FROM quest_pool_template qpt JOIN quest_pool_members qpm ON qpm.poolId = qpt.poolId WHERE qpm.questId = ?d', $this->typeId); + $max = DB::World()->selectCell('SELECT `numActive` FROM quest_pool_template qpt JOIN quest_pool_members qpm ON qpm.`poolId` = qpt.`poolId` WHERE qpm.`questId` = %i', $this->typeId); $pooledQuests = new QuestList(array(['id', $qp])); if (!$pooledQuests->error) { $this->extendGlobalData($pooledQuests->getJSGlobals()); - $this->lvTabs[] = ['quest', array( - 'data' => array_values($pooledQuests->getListviewData()), + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $pooledQuests->getListviewData(), 'name' => 'Quest Pool', 'id' => 'quest-pool', 'note' => Lang::quest('questPoolDesc', [$max]) - )]; + ), QuestList::$brickFile)); } } // tab: conditions - $cnd = []; - if ($_ = $this->subject->getField('reqMinRepFaction')) + $cnd = new Conditions(); + $cnd->getBySource([Conditions::SRC_QUEST_AVAILABLE, Conditions::SRC_QUEST_SHOW_MARK], entry: $this->typeId) + ->getByCondition(Type::QUEST, $this->typeId) + ->prepare(); + + + $minRepFac = $this->subject->getField('reqMinRepFaction'); + $maxRepFac = $this->subject->getField('reqMaxRepFaction'); + // add +/- 2 to contain edgecases. ie a reqMaxRepValue of 1 should not include the whole of REP_NEUTRAL + $minRepRank = $minRepFac ? Game::getReputationLevelForPoints($this->subject->getField('reqMinRepValue') + 2) : REP_HATED; + $maxRepRank = $maxRepFac ? Game::getReputationLevelForPoints($this->subject->getField('reqMaxRepValue') - 2) : REP_EXALTED; + + $convertRankBits = function (int $minRank, int $maxRank) : int { - $cnd[CND_SRC_QUEST_ACCEPT][$this->typeId][0][] = [CND_REPUTATION_RANK, $_, 1 << Game::getReputationLevelForPoints($this->subject->getField('reqMinRepValue'))]; - $this->extendGlobalIds(Type::FACTION, $_); + $bits = 0; + for ($i = $minRank; $i <= $maxRank; $i++) + $bits |= (1 << $i); + + return $bits; + }; + + if ($minRepFac && $maxRepFac && $minRepFac <> $maxRepFac) + { + $cnd->addExternalCondition(Conditions::SRC_QUEST_AVAILABLE, '0:'.$this->typeId, [Conditions::REPUTATION_RANK, $minRepFac, $convertRankBits($minRepRank, REP_EXALTED)]); + $cnd->addExternalCondition(Conditions::SRC_QUEST_AVAILABLE, '0:'.$this->typeId, [Conditions::REPUTATION_RANK, $maxRepFac, $convertRankBits(REP_HATED, $maxRepRank)]); + } + else if (($_ = $minRepFac) || ($_ = $maxRepFac)) + $cnd->addExternalCondition(Conditions::SRC_QUEST_AVAILABLE, '0:'.$this->typeId, [Conditions::REPUTATION_RANK, $_, $convertRankBits($minRepRank, $maxRepRank)]); + + if ($tab = $cnd->toListviewTab()) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); } - if ($_ = $this->subject->getField('reqMaxRepFaction')) - { - $cnd[CND_SRC_QUEST_ACCEPT][$this->typeId][0][] = [-CND_REPUTATION_RANK, $_, 1 << Game::getReputationLevelForPoints($this->subject->getField('reqMaxRepValue'))]; - $this->extendGlobalIds(Type::FACTION, $_); - } - - $_ = Util::getServerConditions([CND_SRC_QUEST_ACCEPT, CND_SRC_QUEST_SHOW_MARK], null, $this->typeId); - if (!empty($_[0])) - { - // awkward merger - if (isset($_[0][CND_SRC_QUEST_ACCEPT][$this->typeId][0])) - { - if (isset($cnd[CND_SRC_QUEST_ACCEPT][$this->typeId][0])) - $cnd[CND_SRC_QUEST_ACCEPT][$this->typeId][0] = array_merge($cnd[CND_SRC_QUEST_ACCEPT][$this->typeId][0], $_[0][CND_SRC_QUEST_ACCEPT][$this->typeId][0]); - else - $cnd[CND_SRC_QUEST_ACCEPT] = $_[0][CND_SRC_QUEST_ACCEPT]; - } - - if (isset($_[0][CND_SRC_QUEST_SHOW_MARK])) - $cnd[CND_SRC_QUEST_SHOW_MARK] = $_[0][CND_SRC_QUEST_SHOW_MARK]; - - $this->extendGlobalData($_[1]); - } - - if ($cnd) - { - $tab = ""; - - $this->lvTabs[] = [null, array( - 'data' => $tab, - 'id' => 'conditions', - 'name' => '$LANG.requires' - )]; - } + parent::generate(); } - protected function generateTooltip() + private function createRewards() : ?array { - $power = new StdClass(); - if (!$this->subject->error) - { - $power->{'name_'.User::$localeString} = $this->subject->getField('name', true); - $power->{'tooltip_'.User::$localeString} = $this->subject->renderTooltip(); - if ($this->subject->isDaily()) - $power->daily = 1; - } - - return sprintf($this->powerTpl, $this->typeId, User::$localeId, Util::toJSON($power, JSON_AOWOW_POWER)); - } - - private function createRewards($side) - { - $rewards = []; + $rewards = [[], [], [], '']; // [spells, items, choice, money] // moneyReward / maxLevelCompensation $comp = $this->subject->getField('rewardMoneyMaxLevel'); @@ -1083,75 +1027,68 @@ class QuestPage extends GenericPage $realComp = max($comp, $questMoney); if ($questMoney > 0) { - $rewards['money'] = Util::formatMoney($questMoney); + $rewards[3] = Util::formatMoney($questMoney); if ($realComp > $questMoney) - $rewards['money'] .= ' ' . sprintf(Lang::quest('expConvert'), Util::formatMoney($realComp), MAX_LEVEL); + $rewards[3] .= ' ' . Lang::quest('expConvert', [Util::formatMoney($realComp), MAX_LEVEL]); } else if ($questMoney <= 0 && $realComp > 0) - $rewards['money'] = sprintf(Lang::quest('expConvert2'), Util::formatMoney($realComp), MAX_LEVEL); + $rewards[3] = Lang::quest('expConvert2', [Util::formatMoney($realComp), MAX_LEVEL]); // itemChoices if (!empty($this->subject->choices[$this->typeId][Type::ITEM])) { - $c = $this->subject->choices[$this->typeId][Type::ITEM]; - $choiceItems = new ItemList(array(['id', array_keys($c)])); + $choices = $this->subject->choices[$this->typeId][Type::ITEM]; + $choiceItems = new ItemList(array(['id', array_keys($choices)])); if (!$choiceItems->error) { $this->extendGlobalData($choiceItems->getJSGlobals()); - foreach ($choiceItems->Iterate() as $id => $__) - { - $rewards['choice'][] = array( - 'typeStr' => Type::getFileString(Type::ITEM), - 'id' => $id, - 'name' => $choiceItems->getField('name', true), - 'quality' => $choiceItems->getField('quality'), - 'qty' => $c[$id], - 'globalStr' => Type::getJSGlobalString(Type::ITEM) - ); - } + foreach ($choices as $id => $num) // itr over $choices to preserve display order + if ($choiceItems->getEntry($id)) + $rewards[2][] = new IconElement( + Type::ITEM, + $id, + Lang::unescapeUISequences($choiceItems->getField('name', true), Lang::FMT_HTML), + quality: $choiceItems->getField('quality'), + num: $num + ); } } // itemRewards if (!empty($this->subject->rewards[$this->typeId][Type::ITEM])) { - $ri = $this->subject->rewards[$this->typeId][Type::ITEM]; - $rewItems = new ItemList(array(['id', array_keys($ri)])); + $reward = $this->subject->rewards[$this->typeId][Type::ITEM]; + $rewItems = new ItemList(array(['id', array_keys($reward)])); if (!$rewItems->error) { $this->extendGlobalData($rewItems->getJSGlobals()); - foreach ($rewItems->Iterate() as $id => $__) - { - $rewards['items'][] = array( - 'typeStr' => Type::getFileString(Type::ITEM), - 'id' => $id, - 'name' => $rewItems->getField('name', true), - 'quality' => $rewItems->getField('quality'), - 'qty' => $ri[$id], - 'globalStr' => Type::getJSGlobalString(Type::ITEM) - ); - } + foreach ($reward as $id => $num) // itr over $reward to preserve display order + if ($rewItems->getEntry($id)) + $rewards[1][] = new IconElement( + Type::ITEM, + $id, + Lang::unescapeUISequences($rewItems->getField('name', true), Lang::FMT_HTML), + quality: $rewItems->getField('quality'), + num: $num + ); } } - if (!empty($this->subject->rewards[$this->typeId][Type::CURRENCY])) + if ($currency = array_filter($this->subject->rewards[$this->typeId][Type::CURRENCY] ?? [], + fn($x) => $x != CURRENCY_ARENA_POINTS && $x != CURRENCY_HONOR_POINTS, ARRAY_FILTER_USE_KEY)) { - $rc = $this->subject->rewards[$this->typeId][Type::CURRENCY]; - $rewCurr = new CurrencyList(array(['id', array_keys($rc)])); + $rewCurr = new CurrencyList(array(['id', array_keys($currency)])); if (!$rewCurr->error) { $this->extendGlobalData($rewCurr->getJSGlobals()); - foreach ($rewCurr->Iterate() as $id => $__) - { - $rewards['items'][] = array( - 'typeStr' => Type::getFileString(Type::CURRENCY), - 'id' => $id, - 'name' => $rewCurr->getField('name', true), - 'quality' => 1, - 'qty' => $rc[$id] * ($side == 2 ? -1 : 1), // toggles the icon - 'globalStr' => Type::getJSGlobalString(Type::CURRENCY) + foreach ($rewCurr->iterate() as $id => $__) + $rewards[1][] = new IconElement( + Type::CURRENCY, + $id, + $rewCurr->getField('name', true), + quality: ITEM_QUALITY_NORMAL, + num: $currency[$id] ); - } } } @@ -1173,18 +1110,14 @@ class QuestPage extends GenericPage { $extra = null; if ($_ = $rewSpells->getEntry($displ)) - $extra = sprintf(Lang::quest('spellDisplayed'), $displ, Util::localizedString($_, 'name')); + $extra = Lang::quest('spellDisplayed', [$displ, Util::localizedString($_, 'name')]); if ($_ = $rewSpells->getEntry($cast)) - { - $rewards['spells']['extra'] = $extra; - $rewards['spells']['cast'][] = array( - 'typeStr' => Type::getFileString(Type::SPELL), - 'id' => $cast, - 'name' => Util::localizedString($_, 'name'), - 'globalStr' => Type::getJSGlobalString(Type::SPELL) + $rewards[0] = array( + 'title' => Lang::quest('rewardAura'), + 'cast' => [new IconElement(Type::SPELL, $cast, Util::localizedString($_, 'name'))], + 'extra' => $extra ); - } } else // if it has effect:learnSpell display the taught spell instead { @@ -1194,114 +1127,111 @@ class QuestPage extends GenericPage foreach ($_ as $idx) $teach[$rewSpells->getField('effect'.$idx.'TriggerSpell')] = $id; - if ($_ = $rewSpells->getEntry($displ)) - { - $rewards['spells']['extra'] = null; - $rewards['spells'][$teach ? 'learn' : 'cast'][] = array( - 'typeStr' => Type::getFileString(Type::SPELL), - 'id' => $displ, - 'name' => Util::localizedString($_, 'name'), - 'globalStr' => Type::getJSGlobalString(Type::SPELL) - ); - } - else if (($_ = $rewSpells->getEntry($cast)) && !$teach) - { - $rewards['spells']['extra'] = null; - $rewards['spells']['cast'][] = array( - 'typeStr' => Type::getFileString(Type::SPELL), - 'id' => $cast, - 'name' => Util::localizedString($_, 'name'), - 'globalStr' => Type::getJSGlobalString(Type::SPELL) - ); - } - else + if ($teach) { $taught = new SpellList(array(['id', array_keys($teach)])); if (!$taught->error) { $this->extendGlobalData($taught->getJSGlobals()); - $rewards['spells']['extra'] = null; + $rewards[0] = ['cast' => [], 'extra' => null]; + + $isTradeSkill = 0; foreach ($taught->iterate() as $id => $__) { - $rewards['spells']['learn'][] = array( - 'typeStr' => Type::getFileString(Type::SPELL), - 'id' => $id, - 'name' => $taught->getField('name', true), - 'globalStr' => Type::getJSGlobalString(Type::SPELL) - ); + $isTradeSkill |= array_intersect($taught->getField('skillLines'), array_merge(SKILLS_TRADE_PRIMARY, SKILLS_TRADE_SECONDARY)) ? 1 : 0; + $rewards[0]['cast'][] = new IconElement(Type::SPELL, $id, $taught->getField('name', true)); } + + $rewards[0]['title'] = $isTradeSkill ? Lang::quest('rewardTradeSkill') : Lang::quest('rewardSpell'); } } - } - } - - return $rewards; - } - - private function createMail($startEnd) - { - $mail = []; - - if ($rmtId = $this->subject->getField('rewardMailTemplateId')) - { - $delay = $this->subject->getField('rewardMailDelay'); - $letter = DB::Aowow()->selectRow('SELECT * FROM ?_mails WHERE id = ?d', $rmtId); - - $mail = array( - 'id' => $rmtId, - 'delay' => $delay ? sprintf(Lang::mail('mailIn'), Util::formatTime($delay * 1000)) : null, - 'sender' => null, - 'attachments' => [], - 'text' => $letter ? Util::parseHtmlText(Util::localizedString($letter, 'text')) : null, - 'subject' => Util::parseHtmlText(Util::localizedString($letter, 'subject')) - ); - - $senderTypeId = 0; - if ($_= DB::World()->selectCell('SELECT RewardMailSenderEntry FROM quest_mail_sender WHERE QuestId = ?d', $this->typeId)) - $senderTypeId = $_; - else - foreach ($startEnd as $se) - if (($se['method'] & 0x2) && $se['type'] == Type::NPC) - $senderTypeId = $se['typeId']; - - if ($ti = CreatureList::getName($senderTypeId)) - $mail['sender'] = sprintf(Lang::mail('mailBy'), $senderTypeId, $ti); - - // while mail attachemnts are handled as loot, it has no variance. Always 100% chance, always one item. - $mailLoot = new Loot(); - if ($mailLoot->getByContainer(LOOT_MAIL, $rmtId)) - { - $this->extendGlobalData($mailLoot->jsGlobals); - foreach ($mailLoot->getResult() as $loot) + else if (($_ = $rewSpells->getEntry($displ)) || ($_ = $rewSpells->getEntry($cast))) { - $mail['attachments'][] = array( - 'typeStr' => Type::getFileString(Type::ITEM), - 'id' => $loot['id'], - 'name' => substr($loot['name'], 1), - 'quality' => 7 - $loot['name'][0], - 'qty' => $loot['stack'][0], - 'globalStr' => Type::getJSGlobalString(Type::ITEM) + $rewards[0] = array( + 'title' => Lang::quest('rewardAura'), + 'cast' => [new IconElement(Type::SPELL, $cast, Util::localizedString($_, 'name'))], + 'extra' => null ); } } } - return $mail; + if (!array_filter($rewards)) + return null; + + return $rewards; } - private function createGains() + private function createMail(array $startEnd) : bool + { + $rmtId = $this->subject->getField('rewardMailTemplateId'); + if (!$rmtId) + return false; + + $delay = $this->subject->getField('rewardMailDelay'); + $letter = DB::Aowow()->selectRow('SELECT * FROM ::mails WHERE `id` = %i', $rmtId); + + $this->mail = array( + 'attachments' => [], + 'text' => $letter ? Util::parseHtmlText(Util::localizedString($letter, 'text')) : null, + 'subject' => Util::parseHtmlText(Util::localizedString($letter, 'subject')), + 'header' => array( + $rmtId, + null, + $delay ? Lang::mail('mailIn', [DateTime::formatTimeElapsed($delay * 1000)]) : null, + ) + ); + + $senderTypeId = 0; + if ($_= DB::World()->selectCell('SELECT `RewardMailSenderEntry` FROM quest_mail_sender WHERE `QuestId` = %i', $this->typeId)) + $senderTypeId = $_; + else + foreach ($startEnd as $se) + if (($se['method'] & 0x2) && $se['type'] == Type::NPC) + $senderTypeId = $se['typeId']; + + if ($ti = CreatureList::getName($senderTypeId)) + $this->mail['header'][1] = Lang::mail('mailBy', [$senderTypeId, $ti]); + + // while mail attachemnts are handled as loot, it has no variance. Always 100% chance, always one item. + $mailLoot = new LootByContainer(); + if ($mailLoot->getByContainer(Loot::MAIL, [$rmtId])) + { + $this->extendGlobalData($mailLoot->jsGlobals); + foreach ($mailLoot->getResult() as $loot) + $this->mail['attachments'][] = new IconElement(Type::ITEM, $loot['id'], substr($loot['name'], 1), $loot['stack'][0], quality: 7 - $loot['name'][0]); + } + + return true; + } + + private function createGains(int $side) : ?array { $gains = []; // xp - if ($_ = $this->subject->getField('rewardXP')) - $gains['xp'] = $_; + $gains[0] = $this->subject->getField('rewardXP'); + + // arena points + $gains[5] = $this->subject->getField('rewardArenaPoints'); + + // honor points + if ($_ = $this->subject->getField('rewardHonorPoints')) + $gains[4] = [$_, $side]; + else + $gains[4] = null; // talent points - if ($_ = $this->subject->getField('rewardTalents')) - $gains['tp'] = $_; + $gains[3] = $this->subject->getField('rewardTalents'); + + // title + if ($tId = $this->subject->getField('rewardTitleId')) + $gains[2] = [$tId, (new TitleList(array(['id', $tId])))->getHtmlizedName()]; + else + $gains[2] = null; // reputation + $repGains = []; for ($i = 1; $i < 6; $i++) { $fac = $this->subject->getField('rewardFactionId'.$i); @@ -1315,32 +1245,115 @@ class QuestPage extends GenericPage 'name' => FactionList::getName($fac) ); - if ($cuRates = DB::World()->selectRow('SELECT * FROM reputation_reward_rate WHERE faction = ?d', $fac)) + if ($cuRates = DB::World()->selectRow('SELECT * FROM reputation_reward_rate WHERE `faction` = %i', $fac)) { - if ($dailyType = $this->subject->isDaily()) - { - if ($dailyType == 1 && $cuRates['quest_daily_rate'] != 1.0) - $rep['qty'][1] = $rep['qty'][0] * ($cuRates['quest_daily_rate'] - 1); - else if ($dailyType == 2 && $cuRates['quest_weekly_rate'] != 1.0) - $rep['qty'][1] = $rep['qty'][0] * ($cuRates['quest_weekly_rate'] - 1); - else if ($dailyType == 3 && $cuRates['quest_monthly_rate'] != 1.0) - $rep['qty'][1] = $rep['qty'][0] * ($cuRates['quest_monthly_rate'] - 1); - } - else if ($this->subject->isRepeatable() && $cuRates['quest_repeatable_rate'] != 1.0) + if ($this->subject->isRepeatable()) $rep['qty'][1] = $rep['qty'][0] * ($cuRates['quest_repeatable_rate'] - 1); - else if ($cuRates['quest_rate'] != 1.0) - $rep['qty'][1] = $rep['qty'][0] * ($cuRates['quest_rate'] - 1); + else + $rep['qty'][1] = $rep['qty'][0] * match ($this->subject->isDaily()) + { + 1 => $cuRates['quest_daily_rate'] - 1, + 2 => $cuRates['quest_weekly_rate'] - 1, + 3 => $cuRates['quest_monthly_rate'] - 1, + default => $cuRates['quest_rate'] - 1 + }; } - $gains['rep'][] = $rep; - } + if (User::isInGroup(U_GROUP_STAFF)) + $rep['qty'][1] = $rep['qty'][0] . ($rep['qty'][1] ? $this->fmtStaffTip(($rep['qty'][1] > 0 ? '+' : '').$rep['qty'][1], Lang::faction('customRewRate')) : ''); + else + $rep['qty'][1] += $rep['qty'][0]; - // title - if ($_ = (new TitleList(array(['id', $this->subject->getField('rewardTitleId')])))->getHtmlizedName()) - $gains['title'] = $_; + $repGains[] = $rep; + } + $gains[1] = $repGains; + + if (!array_filter($gains)) + return null; return $gains; } + + private function createSeries() : array + { + $series = []; + + $makeSeriesItem = function (array $questData) : array + { + return array( + 'side' => ChrRace::sideFromMask($questData['reqRaceMask']), + 'typeStr' => Type::getFileString(Type::QUEST), + 'typeId' => $questData['id'], + 'name' => Util::htmlEscape(Lang::trimTextClean(Util::localizedString($questData, 'name'), 40)), + ); + }; + + // Assumption + // a chain always ends in a single quest, but can have an arbitrary amount of quests leading into it. + // so we fast forward to the last quest and go backwards from there. + + $lastQuestId = $this->subject->getField('nextQuestIdChain'); + while ($newLast = DB::Aowow()->selectCell('SELECT `nextQuestIdChain` FROM ::quests WHERE `id` = %i AND `id` <> `nextQuestIdChain`', $lastQuestId)) + $lastQuestId = $newLast; + + $end = DB::Aowow()->selectRow('SELECT `id`, `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8`, `reqRaceMask` FROM ::quests WHERE `id` = %i', $lastQuestId ?: $this->typeId); + $chain = array(array($makeSeriesItem($end))); // series / step / quest + + $prevStepIds = [$lastQuestId ?: $this->typeId]; + while ($prevQuests = DB::Aowow()->selectAssoc('SELECT `id`, `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8`, `reqRaceMask` FROM ::quests WHERE `nextQuestIdChain` IN %in AND `id` <> `nextQuestIdChain` AND (`cuFlags` & %i) = 0', + $prevStepIds, User::isInGroup(U_GROUP_STAFF) ? CUSTOM_EXCLUDE_FOR_LISTVIEW : 0)) + { + $step = []; + foreach ($prevQuests as $pQuest) + $step[$pQuest['id']] = $makeSeriesItem($pQuest); + + $prevStepIds = array_keys($step); + $chain[] = $step; + } + + if (count($chain) > 1) + $series[] = [array_reverse($chain), null]; + + // todo (low): sensibly merge the following lists into 'series' + $listGen = function($cnd) use ($makeSeriesItem) + { + $chain = []; + $list = new QuestList($cnd); + if ($list->error) + return null; + + foreach ($list->iterate() as $tpl) + $chain[] = [$makeSeriesItem($tpl)]; + + return $chain; + }; + + $extraLists = array( + // Requires all of these quests (Quests that you must follow to get this quest) + ['reqQ', array(DB::OR, [DB::AND, ['nextQuestId', $this->typeId], ['exclusiveGroup', 0, '<']], [DB::AND, ['id', $this->subject->getField('prevQuestId')], ['nextQuestIdChain', $this->typeId, '!']])], + + // Requires one of these quests (Requires one of the quests to choose from) + ['reqOneQ', array(DB::OR, [DB::AND, ['exclusiveGroup', 0, '>='], ['nextQuestId', $this->typeId]], ['breadCrumbForQuestId', $this->typeId])], + + // Opens Quests (Quests that become available only after complete this quest (optionally only one)) + ['opensQ', array(DB::OR, [DB::AND, ['prevQuestId', $this->typeId], ['id', $this->subject->getField('nextQuestIdChain'), '!']], ['id', $this->subject->getField('nextQuestId')], ['id', $this->subject->getField('breadcrumbForQuestId')])], + + // Closes Quests (Quests that become inaccessible after completing this quest) + ['closesQ', array(['exclusiveGroup', 0, '>'], ['exclusiveGroup', $this->subject->getField('exclusiveGroup')], ['id', $this->typeId, '!'])], + + // During the quest available these quests (Quests that are available only at run time this quest) + ['enablesQ', array(['prevQuestId', -$this->typeId])], + + // Requires an active quest (Quests during the execution of which is available on the quest) + ['enabledByQ', array(['id', -$this->subject->getField('prevQuestId')])] + ); + + foreach ($extraLists as [$section, $condition]) + if ($_ = $listGen($condition)) + $series[] = [$_, sprintf(Util::$dfnString, Lang::quest($section.'Desc'), Lang::quest($section))]; + + return $series; + } } ?> diff --git a/endpoints/quest/quest_power.php b/endpoints/quest/quest_power.php new file mode 100644 index 00000000..d0eccf52 --- /dev/null +++ b/endpoints/quest/quest_power.php @@ -0,0 +1,50 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(string $id) + { + parent::__construct($id); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($id); + } + + protected function generate() : void + { + $quest = new QuestList(array(['id', $this->typeId])); + if ($quest->error) + $this->cacheType = CACHE_TYPE_NONE; + else + $opts = array( + 'name' => Lang::unescapeUISequences($quest->getField('name', true), Lang::FMT_RAW), + 'tooltip' => $quest->renderTooltip(), + 'daily' => $quest->isDaily() ? 1 : null + ); + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/endpoints/quests/quests.php b/endpoints/quests/quests.php new file mode 100644 index 00000000..86dc7e9b --- /dev/null +++ b/endpoints/quests/quests.php @@ -0,0 +1,153 @@ + 3519, 4024 => 3537, 25 => 46, 1769 => 361, + // Startzones: Horde + 132 => 1, 9 => 12, 3431 => 3430, 154 => 85, + // Startzones: Alliance + 3526 => 3524, 363 => 14, 220 => 215, 188 => 141, + // Group: Caverns of Time + 2366 => 1941, 2367 => 1941, 4100 => 1941, + // Group: Hellfire Citadell + 3562 => 3535, 3713 => 3535, 3714 => 3535, + // Group: Auchindoun + 3789 => 3688, 3790 => 3688, 3791 => 3688, 3792 => 3688, + // Group: Tempest Keep + 3847 => 3842, 3848 => 3842, 3849 => 3842, + // Group: Coilfang Reservoir + 3715 => 3905, 3716 => 3905, 3717 => 3905, + // Group: Icecrown Citadel + 4809 => 4522, 4813 => 4522, 4820 => 4522 + ); + + protected int $type = Type::QUEST; + protected int $cacheType = CACHE_TYPE_LIST_PAGE; + + protected string $template = 'quests'; + protected string $pageName = 'quests'; + protected ?int $activeTab = parent::TAB_DATABASE; + protected array $breadcrumb = [0, 3]; + + protected array $scripts = [[SC_JS_FILE, 'js/filters.js']]; + protected array $expectedGET = array( + 'filter' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = Game::QUEST_CLASSES; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + + if ($this->category) + { + // basically: everywhere except for page structure quests require cat AND cat2 to be set + // causing links to be generated for /?quests=-2.0, which only exist as /?quests=-2 + if ($this->category[0] == -2 && isset($this->category[1])) + $this->forward('?' . $this->pageName . '=-2'); + + $this->subCat = '='.implode('.', $this->category); + } + + $this->filter = new QuestListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('quests')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + if (isset($this->category[1])) + $conditions[] = ['questSortId', $this->category[1]]; + else if (isset($this->category[0])) + $conditions[] = ['questSortId', $this->validCats[$this->category[0]]]; + + + /*************/ + /* Menu Path */ + /*************/ + + foreach ($this->category as $c) + $this->breadcrumb[] = $c; + + if (isset($this->category[1]) && isset(self::SUB_SUB_CAT[$this->category[1]])) + array_splice($this->breadcrumb, 3, 0, self::SUB_SUB_CAT[$this->category[1]]); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1); + + if (isset($this->category[1])) + array_unshift($this->title, Lang::quest('cat', $this->category[0], $this->category[1])); + else if (isset($this->category[0])) + { + $c0 = Lang::quest('cat', $this->category[0]); + array_unshift($this->title, is_array($c0) ? $c0[0] : $c0); + } + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + $quests = new QuestList($conditions, ['extraOpts' => $this->filter->extraOpts, 'calcTotal' => true]); + + $this->extendGlobalData($quests->getJSGlobals()); + + $tabData = ['data' => $quests->getListviewData()]; + + if ($rc = $this->filter->fiReputationCols) + $tabData['extraCols'] = '$fi_getReputationCols('.json_encode($rc, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE).')'; + else if ($this->filter->fiExtraCols) + $tabData['extraCols'] = '$fi_getExtraCols(fi_extraCols, 0, 0)'; + + // create note if search limit was exceeded + if ($quests->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_questsfound', $quests->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + else if (isset($this->category[1]) && $this->category[1] > 0) + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_questgivers, '.$this->category[1].', g_zones['.$this->category[1].'], '.$this->category[1].')'; + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, QuestList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/race/race.php b/endpoints/race/race.php new file mode 100644 index 00000000..e331f1b0 --- /dev/null +++ b/endpoints/race/race.php @@ -0,0 +1,296 @@ + [starter, argent tournament] + null, [384, 33307], [3362, 33553], [1261, 33310], + [4730, 33653], [4731, 33555], [3685, 33556], [7955, 33650], + [7952, 33554], null, [16264, 33557], [17584, 33657] + ); + + protected int $cacheType = CACHE_TYPE_DETAIL_PAGE; + + protected string $template = 'detail-page-generic'; + protected string $pageName = 'race'; + protected ?int $activeTab = parent::TAB_DATABASE; + protected array $breadcrumb = [0, 13]; + + public int $type = Type::CHR_RACE; + public int $typeId = 0; + public ?string $expansion = null; + + private CharRaceList $subject; + + public function __construct(string $id) + { + parent::__construct($id); + + $this->typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new CharRaceList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('race'), Lang::race('notFound')); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->typeId; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('race'))); + + + /***********/ + /* Infobox */ + /***********/ + + $ra = ChrRace::from($this->typeId); + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // side + if ($_ = $this->subject->getField('side')) + $infobox[] = Lang::main('side').'[span class=icon-'.($_ == SIDE_HORDE ? 'horde' : 'alliance').']'.Lang::game('si', $_).'[/span]'; + + // faction + if ($_ = $this->subject->getField('factionId')) + { + $this->extendGlobalIds(Type::FACTION, $_); + $infobox[] = Util::ucFirst(Lang::game('faction')).Lang::main('colon').'[faction='.$_.']'; + } + + // leader + if ($_ = $this->subject->getField('leader')) + { + $this->extendGlobalIds(Type::NPC, $_); + $infobox[] = Lang::race('racialLeader').'[npc='.$_.']'; + } + + // start area + if ($_ = $this->subject->getField('startAreaId')) + { + $this->extendGlobalIds(Type::ZONE, $_); + $infobox[] = Lang::race('startZone').Lang::main('colon').'[zone='.$_.']'; + } + + // id + $infobox[] = Lang::race('id') . $this->typeId; + + // icon + $mIcon = $this->subject->getField('iconId0'); + $fIcon = $this->subject->getField('iconId1'); + if ($mIcon || $fIcon) + { + $buff = ''; + if ($mIcon) + { + $buff .= '[icondb='.$mIcon.(!$fIcon ? ' name=true': '').']'; + $this->extendGlobalIds(Type::ICON, $mIcon); + } + if ($fIcon) + { + $buff .= '[icondb='.$fIcon.' name=true]'; + $this->extendGlobalIds(Type::ICON, $fIcon); + } + + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').$buff; + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $this->expansion = Util::$expansionString[$this->subject->getField('expansion')]; + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId] + ); + + if ($_ = $this->subject->getField('iconStringMale')) + $this->headIcons[] = $_; + if ($_ = $this->subject->getField('iconStringFemale')) + $this->headIcons[] = $_; + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: classes + $classes = new CharClassList(array(['racemask', $ra->toMask(), '&'])); + if (!$classes->error) + { + $this->extendGlobalData($classes->getJSGlobals()); + $this->lvTabs->addListviewTab(new Listview(['data' => $classes->getListviewData()], CharClassList::$brickFile)); + } + + // tab: languages + $conditions = array( + ['typeCat', -11], // proficiencies + ['reqRaceMask', $ra->toMask(), '&'] // only languages are race-restricted + ); + + $tongues = new SpellList($conditions); + if (!$tongues->error) + { + $this->extendGlobalData($tongues->getJSGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $tongues->getListviewData(), + 'id' => 'languages', + 'name' => '$LANG.tab_languages', + 'hiddenCols' => ['reagents'] + ), SpellList::$brickFile)); + } + + // tab: racial-traits + $conditions = array( + ['typeCat', -4], // racial traits + ['reqRaceMask', $ra->toMask(), '&'] + ); + + $racials = new SpellList($conditions); + if (!$racials->error) + { + $this->extendGlobalData($racials->getJSGlobals()); + $tabData = array( + 'data' => $racials->getListviewData(), + 'id' => 'racial-traits', + 'name' => '$LANG.tab_racialtraits', + 'hiddenCols' => ['reagents'] + ); + if ($racials->hasDiffFields('reqClassMask')) + $tabData['visibleCols'] = ['classes']; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + + // tab: quests + $conditions = array( + ['reqRaceMask', $ra->toMask(), '&'], + [['reqRaceMask', ChrRace::MASK_HORDE, '&'], ChrRace::MASK_HORDE, '!'], + [['reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], ChrRace::MASK_ALLIANCE, '!'] + ); + + $quests = new QuestList($conditions); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals()); + $this->lvTabs->addListviewTab(new Listview(['data' => $quests->getListviewData()], QuestList::$brickFile)); + } + + // tab: mounts + // ok, this sucks, but i rather hardcode the trainer, than fetch items by namepart + if (isset(self::MOUNT_VENDORS[$this->typeId])) + { + if ($items = DB::World()->selectCol('SELECT `item` FROM npc_vendor WHERE `entry` IN %in', self::MOUNT_VENDORS[$this->typeId])) + { + $conditions = array( + ['i.id', $items], + ['i.class', ITEM_CLASS_MISC], + ['i.subClass', 5], // mounts + ); + + $mounts = new ItemList($conditions); + if (!$mounts->error) + { + $this->extendGlobalData($mounts->getJSGlobals()); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $mounts->getListviewData(), + 'id' => 'mounts', + 'name' => '$LANG.tab_mounts', + 'hiddenCols' => ['slot', 'type'] + ), ItemList::$brickFile)); + } + } + } + + // tab: sounds + if ($vo = DB::Aowow()->selectCol('SELECT `soundId` AS ARRAY_KEY, `gender` FROM ::races_sounds WHERE `raceId` = %i', $this->typeId)) + { + $sounds = new SoundList(array(['id', array_keys($vo)])); + if (!$sounds->error) + { + $this->extendGlobalData($sounds->getJSGlobals(GLOBALINFO_SELF)); + $data = $sounds->getListviewData(); + foreach ($data as $id => &$d) + $d['gender'] = $vo[$id]; + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + 'extraCols' => ['$Listview.templates.title.columns[1]'] + ), SoundList::$brickFile)); + } + } + + // tab: criteria-of + $conditions = array( + DB::AND, + ['ac.type', ACHIEVEMENT_CRITERIA_TYPE_HK_RACE], + ['ac.value1', $this->typeId] + ); + + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` IN %in AND `value2` = %i', [ACHIEVEMENT_CRITERIA_DATA_TYPE_S_PLAYER_CLASS_RACE, ACHIEVEMENT_CRITERIA_DATA_TYPE_T_PLAYER_CLASS_RACE], $this->typeId)) + $conditions = [DB::OR, $conditions, ['ac.id', $extraCrt]]; + + $crtOf = new AchievementList($conditions); + if (!$crtOf->error) + { + $this->extendGlobalData($crtOf->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $crtOf->getListviewData(), + 'name' => '$LANG.tab_criteriaof', + 'id' => 'criteria-of' + ), AchievementList::$brickFile)); + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::CHR_RACE, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/races/races.php b/endpoints/races/races.php new file mode 100644 index 00000000..43d370b6 --- /dev/null +++ b/endpoints/races/races.php @@ -0,0 +1,52 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('races')); + + + array_unshift($this->title, $this->h1); + + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $races = new CharRaceList($conditions); + if (!$races->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $races->getListviewData()], CharRaceList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/random/random.php b/endpoints/random/random.php new file mode 100644 index 00000000..21250899 --- /dev/null +++ b/endpoints/random/random.php @@ -0,0 +1,48 @@ +h1 = 'Random Page'; + // array_unshift($this->title, $this->h1); + + $type = array_rand(Type::getClassesFor(Type::FLAG_RANDOM_SEARCHABLE)); + $typeId = (Type::newList($type))?->getRandomId(); + + $this->redirectTo = '?'.Type::getFileString($type).'='.$typeId; + + // $this->extraHTML = <<// + // JS; + } +} + +?> diff --git a/endpoints/reputation/reputation.php b/endpoints/reputation/reputation.php new file mode 100644 index 00000000..0e160637 --- /dev/null +++ b/endpoints/reputation/reputation.php @@ -0,0 +1,58 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + if ($repData = DB::Aowow()->selectAssoc('SELECT `action`, `amount`, `date` AS "when", IF(`action` IN %in, `sourceA`, 0) AS "param" FROM ::account_reputation WHERE `userId` = %i', + [SITEREP_ACTION_COMMENT, SITEREP_ACTION_UPVOTED, SITEREP_ACTION_DOWNVOTED], User::$id)) + { + array_walk($repData, fn(&$x) => $x['when'] = date(Util::$dateFormatInternal, $x['when'])); + + $this->tabsTitle = Lang::main('yourRepHistory'); + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], __forceTabs: true); + + $this->lvTabs->addListviewTab(new Listview(array( + 'id' => 'reputation-history', + 'name' => '$LANG.reputationhistory', + 'data' => $repData + ), 'reputationhistory')); + } + + parent::generate(); + + $this->result->registerDisplayHook('article', [self::class, 'articleHook']); + } + + public static function articleHook(Template\PageTemplate &$pt, Markup &$article) : void + { + $article->apply(Cfg::applyToString(...)); + } +} + +?> diff --git a/endpoints/screenshot/add.php b/endpoints/screenshot/add.php new file mode 100644 index 00000000..294a4285 --- /dev/null +++ b/endpoints/screenshot/add.php @@ -0,0 +1,98 @@ + 1. =add: receives user upload + 1.1. checks and processing on the upload + 1.2. forward to =crop or blank response + 2. =crop: user edites upload + 3. =complete: store edited screenshot file and data + 4. =thankyou +*/ + +// filename: Username-type-typeId-[_original].jpg + +class ScreenshotAddResponse extends TextResponse +{ + protected bool $requiresLogin = true; + + private string $imgHash = ''; + private int $destType = 0; + private int $destTypeId = 0; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get screenshot destination + // target delivered as screenshot=&.. (hash is optional) + if (!preg_match('/^screenshot=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generate404(); + + [, $this->destType, $this->destTypeId, , $imgHash] = $m; + + // no such type or this type cannot receive screenshots + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_SS)) + $this->generate404(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generate404(); + + // only accept/expect hash for crop & complete + if ($imgHash) + $this->generate404(); + } + + protected function generate() : void + { + if ($this->handleAdd()) + $this->redirectTo = '?screenshot=crop&'.$this->destType.'.'.$this->destTypeId.'.'.$this->imgHash; + else if ($this->destType && $this->destTypeId) + $this->redirectTo = '?'.Type::getFileString($this->destType).'='.$this->destTypeId.'#submit-a-screenshot'; + else + $this->generate404(); + } + + private function handleAdd() : bool + { + if (!User::canUploadScreenshot()) + { + $_SESSION['error']['ss'] = Lang::screenshot('error', 'notAllowed'); + return false; + } + + if (!ScreenshotMgr::init()) + { + $_SESSION['error']['ss'] = Lang::main('intError'); + return false; + } + + if (!ScreenshotMgr::validateUpload()) + { + $_SESSION['error']['ss'] = ScreenshotMgr::$error; + return false; + } + + if (!ScreenshotMgr::loadUpload()) + { + $_SESSION['error']['ss'] = Lang::main('intError'); + return false; + } + + if (!ScreenshotMgr::tempSaveUpload([$this->destType, $this->destTypeId], $this->imgHash)) + { + $_SESSION['error']['ss'] = Lang::main('intError'); + return false; + } + + return true; + } +} + +?> diff --git a/endpoints/screenshot/complete.php b/endpoints/screenshot/complete.php new file mode 100644 index 00000000..5bf80622 --- /dev/null +++ b/endpoints/screenshot/complete.php @@ -0,0 +1,106 @@ + 3. =complete: store edited screenshot file and data + 4. =thankyou +*/ + +// filename: Username-type-typeId-[_original].jpg + +class ScreenshotCompleteResponse extends TextResponse +{ + use TrCommunityHelper; + + protected bool $requiresLogin = true; + + protected array $expectedPOST = array( + 'coords' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCoords'] ], + 'screenshotalt' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + private int $destType = 0; + private int $destTypeId = 0; + private string $imgHash = ''; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get screenshot destination + // target delivered as screenshot=&.. (hash is optional) + if (!preg_match('/^screenshot=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generate404(); + + [, $this->destType, $this->destTypeId, , $this->imgHash] = $m; + + // no such type or this type cannot receive screenshots + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_SS)) + $this->generate404(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generate404(); + + // hash required for crop & complete + if (!$this->imgHash) + $this->generate404(); + } + + protected function generate() : void + { + if ($this->handleComplete()) + $this->forward('?screenshot=thankyou&'.$this->destType.'.'.$this->destTypeId); + else + $this->generate404(); + } + + private function handleComplete() : bool + { + if (!$this->assertPOST('coords')) + return false; + + ScreenshotMgr::init(); + + if (!ScreenshotMgr::loadFile(ScreenshotMgr::PATH_TEMP, User::$username.'-'.$this->destType.'-'.$this->destTypeId.'-'.$this->imgHash.'_original')) + return false; + + ScreenshotMgr::cropImg(...$this->_post['coords']); + + ['oWidth' => $w, 'oHeight' => $h] = ScreenshotMgr::calcImgDimensions(); + + // write to db + $newId = DB::Aowow()->qry( + 'INSERT INTO ::screenshots (`type`, `typeId`, `userIdOwner`, `date`, `width`, `height`, `caption`, `status`) VALUES (%i, %i, %i, UNIX_TIMESTAMP(), %i, %i, %s, 0)', + $this->destType, $this->destTypeId, + User::$id, + $w, $h, + $this->handleCaption($this->_post['screenshotalt']) + ); + if (!is_int($newId)) // 0 is valid, NULL or FALSE is not + { + trigger_error('ScreenshotCompleteResponse - screenshot query failed', E_USER_ERROR); + return false; + } + + // write to file + return ScreenshotMgr::writeImage(ScreenshotMgr::PATH_PENDING, $newId); + } + + protected static function checkCoords(string $val) : ?array + { + if (preg_match('/^[01]\.[0-9]{3}(,[01]\.[0-9]{3}){3}$/', $val)) + return explode(',', $val); + + return null; + } +} + +?> diff --git a/endpoints/screenshot/crop.php b/endpoints/screenshot/crop.php new file mode 100644 index 00000000..9f446899 --- /dev/null +++ b/endpoints/screenshot/crop.php @@ -0,0 +1,92 @@ + 2. =crop: user edites upload + 2.1. just show edit page + 2.2. user submits coords and description to =complete + 3. =complete: store edited screenshot file and data + 4. =thankyou +*/ + +// filename: Username-type-typeId-[_original].jpg + +class ScreenshotCropResponse extends TemplateResponse +{ + protected bool $requiresLogin = true; + + protected string $template = 'screenshot'; + protected string $pageName = 'screenshot'; + + protected array $scripts = [[SC_JS_FILE, 'js/Cropper.js'], [SC_CSS_FILE, 'css/Cropper.css']]; + + public ?Markup $infobox = null; + public array $cropper = []; + public int $destType = 0; + public int $destTypeId = 0; + public string $imgHash = ''; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get screenshot destination + // target delivered as screenshot=&.. (hash is optional) + if (!preg_match('/^screenshot=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generateError(); + + [, $this->destType, $this->destTypeId, , $this->imgHash] = $m; + + // no such type or this type cannot receive screenshots + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_SS)) + $this->generateError(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generateError(); + + // hash required for crop & complete + if (!$this->imgHash) + $this->generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::screenshot('submission'); + $fileBase = User::$username.'-'.$this->destType.'-'.$this->destTypeId.'-'.$this->imgHash; + + array_unshift($this->title, $this->h1); + + ScreenshotMgr::init(); + + if (!ScreenshotMgr::loadFile(ScreenshotMgr::PATH_TEMP, $fileBase.'_original')) + { + $_SESSION['error']['ss'] = Lang::main('intError'); + $this->forward('?'.Type::getFileString($this->destType).'='.$this->destTypeId.'#submit-a-screenshot'); + } + + $dims = ScreenshotMgr::calcImgDimensions(); + + $this->cropper = $dims + array( + 'url' => Cfg::get('STATIC_URL').'/uploads/screenshots/temp/'.$fileBase.'.jpg', + 'parent' => 'ss-container', + 'minCrop' => ScreenshotMgr::$MIN_SIZE, // optional; defaults to 150 - min selection size (a square) + 'type' => $this->destType, // only used to check against NPC: 15384 [OLDWorld Trigger (DO NOT DELETE)] for U_GROUP_MODERATOR | U_GROUP_EDITOR. If successful drops minCrop constraint + 'typeId' => $this->destTypeId // i guess this was used to upload arbitrary imagery for articles, blog posts, etc + ); + + // target + $this->infobox = new Markup(Lang::screenshot('displayOn', [Lang::typeName($this->destType), Type::getFileString($this->destType), $this->destTypeId]), ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + $this->extendGlobalIds($this->destType, $this->destTypeId); + + parent::generate(); + } +} + +?> diff --git a/endpoints/screenshot/thankyou.php b/endpoints/screenshot/thankyou.php new file mode 100644 index 00000000..d2517671 --- /dev/null +++ b/endpoints/screenshot/thankyou.php @@ -0,0 +1,66 @@ + 4. =thankyou +*/ + +// filename: Username-type-typeId-[_original].jpg + +class ScreenshotThankyouResponse extends TemplateResponse +{ + protected bool $requiresLogin = true; + + protected string $template = 'text-page-generic'; + protected string $pageName = 'screenshot'; + + private int $destType = 0; + private int $destTypeId = 0; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get screenshot destination + // target delivered as screenshot=&.. (hash is optional) + if (!preg_match('/^screenshot=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generateError(); + + [, $this->destType, $this->destTypeId, , $imgHash] = $m; + + // no such type or this type cannot receive screenshots + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_SS)) + $this->generateError(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generateError(); + + // only accept/expect hash for crop & complete + if ($imgHash) + $this->generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::screenshot('submission'); + + array_unshift($this->title, $this->h1); + + $this->extraHTML = Lang::screenshot('thanks', 'contrib').'

'; + $this->extraHTML .= Lang::screenshot('thanks', 'goBack', [Type::getFileString($this->destType), $this->destTypeId])."

\n"; + $this->extraHTML .= ''.Lang::screenshot('thanks', 'note').''; + + parent::generate(); + } +} + +?> diff --git a/endpoints/search/search.php b/endpoints/search/search.php new file mode 100644 index 00000000..11bce6f4 --- /dev/null +++ b/endpoints/search/search.php @@ -0,0 +1,113 @@ + Templated Page /w Listviews +*/ + + +class SearchBaseResponse extends TemplateResponse implements ICache +{ + use TrCache, TrSearch; + + private const SEARCH_MODS_ALL = 0x0FFFFFFF; // yeah im lazy, now what? + + protected int $cacheType = CACHE_TYPE_SEARCH; + + protected string $template = 'search'; + protected string $pageName = 'search'; + protected ?int $activeTab = parent::TAB_DATABASE; + + protected array $expectedGET = array( + 'search' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + public string $invalidTerms = ''; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); // just to set g_user and g_locale + + $this->query = $this->_get['search']; // technically rawParam, but prepared + + $this->searchMask = Search::TYPE_REGULAR | self::SEARCH_MODS_ALL; + + $this->searchObj = new Search($this->query, $this->searchMask); + } + + protected function generate() : void + { + if (!$this->query) // empty search > goto home page + $this->forward(); + + $this->search = $this->query; // escaped by TemplateResponse + + if ($iv = $this->searchObj->invalid) + $this->invalidTerms = implode(', ', Util::htmlEscape($iv)); + + array_unshift($this->title, $this->search, Lang::main('search')); + + $this->redButtons[BUTTON_WOWHEAD] = true; + $this->wowheadLink = sprintf(WOWHEAD_LINK, Lang::getLocale()->domain(), 'search=', Util::htmlEscape($this->query)); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], __forceTabs: true); + + $canRedirect = true; + $redirectTo = ''; + + if ($this->searchObj->canPerform()) + { + foreach ($this->searchObj->perform() as $lvData) + { + if ($lvData[1] == 'npc' || $lvData[1] == 'object') + $this->addDataLoader('zones'); + + $this->lvTabs->addListviewTab(new Listview(...$lvData)); + + // we already have a target > can't have more targets > no redirects + if (($canRedirect && $redirectTo) || count($lvData[0]['data']) > 1) + $canRedirect = false; + + if ($canRedirect) // note - we are very lucky that in case of searches $template is identical to the typeString + $redirectTo = '?'.$lvData[1].'='.key($lvData[0]['data']); + } + } + + $this->extendGlobalData($this->searchObj->getJSGlobals()); + + parent::generate(); + + $this->result->registerDisplayHook('lvTabs', [self::class, 'tabsHook']); + + // this one stings.. + // we have to manually call saveCache, beacause normally it would be called AFTER the page is rendered.. + // .. which will not happen if we forward to somewhere + // also we have to set a postCacheHook in this case that handles future forwards (gets called in display() so the currenct call is also covered) + if ($canRedirect && $redirectTo) + { + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay'], $redirectTo); + $this->saveCache($this->result); + } + } + + // update dates to now() + public static function tabsHook(Template\PageTemplate $pt, Tabs &$lvTabs) : void + { + foreach ($lvTabs->iterate() as &$listview) + if ($listview instanceof Listview && $listview->getTemplate() == 'holiday') + WorldEventList::updateListview($listview); + } + + public static function onBeforeDisplay(Template\PageTemplate $pt, string $url) : never + { + header('Location: '.$url, true, 302); // we no longer have access to BaseResponse .. so thats fun + exit(); + } +} + +?> diff --git a/endpoints/search/search_json.php b/endpoints/search/search_json.php new file mode 100644 index 00000000..fa8d4b5c --- /dev/null +++ b/endpoints/search/search_json.php @@ -0,0 +1,91 @@ + search by compare or profiler (only items + itemsets) + array:[ + searchString, + [itemData], + [itemsetData] + ] +*/ + + +class SearchJsonResponse extends TextResponse implements ICache +{ + use TrCache, TrSearch; + + protected int $cacheType = CACHE_TYPE_SEARCH; + + protected array $expectedGET = array( + 'search' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine'] ], + 'wt' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIntArray'] ], + 'wtv' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIntArray'] ], + 'slots' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIntArray'] ], + 'type' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => Type::ITEM, 'max_range' => Type::ITEMSET]] + ); + + private array $extraOpts = []; // for weighted search + private array $extraCnd = []; // for weighted search + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + $this->query = $this->_get['search']; // technically rawParam, but prepared + + if ($this->_get['wt'] && $this->_get['wtv']) // slots and type should get ignored + { + $itemFilter = new ItemListFilter($this->_get); + if ($_ = $itemFilter->createConditionsForWeights()) + { + $this->extraOpts = $itemFilter->extraOpts; + $this->extraCnd[] = $_; + } + } + + if ($_ = array_filter($this->_get['slots'] ?? [])) + $this->extraCnd[] = ['slot', $_]; + + $this->searchMask = Search::TYPE_JSON; + if ($this->_get['slots'] || $this->_get['type'] == Type::ITEM) + $this->searchMask |= 1 << Search::MOD_ITEM; + else if ($this->_get['type'] == Type::ITEMSET) + $this->searchMask |= 1 << Search::MOD_ITEM | 1 << Search::MOD_ITEMSET; + + $this->searchObj = new Search($this->query, $this->searchMask, $this->extraCnd, $this->extraOpts); + } + + // !note! dear reader, if you ever try to generate a string, that is to be evaled by JS, NEVER EVER terminate with a \n ..... $totalHoursWasted +=2; + protected function generate() : void + { + $outItems = []; + $outSets = []; + + // invalid conditions: not enough characters to search OR no types to search + if (!$this->searchObj->canPerform()) + $this->generate404($this->query); + + foreach ($this->searchObj->perform() as $modId => $data) + { + if ($modId == Search::MOD_ITEM) + $outItems = $data; + else if ($modId == Search::MOD_ITEMSET) + $outSets = $data; + } + + $this->result = Util::toJSON([$this->query, $outItems, $outSets]); + } + + public function generate404(?string $search = ''): never + { + parent::generate404(Util::toJSON([$search, [], []])); + } +} + +?> diff --git a/endpoints/search/search_open.php b/endpoints/search/search_open.php new file mode 100644 index 00000000..699fc3fa --- /dev/null +++ b/endpoints/search/search_open.php @@ -0,0 +1,128 @@ + suggestions when typing into searchboxes + array:[ + str, // search + str[10], // found + [], // unused - description for found result? + str[10], // url to found result + [], // unused + [], // unused + [], // unused + str[10][4] // type, typeId, param1 (4:quality, 3,6,9,10,17:icon, 5,11:faction), param2 (3:quality, 6:rank) + ] + + WH walked away from this hybrid approach and has separate endpoints for internal search suggestions and opensearch, with the latter only providing found text (index 1) + + we move the appendix of ' (TypeName)' on found text to descriptions as it fucks over Firefox users when they apply the suggestion +*/ + + +class SearchOpenResponse extends TextResponse implements ICache +{ + use TrCache, TrSearch; + + private const /* int */ SEARCH_MODS_OPEN = + 1 << Search::MOD_CLASS | 1 << Search::MOD_RACE | 1 << Search::MOD_TITLE | 1 << Search::MOD_WORLDEVENT | + 1 << Search::MOD_CURRENCY | 1 << Search::MOD_ITEMSET | 1 << Search::MOD_ITEM | 1 << Search::MOD_ABILITY | + 1 << Search::MOD_TALENT | 1 << Search::MOD_CREATURE | 1 << Search::MOD_QUEST | 1 << Search::MOD_ACHIEVEMENT | + 1 << Search::MOD_ZONE | 1 << Search::MOD_OBJECT | 1 << Search::MOD_FACTION | 1 << Search::MOD_SKILL | + 1 << Search::MOD_PET; + + private int $maxResults = Search::SUGGESTIONS_MAX_RESULTS; + + protected string $contentType = MIME_TYPE_OPENSEARCH; + protected int $cacheType = CACHE_TYPE_SEARCH; + + protected array $expectedGET = array( + 'search' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); // just to set g_user and g_locale + + $this->query = $this->_get['search']; // technically rawParam, but prepared + + $this->searchMask = Search::TYPE_OPEN | self::SEARCH_MODS_OPEN; + + $this->searchObj = new Search($this->query, $this->searchMask, maxResults: $this->maxResults); + } + + protected function generate() : void + { + // this one is funny: we want 10 results, ideally equally distributed over each type + $foundTotal = 0; + $result = array( // 0:query, 1:[names], 3:[links]; 7:[extraInfo] + $this->query, + [], [], [], [], [], [], [] + ); + + // invalid conditions: not enough characters to search OR no types to search + if (!$this->searchObj->canPerform()) + $this->generate404($this->query); + + foreach ($this->searchObj->perform() as [, , $nMatches, , , ]) + $foundTotal += $nMatches; + + foreach ($this->searchObj->perform() as [$data, $type, $nMatches, $param1, $param2, $desc]) + { + $max = max(1, intVal($this->maxResults * $nMatches / $foundTotal)); + + $i = 0; + foreach ($data as $id => $name) + { + if (++$i > $max) + break; + + if (count($result[1]) >= $this->maxResults) + break 2; + + $result[1][] = $name; // originally - $name . ' ('.$desc.')' + $result[2][] = $desc; // .. and here empty + $result[3][] = Cfg::get('HOST_URL').'/?'.Type::getFileString($type).'='.$id; + + $extra = [$type, $id]; // type, typeId + if (isset($param1[$id])) + $extra[] = $param1[$id]; // param1 + if (isset($param2[$id])) + $extra[] = $param2[$id]; // param2 + + $result[7][] = $extra; + } + } + + $this->result = Util::toJSON($result); + } + + public function generate404(?string $search = null) : never + { + parent::generate404(Util::toJSON([$search, [], [], [], [], [], [], []])); + } +} + +?> diff --git a/endpoints/searchbox/searchbox.php b/endpoints/searchbox/searchbox.php new file mode 100644 index 00000000..22c289d9 --- /dev/null +++ b/endpoints/searchbox/searchbox.php @@ -0,0 +1,34 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/searchplugins/searchplugins.php b/endpoints/searchplugins/searchplugins.php new file mode 100644 index 00000000..6dd00b1b --- /dev/null +++ b/endpoints/searchplugins/searchplugins.php @@ -0,0 +1,34 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/signature/delete.php b/endpoints/signature/delete.php new file mode 100644 index 00000000..2b9a9506 --- /dev/null +++ b/endpoints/signature/delete.php @@ -0,0 +1,30 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkIdListUnsigned']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + } + + protected function generate() : void + { + if (!$this->assertGET('id')) + $this->generate404(); + + // DB::Aowow()->qry(DELETE FROM ::account_signatures WHERE %if', !User::isInGroup(U_GROUP_MODERATOR), '`accountId` = %i AND', User::$id, '%end `id` IN %in', $this->_get['id]); + } +} + +?> diff --git a/endpoints/signature/generate.php b/endpoints/signature/generate.php new file mode 100644 index 00000000..a04ef5ac --- /dev/null +++ b/endpoints/signature/generate.php @@ -0,0 +1,60 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkProfileId']] + ); + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + } + + protected function generate() : void + { + parent::generate(); + + if (!$this->assertGET('id')) + $this->generate404(); + + // find file in storage + + // build image + } + + public function generate404(?string $out = null) : never + { + // "Signature Unavailable" + $out = /*data:image/png;base64,*/'iVBORw0KGgoAAAANSUhEUgAAAdQAAAA8CAIAAABQJdxgAAALbElEQVR4nO3bXWxT5R8H8OectnvtDtB2fVnXN6Cr29zW0SCSjMgSh0YSifPtjgsu1EuvjXFojPfKDRLitYQYxEggEwzhQiJb1+GG4MrWjrbr1petW6FubXee/8UTT05aKBv85az4/VyY9uyc5y32y9PfOeXMZjMBAIBni1d6AAAA/0UIXwAABSB8AQAUgPAFAFAAwhcAQAEIXwAABSB8AQAUgPAFAFAAwhcAQAEIXwAABSB8AQAUgPAFAFAAwhcAQAEIXwAABSB8AQAUgPAFAFAAwhcAQAEIXwAABSB8AQAUgPAFAFAAwhcAQAEIXwAABaiVHgDA8+bzzz+XXouiKH+72TM33hRUHYRv1di7d++BAwcEQZCOcBzHXgwNDR07dsxkMhFCFhYWvvvuO2WG+K8pD52hoaHy4+zgs1e++D6fjxBCKR0bG6t87WPP3HhTUF0QvtVhYGCgr6/P5XJt376dZS6ldH19XaPR+P1+QojJZNq3bx8h5Pfff/+3B/Ppp5+q1WpCiCiKX3zxxb/dHcMyiGFTLjkuP/iMlS/+7Oysw+GQ/nWsIBQKuVyuCmduvCmoLqj5VoeXX37Z4/HodLpCoXDz5s2JiYlMJsMSUMJx3LP5iKrVap/P5/P5eP7Z/f8TCoXYi2AwKD+eSqUIIfPz889sJA8lX/zTp0+zUT3WqVOnFhcXK5yw8aag6mDnWx1UKlVjYyMhZGpqamlpaXx8vK+vz263s2+77Nv3+Pg4IaS2tpb999ChQ93d3RqNpkIiU0ql1ysrK2fPno1EIoSQvr6+ffv2NTU1SX9NpVLnzp2LxWLyb/ocx1WuQrIypfwcSimllOM4Sumff/45MjJy+PBhg8HABlksFkdHR4eHh0VRlLfz7bfffvjhhy6Xi43zxIkT7PhPP/301ltvGQyGeDx+5syZko42NbUPPvigwkQIIb/88kuFC+WLzzoq8ah+5eccP36cvchms8PDwxMTEw9tyul0bmTRYItD+FYZt9vN8/yBAwey2Ww0Go1Go+x4SWXw/fff93g8bre7vr6eyKrDhBC/3y+dHAwGd+7cqVKpKKWRSGRwcPDrr78mhOzfv7+np6epqYntbUVRvHfv3ttvv/3NN98Q2Td99kJqkPzz3b+8TCkdmZ6edrlcPM/n8/l8Pt/e3m42m61WK+sol8tpNJpCoXDlyhX5rOfm5uRvpc3gH3/88eabb7IR/vXXX/KONjs1UlbZsFqtZrNZevvYCyuXZStczlgsFrPZzP5ZSqVSgiAUCoU7d+6UtGO3248ePbqRRYMtDmWH6rC2tra0tEQIqaur6+jo8Hq9XV1dPM9ns1m2DZydnSWykHW5XB0dHQ0NDYlEYnx8nH23LRQKLBzZV3iO4/R6/cTERCKR4HneZrPt2LFD6lEQhJs3b/r9/mg0qlKpHA6HTqdjf5Kqq36/X96ghFUG5Ikv9dja2nr37l1KqVqt1mg0ZrPZZrMVCoVAIJBOpxsbG9va2l566aUNLkuhUJBer6+vP+XUQqEQ2y+zIkYsFkun05RSaXaPurBk8R+lwpISQkRRHBsbm5qaopQajUar1frqq6+WN/L6668/5aLBFoHwrQ6XL18OhUKRSCSfzxNC1Gq1wWDwer1Wq3VwcLC8MshxHKsIz83NpdNplg5qtZpSKq8zhsNhlgVEVrXkeZ7juNHRUVEUa2trGxoa5H8teaJgaGjo5MmT8sLliRMnVlZW5OfIe7x161Y6nb5x48bIyAjP8y0tLRzHsdSLxWKEEK1Wy768P4GnmdrJkyeTyWQulyOEbN++XRTFtbU1jUaTy+VSqdTp06cfdeFGyrKVl5RZWFg4f/78vXv3WKmhublZr9eXN2WxWP6/iwZKQdmhOoyMjKRSqf7+fpvNVlNTY7Va9Xo9x3Eul2t5ebm8MkgpLRaLGo3G4XAQQux2OyFkfX1d+rhKzp8/39vbS2QbtzfeeINtpurr61Uq1YMHD0jFbV08Hpe/LU+ikh6/+uor9uL48eMqlYoQ0t7evsF1YFQqFdvnajSaCh1tamrxeDwQCAiC4PF4amtrWblGq9Xevn07EAj09PQ86sKHlmVLbHBJA4FAJpM5duyY0+lkeV3eFMdxT7ZosNUgfKuDdCvp3LlzGo1mYGDAaDTu3r2bfQ7LhcPhuro6t9ut0+l0Oh2ldHV1NRgMsi2wnPzGFLNnz56Ojo76+vr79+/fuXOH4zh5MfSxnuARiEAgIL9ZVD4k6TjHcTzP2+12Vgpoa2tjVd1HnV9ypPLUrl275vP58vl8bW2tw+G4f/9+oVDIZrNXr179+OOPn2ZNNr6klW+Qym1w0WDLQtmhavh8vtbW1iNHjgiCkEgkWP2B2bZtm/xMs9l85syZtbU1tVodDAb9fv/Y2Njk5OTq6qooijabTX4ye4RAYjAYeJ6vq6sjhNy9ezeTycjDVKvVloyK7ayJ7MP/0UcflTRYMjx5+ZXFhyAI8Xjc7/enUqlkMnnr1q3y6VNK2cl6vf7dd9/1eDzd3d1HjhwxmUyiKLLeSzra7NSWl5dnZmbYzT2tVms0GqPR6Ozs7MrKSoULLRaLvBez2VwyDKPRWOFyuffee+/w4cOs2sC29uVNbWrRYCtTlX+cYAvq7+9vaWlpbGzUarU6nW7btm0ej0elUi0uLs7Nzb3yyiuNjY0Gg4EQ8vfff3d1dV2+fPngwYOsOmGxWFihsLm5uaampqurSzo5l8u1t7c3NDQ0NzcTQh48eOD1egkhrKpICBFF0ePxqNVqjuOWlpYcDsfo6Gh/f7/FYuE4rlgsut1uu92+Y8cOdkSlUjU3NzudTlZxZg3u379f6jGbze7Zs+fq1auEkM7OzsbGxqamJkEQisWixWLp7e3duXPn7du3y6PkxRdfrKurEwRBEASVSrVr167Ozk6bzWY2m1lE3rhx45NPPnnKqaVSqY6ODpPJxPO8KIrT09M//PBDNps9ePDgoy70er0liy+fby6X6+7urtBvsVhsaWmhlOp0Oo7jDAaD0+kkhMzNzc3MzAwMDJQ0lUwmN75osJVh51tlBEHo6urq6elRq9WJRCIcDrMHjF544QV2wq5duwghR48eZeFI/rmxw3FcTU2N2+2Wn7x7924iqx6yv+ZyuWg0Sik1mUzd3d2ZTGZ1dZVSKl21urrKHgOw2Wws0TKZDLvEaDRaLJZYLMa2oqxBeY8ej0eay/DwcDwen5+f53m+s7Ozp6eH5/nJycmHTvznn3+en5+fmZkpFAqtra29vb1er9dgMLD7kBcuXCjp6Mmmxp5wYGXrpaWlTCbDNsKVLyxZ/PJhVL6cUppOp/P5fG9vb1tbGyEkHo8vLCxcunSpvKlNLRpsZaj5Vg32UJe8tLe8vHzlyhX2rGvJj2udTicL6Onp6UwmQwjR6XTsAVtS9kvckrdnz54dHBxcWFhgb/P5fCwWk3ZthJCLFy/yPB8Oh6Xx/Pjjj++8804ikZAaKXni9aG//Q0Gg99///1rr70m3SgTRXFycvLixYvlJ4fD4VOnTg0MDCSTSemnfZTSaDR66dIlqYWnnBoh5Ndff9Xr9ewxiWvXrj32Qo7jyme3qX6vX7/OKtfSvFZWVi5cuMDq2iVNbWrRYCvjpMfI4Xny2Wef7d27l+O4ZDIZiUQ4jrPZbAaDYXFxcWpq6ssvv1R6gAD/ddj5Pp8KhUIkEmlpaTEYDKxiSCnNZDKhUGh4eFjp0QEAdr7PKbvdzn4KJb+xns1mf/vtt+vXrys4MABgEL4AAArA0w4AAApA+AIAKADhCwCgAIQvAIACEL4AAApA+AIAKADhCwCgAIQvAIACEL4AAApA+AIAKADhCwCgAIQvAIACEL4AAApA+AIAKADhCwCgAIQvAIACEL4AAApA+AIAKADhCwCggP8BQFB72fG1SEAAAAAASUVORK5CYII='; + parent::generate404(base64_decode($out)); + } + + protected static function checkProfileId(string $sigId) : ?int + { + if (preg_match('/^(\d+)\.png$/i', $sigId, $m)) + return $m[0]; + + return null; + } +} + +?> diff --git a/endpoints/signature/signature.php b/endpoints/signature/signature.php new file mode 100644 index 00000000..e0a5b76b --- /dev/null +++ b/endpoints/signature/signature.php @@ -0,0 +1,55 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkProfile']] // optional - full profile string to build sig from + ); + + private int $id = 0; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + if ($rawParam) + $this->id = intVal($rawParam); + else if ($this->assertGET('profile')) + $this->id = $this->_get['profile']; + else + $this->generateError(); + } + + protected function generate() : void + { + // show editor + + parent::generate(); + } + + protected static function checkProfile(string $profile) : ?int + { + if (!preg_match('/^([a-z]+)\.([a-z_]+)\.(.+)$/i', $profile, $m)) + return null; + + [, $region, $realm, $char] = $m; + + $realms = Profiler::getRealms(); + if ($rId = array_find_key($realms, fn($x) => $x['region'] == $region && $x['name'] == $realm)) + return DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_profiles WHERE `realm` = %i AND `custom` = 0 AND `name` = %s', $rId, urldecode($char)) ?: null; + + return null; + } +} + +?> diff --git a/endpoints/sitemap/sitemap.php b/endpoints/sitemap/sitemap.php new file mode 100644 index 00000000..09e5ca53 --- /dev/null +++ b/endpoints/sitemap/sitemap.php @@ -0,0 +1,52 @@ + ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_value' => 1]] + ); + + private string $page; + + public function __construct(string $pageParam) + { + $this->page = $pageParam; + + parent::__construct($pageParam); + } + + protected function generate() : void + { + if ($xml = Sitemap::generate($this->page, $this->_get['page'] ?? 1)) + $this->result = $xml; + else if (Sitemap::$maxPage) + (new TemplateResponse($this->page))->generateNotFound(Sitemap::ERR_TITLE, sprintf(Sitemap::ERR_OFFSET, Sitemap::$maxPage)); + else + (new TemplateResponse($this->page))->generateNotFound(Sitemap::ERR_TITLE, Sitemap::ERR_PAGE); + } + + public function getCacheKeyComponents() : array + { + $misc = $this->page . serialize($this->_get['page'] ?? 1); + + return array( + -1, // DBType + -1, // DBTypeId/category + -1, // staff mask (content does not diff) + md5($misc) // misc + ); + } +} + +?> diff --git a/endpoints/skill/skill.php b/endpoints/skill/skill.php new file mode 100644 index 00000000..a95221bf --- /dev/null +++ b/endpoints/skill/skill.php @@ -0,0 +1,374 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new SkillList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('skill'), Lang::skill('notFound')); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_cat = $this->subject->getField('typeCat'); + + + /*************/ + /* Menu Path */ + /*************/ + + if (in_array($this->typeId, SKILLS_TRADE_PRIMARY) || in_array($this->typeId, SKILLS_TRADE_SECONDARY)) + $this->breadcrumb[] = $this->typeId; + else + $this->breadcrumb[] = $_cat; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('skill'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + // id + $infobox[] = Lang::skill('id') . $this->typeId; + + // icon + if ($_ = $this->subject->getField('iconId')) + { + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $this->extendGlobalIds(Type::ICON, $_); + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $this->headIcons = [$this->subject->getField('iconString')]; + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId] + ); + + if ($_ = $this->subject->getField('description', true)) + $this->extraText = new Markup($_, ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], 'text-generic'); + + + /**************/ + /* Extra Tabs */ + /**************/ + + parent::generate(); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + if (in_array($_cat, [-5, 9, 11])) + { + // tab: recipes [spells] (crafted) + $condition = array( + [DB::OR, ['s.reagent1', 0, '>'], ['s.reagent2', 0, '>'], ['s.reagent3', 0, '>'], ['s.reagent4', 0, '>'], ['s.reagent5', 0, '>'], ['s.reagent6', 0, '>'], ['s.reagent7', 0, '>'], ['s.reagent8', 0, '>']], + [DB::OR, ['s.skillLine1', $this->typeId], [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->typeId]]] + ); + + $recipes = new SpellList($condition); // also relevant for 3 + if (!$recipes->error) + { + $this->extendGlobalData($recipes->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $recipes->getListviewData(), + 'id' => 'recipes', + 'name' => '$LANG.tab_recipes', + 'visibleCols' => ['reagents', 'source'], + 'note' => sprintf(Util::$filterResultString, '?spells='.$_cat.'.'.$this->typeId.'&filter=cr=20;crs=1;crv=0') + ), SpellList::$brickFile)); + } + + // tab: recipe Items [items] (Books) + $filterRecipe = [null, SKILL_LEATHERWORKING, SKILL_TAILORING, SKILL_ENGINEERING, SKILL_BLACKSMITHING, SKILL_COOKING, SKILL_ALCHEMY, SKILL_FIRST_AID, SKILL_ENCHANTING, SKILL_FISHING, SKILL_JEWELCRAFTING, SKILL_INSCRIPTION, SKILL_MINING, SKILL_HERBALISM]; + $conditions = array( + ['requiredSkill', $this->typeId], + ['class', ITEM_CLASS_RECIPE] + ); + + $recipeItems = new ItemList($conditions); + if (!$recipeItems->error) + { + $this->extendGlobalData($recipeItems->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = array( + 'data' => $recipeItems->getListviewData(), + 'id' => 'recipe-items', + 'name' => '$LANG.tab_recipeitems', + ); + + if ($_ = array_search($this->typeId, $filterRecipe)) + $tabData['note'] = sprintf(Util::$filterResultString, "?items=9.".$_); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + } + + // tab: crafted items [items] + $created = []; + foreach ($recipes->iterate() as $__) + if ($idx = $recipes->canCreateItem()) + foreach ($idx as $i) + $created[] = $recipes->getField('effect'.$i.'CreateItemId'); + + if ($created) + { + $created = new ItemList(array(['i.id', $created])); + if (!$created->error) + { + $this->extendGlobalData($created->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = array( + 'data' => $created->getListviewData(), + 'id' => 'crafted-items', + 'name' => '$LANG.tab_crafteditems', + ); + + if (!is_null($_ = ItemListFilter::getCriteriaIndex(86, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, "?items&filter=cr=86;crs=".$_.";crv=0"); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + } + } + + // tab: required by [item] + $conditions = array( + ['requiredSkill', $this->typeId], + ['class', ITEM_CLASS_RECIPE, '!'] + ); + + $reqBy = new ItemList($conditions); + if (!$reqBy->error) + { + $this->extendGlobalData($reqBy->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = array( + 'data' => $reqBy->getListviewData(), + 'id' => 'required-by', + 'name' => '$LANG.tab_requiredby', + ); + + if (!is_null($_ = ItemListFilter::getCriteriaIndex(99, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, "?items&filter=cr=99:168;crs=".$_.":2;crv=0:0"); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + } + + // tab: required by [itemset] + $reqBy = new ItemsetList(array(['skillId', $this->typeId])); + if (!$reqBy->error) + { + $this->extendGlobalData($reqBy->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $reqBy->getListviewData(), + 'id' => 'required-by-set', + 'name' => '$LANG.tab_requiredby' + ), ItemsetList::$brickFile)); + } + } + + // tab: modified by [spell] + $conditions = array( + DB::OR, + [DB::AND, ['effect1AuraId', [SPELL_AURA_MOD_SKILL, SPELL_AURA_MOD_SKILL_TALENT]], ['effect1MiscValue', $this->typeId]], + [DB::AND, ['effect2AuraId', [SPELL_AURA_MOD_SKILL, SPELL_AURA_MOD_SKILL_TALENT]], ['effect2MiscValue', $this->typeId]], + [DB::AND, ['effect3AuraId', [SPELL_AURA_MOD_SKILL, SPELL_AURA_MOD_SKILL_TALENT]], ['effect3MiscValue', $this->typeId]] + ); + $modBy = new SpellList($conditions); + if (!$modBy->error) + { + $this->extendGlobalData($modBy->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $modBy->getListviewData(), + 'id' => 'modified-by', + 'name' => '$LANG.tab_modifiedby', + 'hiddenCols' => ['skill'], + ), SpellList::$brickFile)); + } + + // tab: spells [spells] (exclude first tab) + $reqClass = 0x0; + $reqRace = 0x0; + $condition = array( + [DB::AND, ['s.reagent1', 0], ['s.reagent2', 0], ['s.reagent3', 0], ['s.reagent4', 0], ['s.reagent5', 0], ['s.reagent6', 0], ['s.reagent7', 0], ['s.reagent8', 0]], + [DB::OR, ['s.skillLine1', $this->typeId], [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->typeId]]] + ); + + foreach (Game::$skillLineMask as $line1 => $sets) + foreach ($sets as $idx => [, $skillLineId]) + if ($skillLineId == $this->typeId) + { + $condition[1][] = array(DB::AND, ['s.skillLine1', $line1], ['s.skillLine2OrMask', 1 << $idx, '&']); + break 2; + } + + $spells = new SpellList($condition); + if (!$spells->error) + { + foreach ($spells->iterate() as $__) + { + $reqClass |= $spells->getField('reqClassMask'); + $reqRace |= $spells->getField('reqRaceMask'); + } + + $this->extendGlobalData($spells->getJSGlobals(GLOBALINFO_SELF)); + + $tabData = array( + 'data' => $spells->getListviewData(), + 'visibleCols' => ['source'] + ); + + if ($this->typeId != 769) // Internal + $tabData['note'] = match ($_cat) + { + -4, 7 => sprintf(Util::$filterResultString, '?spells=-4'), + 7 => sprintf(Util::$filterResultString, '?spells='.$_cat.'.'.ChrClass::fromMask($reqClass)[0].'.'.$this->typeId), // doesn't matter what spell; reqClass should be identical for all Class Spells + 9, 11 => sprintf(Util::$filterResultString, '?spells='.$_cat.'.'.$this->typeId), + default => null + }; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + + // tab: trainers [npcs] + if (in_array($_cat, [-5, 6, 7, 8, 9, 11])) + { + $mask = 0; + foreach (Game::$skillLineMask[-3] as $idx => [, $skillLineId]) + if ($skillLineId == $this->typeId) + $mask |= 1 << $idx; + + $spellIds = DB::Aowow()->selectCol( + 'SELECT `id` FROM ::spell WHERE %if', $mask, '(`skillLine1` = -3 AND `skillLine2OrMask` = %i) OR', $mask, '%end (`skillLine1` = %i OR (`skillLine1` > 0 AND `skillLine2OrMask` = %i))', + $this->typeId, + $this->typeId + ); + + $list = $spellIds ? DB::World()->selectCol('SELECT cdt.`CreatureId` FROM creature_default_trainer cdt JOIN trainer_spell ts ON ts.`TrainerId` = cdt.`TrainerId` WHERE ts.`SpellID` IN %in', $spellIds) : []; + if ($list) + { + $trainer = new CreatureList(array(['ct.id', $list], ['s.guid', NULL, '!'], ['ct.npcflag', 0x10, '&'])); + + if (!$trainer->error) + { + $this->extendGlobalData($trainer->getJSGlobals()); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $trainer->getListviewData(), + 'id' => 'trainer', + 'name' => '$LANG.tab_trainers', + ), CreatureList::$brickFile)); + } + } + } + + // tab: quests [quests] + // only for professions + $sort = match ($this->typeId) + { + SKILL_HERBALISM => 24, + SKILL_FISHING => 101, + SKILL_BLACKSMITHING => 121, + SKILL_ALCHEMY => 181, + SKILL_LEATHERWORKING => 182, + SKILL_ENGINEERING => 201, + SKILL_TAILORING => 264, + SKILL_COOKING => 304, + SKILL_FIRST_AID => 324, + SKILL_INSCRIPTION => 371, + SKILL_JEWELCRAFTING => 373, + default => 0 + }; + + if ($sort) + { + $quests = new QuestList(array(['questSortId', -$sort])); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals()); + $this->lvTabs->addListviewTab(new Listview(['data' => $quests->getListviewData()], QuestList::$brickFile)); + } + } + + // tab: related classes (apply classes from [spells]) + if ($class = ChrClass::fromMask($reqClass)) + { + $classes = new CharClassList(array(['id', $class])); + if (!$classes->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $classes->getListviewData()], CharClassList::$brickFile)); + } + + // tab: related races (apply races from [spells]) + if ($race = ChrRace::fromMask($reqRace)) + { + $races = new CharRaceList(array(['id', $race])); + if (!$races->error) + $this->lvTabs->addListviewTab(new Listview(['data' => $races->getListviewData()], CharRaceList::$brickFile)); + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::SKILL, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + } +} + +?> diff --git a/endpoints/skills/skills.php b/endpoints/skills/skills.php new file mode 100644 index 00000000..1d1f1a36 --- /dev/null +++ b/endpoints/skills/skills.php @@ -0,0 +1,66 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('skills')); + + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::skill('cat', $this->category[0])); + + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($this->category) + $conditions[] = ['typeCat', $this->category[0]]; + + $tabData = ['data' => []]; + $skills = new SkillList($conditions); + if (!$skills->error) + $tabData['data'] = $skills->getListviewData(); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, SkillList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/sound/sound.php b/endpoints/sound/sound.php new file mode 100644 index 00000000..8db7cf8f --- /dev/null +++ b/endpoints/sound/sound.php @@ -0,0 +1,320 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new SoundList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('sound'), Lang::sound('notFound')); + + $this->h1 = $this->subject->getField('name'); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_cat = $this->subject->getField('cat'); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $_cat; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('sound'))); + + + /****************/ + /* Main Content */ + /****************/ + + // get spawns + if ($spawns = $this->subject->getSpawns(SPAWNINFO_FULL)) + { + $this->addDataLoader('zones'); + $this->map = array( + ['parent' => 'mapper-generic'], // Mapper + $spawns, // mapperData + null, // ShowOnMap + [Lang::sound('foundIn')] // foundIn + ); + foreach ($spawns as $areaId => $__) + $this->map[3][$areaId] = ZoneList::getName($areaId); + } + + // get full path in-game for sound (workaround for missing PlaySoundKit()) + $fullpath = DB::Aowow()->selectCell('SELECT IF(sf.`path` <> "", CONCAT(sf.`path`, "\\", sf.`file`), sf.`file`) FROM ::sounds_files sf JOIN ::sounds s ON s.`soundFile1` = sf.`id` WHERE s.`id` = %i', $this->typeId); + + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_PLAYLIST => true, + BUTTON_LINKS => array( + 'type' => Type::SOUND, + 'typeId' => $this->typeId, + 'sound' => str_replace('\\', '\\\\', $fullpath) // escape for wow client + ) + ); + + $this->extendGlobalData($this->subject->getJSGlobals()); + + parent::generate(); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: Spells + // skipping (always empty): ready, castertargeting, casterstate, targetstate + $displayIds = DB::Aowow()->selectCol( + 'SELECT `id` + FROM ::spell_sounds + WHERE `animation` = %i OR `precast` = %i OR `cast` = %i OR `impact` = %i OR `state` = %i OR + `statedone` = %i OR `channel` = %i OR `casterimpact` = %i OR `targetimpact` = %i OR `missiletargeting` = %i OR + `instantarea` = %i OR `persistentarea` = %i OR `missile` = %i OR `impactarea` = %i', + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId + ); + + $seMiscValues = DB::Aowow()->selectCol( + 'SELECT `id` + FROM ::screeneffect_sounds + WHERE `ambienceDay` = %i OR `ambienceNight` = %i OR `musicDay` = %i OR `musicNight` = %i', + $this->typeId, $this->typeId, $this->typeId, $this->typeId + ); + + $cnd = array( + DB::OR, + [DB::AND, ['effect1Id', [SPELL_EFFECT_PLAY_MUSIC, SPELL_EFFECT_PLAY_SOUND]], ['effect1MiscValue', $this->typeId]], + [DB::AND, ['effect2Id', [SPELL_EFFECT_PLAY_MUSIC, SPELL_EFFECT_PLAY_SOUND]], ['effect2MiscValue', $this->typeId]], + [DB::AND, ['effect3Id', [SPELL_EFFECT_PLAY_MUSIC, SPELL_EFFECT_PLAY_SOUND]], ['effect3MiscValue', $this->typeId]] + ); + + if ($displayIds) + $cnd[] = ['spellVisualId', $displayIds]; + + if ($seMiscValues) + $cnd[] = array( + DB::OR, + [DB::AND, ['effect1AuraId', SPELL_AURA_SCREEN_EFFECT], ['effect1MiscValue', $seMiscValues]], + [DB::AND, ['effect2AuraId', SPELL_AURA_SCREEN_EFFECT], ['effect2MiscValue', $seMiscValues]], + [DB::AND, ['effect3AuraId', SPELL_AURA_SCREEN_EFFECT], ['effect3MiscValue', $seMiscValues]] + ); + + $spells = new SpellList($cnd); + if (!$spells->error) + { + $this->extendGlobalData($spells->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(['data' => $spells->getListviewData()], SpellList::$brickFile)); + } + + // tab: Items + $subClasses = []; + if ($subClassMask = DB::Aowow()->selectCell('SELECT `subClassMask` FROM ::items_sounds WHERE `soundId` = %i', $this->typeId)) + for ($i = 0; $i <= 20; $i++) + if ($subClassMask & (1 << $i)) + $subClasses[] = $i; + + $where = array( + ['`pickUpSoundId` = %i', $this->typeId], + ['`dropDownSoundId` = %i', $this->typeId], + ['`sheatheSoundId` = %i', $this->typeId], + ['`unsheatheSoundId` = %i', $this->typeId] + ); + if ($displayIds) + $where[] = ['`spellVisualId` IN %in', $displayIds]; + if ($subClasses) + $where[] = [DB::AND, [['IF (`soundOverrideSubclass` > 0, `soundOverrideSubclass`, `subclass`) IN %in', $subClasses], ['`class` = %i', ITEM_CLASS_WEAPON]]]; + + if ($itemIds = DB::Aowow()->selectCol('SELECT `id` FROM ::items WHERE %or', $where)) + { + $items = new ItemList(array(['id', $itemIds])); + if (!$items->error) + { + $this->extendGlobalData($items->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(['data' => $items->getListviewData()], ItemList::$brickFile)); + } + } + + // tab: Zones + if ($zoneIds = DB::Aowow()->selectAssoc('SELECT `id`, `worldStateId`, `worldStateValue` FROM ::zones_sounds WHERE `ambienceDay` = %i OR `ambienceNight` = %i OR `musicDay` = %i OR `musicNight` = %i OR `intro` = %i', $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId)) + { + $zones = new ZoneList(array(['id', array_column($zoneIds, 'id')])); + if (!$zones->error) + { + $this->extendGlobalData($zones->getJSGlobals(GLOBALINFO_SELF)); + + $zoneData = $zones->getListviewData(); + $parents = $zones->getAllFields('parentArea'); + $tabData = []; + $pIds = array_filter(array_unique(array_values($parents))); + if ($pIds) + { + $pZones = new ZoneList(array(['id', $pIds])); + if (!$pZones->error) + { + $this->extendGlobalData($pZones->getJSGlobals(GLOBALINFO_SELF)); + + $pData = $pZones->getListviewData(); + foreach ($parents as $child => $parent) + { + if (!$parent || empty($pData[$parent])) + continue; + + if (!isset($pData[$parent]['subzones'])) + $pData[$parent]['subzones'] = []; + + $pData[$parent]['subzones'][] = $child; + unset($parents[$child]); + } + + // these are original parents + foreach ($parents as $parent => $__) + if (empty($pData[$parent])) + $pData[$parent] = $zoneData[$parent]; + + $zoneData = $pData; + } + } + + if ($worldStates = array_filter($zoneIds, fn($x) => $x['worldStateId'] > 0)) + { + $tabData['extraCols'] = ['$Listview.extraCols.condition']; + + foreach ($worldStates as $state) + { + if (isset($zoneData[$state['id']])) + Conditions::extendListviewRow($zoneData[$state['id']], Conditions::SRC_NONE, $this->typeId, [Conditions::WORLD_STATE, $state['worldStateId'], $state['worldStateValue']]); + else + foreach ($zoneData as &$d) + if (in_array($state['id'], $d['subzones'] ?? [])) + Conditions::extendListviewRow($d, Conditions::SRC_NONE, $this->typeId, [Conditions::WORLD_STATE, $state['worldStateId'], $state['worldStateValue']]); + } + } + + $tabData['data'] = $zoneData; + $tabData['hiddenCols'] = ['territory']; + + $this->lvTabs->addListviewTab(new Listview($tabData, ZoneList::$brickFile)); + } + } + + // tab: Races (VocalUISounds (containing error voice overs)) + if ($vo = DB::Aowow()->selectCol('SELECT `raceId` FROM ::races_sounds WHERE `soundId` = %i GROUP BY `raceId`', $this->typeId)) + { + $races = new CharRaceList(array(['id', $vo])); + if (!$races->error) + { + $this->extendGlobalData($races->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(['data' => $races->getListviewData()], CharRaceList::$brickFile)); + } + } + + // tab: Emotes (EmotesTextSound (containing emote audio)) + if ($em = DB::Aowow()->selectCol('SELECT `emoteId` FROM ::emotes_sounds WHERE `soundId` = %i GROUP BY `emoteId` UNION SELECT `id` FROM ::emotes WHERE `soundId` = %i', $this->typeId, $this->typeId)) + { + $races = new EmoteList(array(['id', $em])); + if (!$races->error) + { + $this->extendGlobalData($races->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $races->getListviewData(), + 'name' => Util::ucFirst(Lang::game('emotes')) + ), EmoteList::$brickFile, 'emote')); + } + } + + $creatureIds = DB::World()->selectCol('SELECT ct.`CreatureID` FROM creature_text ct LEFT JOIN broadcast_text bct ON bct.`ID` = ct.`BroadCastTextId` WHERE bct.`SoundEntriesID` = %i OR ct.`Sound` = %i', $this->typeId, $this->typeId); + + // can objects or areatrigger play sound...? + if ($goosp = SmartAI::getOwnerOfSoundPlayed($this->typeId, Type::NPC)) + $creatureIds = array_merge($creatureIds, $goosp[Type::NPC]); + + // tab: NPC (dialogues...?, generic creature sound) + // skipping (always empty): transforms, footsteps + $displayIds = DB::Aowow()->selectCol( + 'SELECT `id` + FROM ::creature_sounds + WHERE `greeting` = %i OR `farewell` = %i OR `angry` = %i OR `exertion` = %i OR `exertioncritical` = %i OR + `injury` = %i OR `injurycritical` = %i OR `death` = %i OR `stun` = %i OR `stand` = %i OR + `aggro` = %i OR `wingflap` = %i OR `wingglide` = %i OR `alert` = %i OR `fidget` = %i OR + `customattack` = %i OR `loop` = %i OR `jumpstart` = %i OR `jumpend` = %i OR `petattack` = %i OR + `petorder` = %i OR `petdismiss` = %i OR `birth` = %i OR `spellcast` = %i OR `submerge` = %i OR `submerged` = %i', + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, + $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId + ); + + // broadcast_text <-> creature_text + if ($creatureIds || $displayIds) + { + $extra = []; + $cnds = [&$extra]; + if (!User::isInGroup(U_GROUP_STAFF)) + $cnds[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($creatureIds) + $extra[] = ['id', $creatureIds]; + + if ($displayIds) + $extra[] = ['displayId1', $displayIds]; + + if (count($extra) > 1) + array_unshift($extra, DB::OR); + else + $extra = array_pop($extra); + + $npcs = new CreatureList($cnds); + if (!$npcs->error) + { + $this->extendGlobalData($npcs->getJSGlobals(GLOBALINFO_SELF)); + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(['data' => $npcs->getListviewData()], CreatureList::$brickFile)); + } + } + } +} + + +?> diff --git a/endpoints/sound/sound_playlist.php b/endpoints/sound/sound_playlist.php new file mode 100644 index 00000000..334117ae --- /dev/null +++ b/endpoints/sound/sound_playlist.php @@ -0,0 +1,26 @@ +h1 = Lang::sound('cat', 1000); + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('sound'))); + + parent::generate(); + } +} + +?> diff --git a/endpoints/sounds/sounds.php b/endpoints/sounds/sounds.php new file mode 100644 index 00000000..eef7a1ea --- /dev/null +++ b/endpoints/sounds/sounds.php @@ -0,0 +1,120 @@ + ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = [1, 2, 3, 4, 6, 9, 10, 12, 13, 14, 16, 17, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 50, 52, 53]; + + public function __construct(string $rawParam) + { + $this->getCategoryFromUrl($rawParam); + if ($this->category) + $this->forward('?sounds&filter=ty='.$this->category[0]); + + parent::__construct($rawParam); + + if ($this->category) + $this->subCat = '='.implode('.', $this->category); + + $this->filter = new SoundListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('sounds')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + + /**************/ + /* Page Title */ + /**************/ + + $fiForm = $this->filter->values; + + array_unshift($this->title, $this->h1); + if (count($fiForm['ty']) == 1) + array_unshift($this->title, Lang::sound('cat', $fiForm['ty'][0])); + + + /*************/ + /* Menu Path */ + /*************/ + + if (count($fiForm['ty']) == 1) + $this->breadcrumb[] = $fiForm['ty'][0]; + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_PLAYLIST => true + ); + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; + + $tabData = []; + $sounds = new SoundList($conditions, ['calcTotal' => true]); + if (!$sounds->error) + { + $tabData['data'] = $sounds->getListviewData(); + + // create note if search limit was exceeded; overwriting 'note' is intentional + if ($sounds->getMatches() > Listview::DEFAULT_SIZE) + { + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_soundsfound', $sounds->getMatches(), Listview::DEFAULT_SIZE); + $tabData['_truncated'] = 1; + } + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, SoundList::$brickFile)); + + parent::generate(); + + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay']); + } + + public static function onBeforeDisplay() + { + // sort for dropdown-menus in filter + Lang::sort('sound', 'cat'); + } +} + +?> diff --git a/endpoints/spell/spell.php b/endpoints/spell/spell.php new file mode 100644 index 00000000..3819ddc6 --- /dev/null +++ b/endpoints/spell/spell.php @@ -0,0 +1,2536 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new SpellList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('spell'), Lang::spell('notFound')); + + if ($jsg = $this->subject->getJSGlobals(GLOBALINFO_ANY, $extra)) + $this->extendGlobalData($jsg, $extra); + + $this->modelInfo = $this->subject->getModelInfo($this->typeId); + if ($spelldifficulty = DB::Aowow()->selectAssoc( // has difficulty versions of itself + 'SELECT `normal10` AS "0", `normal25` AS "1", + `heroic10` AS "2", `heroic25` AS "3", + `mapType` AS ARRAY_KEY + FROM ::spelldifficulty + WHERE `normal10` = %i OR `normal25` = %i OR + `heroic10` = %i OR `heroic25` = %i', + $this->typeId, $this->typeId, $this->typeId, $this->typeId + )) + { + $this->mapType = key($spelldifficulty); + $this->difficulties = array_pop($spelldifficulty); + } + + // returns self or firstRank + if ($fr = DB::World()->selectCell('SELECT `first_spell_id` FROM spell_ranks WHERE `spell_id` = %i', $this->typeId)) + $this->firstRank = $fr; + else + $this->firstRank = DB::Aowow()->selectCell( + 'SELECT IF(s1.`RankNo` <> 1 AND s2.`id`, s2.`id`, s1.`id`) + FROM ::spell s1 + LEFT JOIN ::spell s2 + ON s1.`SpellFamilyId` = s2.`SpelLFamilyId` AND s1.`SpellFamilyFlags1` = s2.`SpelLFamilyFlags1` AND + s1.`SpellFamilyFlags2` = s2.`SpellFamilyFlags2` AND s1.`SpellFamilyFlags3` = s2.`SpellFamilyFlags3` AND + s1.`name_loc0` = s2.`name_loc0` AND s2.`RankNo` = 1 + WHERE s1.`id` = %i', + $this->typeId + ); + + $this->h1 = Util::htmlEscape($this->subject->getField('name', true)); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->subject->getField('name', true) + ); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->generatePath(); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->subject->getField('name', true), Util::ucFirst(Lang::game('spell'))); + + + /***********/ + /* Infobox */ + /***********/ + + $this->createInfobox(); + + + /***************/ + /* Red Buttons */ + /***************/ + + $this->redButtons = array( + BUTTON_VIEW3D => false, + BUTTON_WOWHEAD => true, + BUTTON_LINKS => array( + 'linkColor' => 'ff71d5ff', + 'linkId' => Type::getFileString(Type::SPELL).':'.$this->typeId, + 'linkName' => $this->subject->getField('name', true), + 'type' => $this->type, + 'typeId' => $this->typeId + ) + ); + + // could have multiple models set, one per effect + foreach ($this->modelInfo as $mI) + { + $this->redButtons[BUTTON_VIEW3D] = ['type' => $mI['type'], 'displayId' => $mI['displayId']]; + + if (isset($mI['humanoid'])) + { + $this->redButtons[BUTTON_VIEW3D]['typeId'] = $mI['typeId']; + $this->redButtons[BUTTON_VIEW3D]['humanoid'] = 1; + } + + break; + } + + + /*******************/ + /* Reagent Listing */ + /*******************/ + + $this->createReagentList(); + + + /******************/ + /* Required Items */ + /******************/ + + $this->createRequiredItems(); + + + /*************************/ + /* Required Tools/Totems */ + /*************************/ + + // prepare Tools + foreach ($this->subject->getToolsForCurrent() as $tool) + $this->tools[] = new IconElement( + Type::ITEM, + $tool['itemId'] ?? 0, + $tool['name'], + quality: ITEM_QUALITY_NORMAL, + size: IconElement::SIZE_SMALL, + url: !isset($tool['itemId']) ? '?items&filter=cr=91;crs='.$tool['id'].';crv=0' : '', + element: 'iconlist-icon' + ); + + + /**********************/ + /* Spell Effect Block */ + /**********************/ + + $this->createEffects(); + + + /**************************************************/ + /* Spell Attributes listing and SpellFilter links */ + /**************************************************/ + + $this->createAttributesList(); + + + /*************************/ + /* Factionchange pendant */ + /*************************/ + + // factionchange-equivalent + if ($pendant = DB::World()->selectCell('SELECT IF(`horde_id` = %i, `alliance_id`, -`horde_id`) FROM player_factionchange_spells WHERE `alliance_id` = %i OR `horde_id` = %i', $this->typeId, $this->typeId, $this->typeId)) + { + $altSpell = new SpellList(array(['id', abs($pendant)])); + if (!$altSpell->error) + { + $this->transfer = Lang::spell('_transfer', array( + $altSpell->id, + ITEM_QUALITY_NORMAL, + $altSpell->getField('iconString'), + $altSpell->getField('name', true), + $pendant > 0 ? 'alliance' : 'horde', + $pendant > 0 ? Lang::game('si', SIDE_ALLIANCE) : Lang::game('si', SIDE_HORDE) + )); + } + } + + + /****************/ + /* Main Content */ + /****************/ + + $this->powerCost = $this->subject->createPowerCostForCurrent(); + $this->castTime = $this->subject->createCastTimeForCurrent(false, false); + $this->level = $this->subject->getField('spellLevel'); + $this->rangeName = $this->subject->getField('rangeText', true); + $this->gcd = DateTime::formatTimeElapsedFloat($this->subject->getField('startRecoveryTime')); + $this->school = $this->fmtStaffTip(Lang::getMagicSchools($this->subject->getField('schoolMask')), Util::asHex($this->subject->getField('schoolMask'))); + $this->dispel = $this->subject->getField('dispelType') ? Lang::game('dt', $this->subject->getField('dispelType')) : null; + $this->mechanic = $this->subject->getField('mechanic') ? Lang::game('me', $this->subject->getField('mechanic')) : null; + $this->tooltip = array( + $this->subject->getField('iconString'), + $this->subject->getField('stackAmount') ?: ($this->subject->getField('procCharges') > 1 ? $this->subject->getField('procCharges') : 0), + $this->subject->getField('buff', true, true) ? 1 : 0 + ); + $this->gcdCat = match((int)$this->subject->getField('startRecoveryCategory')) + { + 133 => Lang::spell('normal'), + 330, // Mounts + 1156, // Heart of the Phoenix + 1159, // Ignis Grab and Slag Pot + 1164, // Kessel Run Elek + 1173, // Birmingham Test Spells + 1178, // Stealth (Druid Cat, Rogue, Hunter Cat Pets) + Charge (Warrior) + 1244 => Lang::spell('special'), // Argent Tournament Vehcile Jousting Abilities + default => '' // n/a + }; + + $this->range = $this->subject->getField('rangeMaxHostile'); + if ($_ = $this->subject->getField('rangeMinHostile')) + $this->range = $_.' - '.$this->range; + + if (!($this->subject->getField('attributes2') & SPELL_ATTR2_NOT_NEED_SHAPESHIFT)) + $this->stances = Lang::getStances($this->subject->getField('stanceMask')); + + if (($_ = $this->subject->getField('recoveryTime')) && $_ > 0) + $this->cooldown = DateTime::formatTimeElapsedFloat($_); + else if (($_ = $this->subject->getField('recoveryCategory')) && $_ > 0) + $this->cooldown = DateTime::formatTimeElapsedFloat($_); + + if (($_ = $this->subject->getField('duration')) && $_ > 0) + $this->duration = DateTime::formatTimeElapsedFloat($_); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + $ubSAI = SmartAI::getOwnerOfSpellCast($this->typeId); + + // tab: abilities [of shapeshift form] + $formSpells = []; + for ($i = 1; $i < 4; $i++) + if ($this->subject->getField('effect'.$i.'AuraId') == SPELL_AURA_MOD_SHAPESHIFT) + if ($_ = DB::Aowow()->selectRow('SELECT `spellId1`, `spellId2`, `spellId3`, `spellId4`, `spellId5`, `spellId6`, `spellId7`, `spellId8` FROM ::shapeshiftforms WHERE `id` = %i', $this->subject->getField('effect'.$i.'MiscValue'))) + $formSpells = array_merge($formSpells, $_); + + if ($formSpells) + { + $abilities = new SpellList(array(['id', $formSpells])); + if (!$abilities->error) + { + $tabData = array( + 'data' => $abilities->getListviewData(), + 'id' => 'controlledabilities', + 'name' => '$LANG.tab_controlledabilities', + 'visibleCols' => ['level'], + ); + + if (!$abilities->hasSetFields('skillLines')) + $tabData['hiddenCols'] = ['skill']; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + + $this->extendGlobalData($abilities->getJSGlobals(GLOBALINFO_SELF)); + } + } + + // tab: [$this] modifies + $sub = []; + $conditions = [ + ['s.typeCat', [-9], '!'], // GM (-9); also include uncategorized (0), NPC-Spell (-8)?; NPC includes totems, lightwell and others :/ + ['s.spellFamilyId', $this->subject->getField('spellFamilyId')], + &$sub + ]; + $modifiesData = []; + $hideSkillCol = true; + + for ($i = 1; $i < 4; $i++) + { + if (!in_array($this->subject->getField('effect'.$i.'AuraId'), self::MOD_AURAS)) + continue; + + $m1 = $this->subject->getField('effect'.$i.'SpellClassMaskA'); + $m2 = $this->subject->getField('effect'.$i.'SpellClassMaskB'); + $m3 = $this->subject->getField('effect'.$i.'SpellClassMaskC'); + + if (!$m1 && !$m2 && !$m3) + continue; + + $classSpells = $miscSpells = []; + $this->effects[$i]['modifies'] = [&$classSpells, &$miscSpells]; + + $sub = [DB::OR, ['s.spellFamilyFlags1', $m1, '&'], ['s.spellFamilyFlags2', $m2, '&'], ['s.spellFamilyFlags3', $m3, '&']]; + + $modSpells = new SpellList($conditions); + if (!$modSpells->error) + { + foreach ($modSpells->iterate() as $id => $__) + { + if (in_array($modSpells->getField('typeCat'), [-2, 7])) + $classSpells[$id] = [new IconElement(Type::SPELL, $id, $modSpells->getField('name', true), size: IconElement::SIZE_SMALL), []]; + else + $miscSpells[$id] = [new IconElement(Type::SPELL, $id, $modSpells->getField('name', true), size: IconElement::SIZE_SMALL), []]; + } + + if ($classSpells) + { + foreach (DB::World()->selectAssoc('SELECT `spell_id` AS ARRAY_KEY, `first_spell_id` AS "0", `rank` AS "1" FROM spell_ranks WHERE `spell_id` IN %in', array_keys($classSpells)) as $spellId => [$firstSpellId, $rank]) + { + $classSpells[$firstSpellId][1][0] = min($classSpells[$firstSpellId][1][0] ?? $rank, $rank); + $classSpells[$firstSpellId][1][1] = max($classSpells[$firstSpellId][1][1] ?? $rank, $rank); + + if ($spellId != $firstSpellId) + unset($classSpells[$spellId]); + } + + array_walk($classSpells, function(&$x) { + if ($x[1] && $x[1][0] == $x[1][1]) // only one rank => unset + $x[1] = null; + }); + } + + $modifiesData += $modSpells->getListviewData(); + if ($modSpells->hasSetFields('skillLines')) + $hideSkillCol = false; + + $this->extendGlobalData($modSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + + $classSpells = array_values($classSpells); + $miscSpells = array_values($miscSpells); + + unset($classSpells, $miscSpells); + } + + if ($modifiesData) + { + $tabData = array( + 'data' => $modifiesData, + 'id' => 'modifies', + 'name' => '$LANG.tab_modifies', + 'visibleCols' => ['level'], + ); + + if ($hideSkillCol) + $tabData['hiddenCols'] = ['skill']; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + + // tab: [$this is] modified by + $sub = [DB::OR]; + $conditions = [ + ['s.typeCat', [-9], '!'], // GM (-9); also include uncategorized (0), NPC-Spell (-8)?; NPC includes totems, lightwell and others :/ + ['s.spellFamilyId', $this->subject->getField('spellFamilyId')], + &$sub + ]; + + for ($i = 1; $i < 4; $i++) + { + $m1 = $this->subject->getField('spellFamilyFlags1'); + $m2 = $this->subject->getField('spellFamilyFlags2'); + $m3 = $this->subject->getField('spellFamilyFlags3'); + + if (!$m1 && !$m2 && !$m3) + continue; + + $sub[] = array( + DB::AND, + ['s.effect'.$i.'AuraId', self::MOD_AURAS], + [ + DB::OR, + ['s.effect'.$i.'SpellClassMaskA', $m1, '&'], + ['s.effect'.$i.'SpellClassMaskB', $m2, '&'], + ['s.effect'.$i.'SpellClassMaskC', $m3, '&'] + ] + ); + } + + if (count($sub) > 1) + { + $modsSpell = new SpellList($conditions); + if (!$modsSpell->error) + { + $tabData = array( + 'data' => $modsSpell->getListviewData(), + 'id' => 'modified-by', + 'name' => '$LANG.tab_modifiedby', + 'visibleCols' => ['level'], + ); + + if (!$modsSpell->hasSetFields('skillLines')) + $tabData['hiddenCols'] = ['skill']; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + + $this->extendGlobalData($modsSpell->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + } + + // tab: see also + $conditions = array( + ['s.schoolMask', $this->subject->getField('schoolMask')], + ['s.effect1Id', $this->subject->getField('effect1Id')], + ['s.effect2Id', $this->subject->getField('effect2Id')], + ['s.effect3Id', $this->subject->getField('effect3Id')], + ['s.id', $this->typeId, '!'], + ['s.name_loc'.Lang::getLocale()->value, $this->subject->getField('name', true)] + ); + + if ($this->difficulties) + $conditions = [DB::OR, [DB::AND, ...$conditions], [DB::AND, ['s.id', $this->difficulties], ['s.id', $this->typeId, '!']]]; + + $saSpells = new SpellList($conditions); + if (!$saSpells->error) + { + $data = $saSpells->getListviewData(); + if ($this->difficulties) + { + $saE = ['$Listview.extraCols.mode']; + + foreach ($data as $id => &$d) + { + if (($modeBit = array_search($id, $this->difficulties)) !== false) + { + if ($this->mapType) + $d['modes'] = ['mode' => 1 << ($modeBit + 3)]; + else + $d['modes'] = ['mode' => 2 - $modeBit]; + } + else + $d['modes'] = ['mode' => 0]; + } + } + + $tabData = array( + 'data' => $data, + 'id' => 'see-also', + 'name' => '$LANG.tab_seealso', + 'visibleCols' => ['level'], + ); + + if (!$saSpells->hasSetFields('skillLines')) + $tabData['hiddenCols'] = ['skill']; + + if (isset($saE)) + $tabData['extraCols'] = $saE; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + + $this->extendGlobalData($saSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + + // tab: shared cooldown + if ($this->subject->getField('recoveryCategory')) + { + $conditions = array( + ['id', $this->typeId, '!'], + ['category', $this->subject->getField('category')], + ['recoveryCategory', 0, '>'], + ); + + // limit shared cooldowns to same player class for regulat users + if (!User::isInGroup(U_GROUP_STAFF) && $this->subject->getField('spellFamilyId')) + $conditions[] = ['spellFamilyId', $this->subject->getField('spellFamilyId')]; + + $cdSpells = new SpellList($conditions); + if (!$cdSpells->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $cdSpells->getListviewData(), + 'name' => '$LANG.tab_sharedcooldown', + 'id' => 'shared-cooldown' + ), SpellList::$brickFile)); + + $this->extendGlobalData($cdSpells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + } + + // tab: glyphs + if ($gpIds = DB::Aowow()->selectCol('SELECT `id` FROM ::glyphproperties WHERE `spellId` = %i', $this->typeId)) + { + $conditions = array( + DB::OR, + [DB::AND, ['effect1Id', SPELL_EFFECT_APPLY_GLYPH], ['effect1MiscValue', $gpIds]], + [DB::AND, ['effect2Id', SPELL_EFFECT_APPLY_GLYPH], ['effect2MiscValue', $gpIds]], + [DB::AND, ['effect3Id', SPELL_EFFECT_APPLY_GLYPH], ['effect3MiscValue', $gpIds]] + ); + $glyphSpells = new SpellList($conditions); + if (!$glyphSpells->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $glyphSpells->getListviewData(), + 'visibleCols' => ['singleclass', 'glyphtype'], + 'id' => 'glyphs', + 'name' => '$LANG.tab_glyphs' + ), SpellList::$brickFile)); + + $this->extendGlobalData($glyphSpells->getJSGlobals(GLOBALINFO_SELF)); + } + } + + // tab: used by - spell + if ($so = DB::Aowow()->selectCell('SELECT `id` FROM ::spelloverride WHERE `spellId1` = %i OR `spellId2` = %i OR `spellId3` = %i OR `spellId4` = %i OR `spellId5` = %i', $this->typeId, $this->typeId, $this->typeId, $this->typeId, $this->typeId)) + { + $conditions = array( + DB::OR, + [DB::AND, ['effect1AuraId', SPELL_AURA_OVERRIDE_SPELLS], ['effect1MiscValue', $so]], + [DB::AND, ['effect2AuraId', SPELL_AURA_OVERRIDE_SPELLS], ['effect2MiscValue', $so]], + [DB::AND, ['effect3AuraId', SPELL_AURA_OVERRIDE_SPELLS], ['effect3MiscValue', $so]] + ); + $ubSpells = new SpellList($conditions); + if (!$ubSpells->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubSpells->getListviewData(), + 'id' => 'used-by-spell', + 'name' => '$LANG.tab_usedby' + ), SpellList::$brickFile)); + + $this->extendGlobalData($ubSpells->getJSGlobals(GLOBALINFO_SELF)); + } + } + + // tab: used by - itemset + $conditions = array( + DB::OR, + ['spell1', $this->typeId], ['spell2', $this->typeId], ['spell3', $this->typeId], ['spell4', $this->typeId], + ['spell5', $this->typeId], ['spell6', $this->typeId], ['spell7', $this->typeId], ['spell8', $this->typeId] + ); + + $ubSets = new ItemsetList($conditions); + if (!$ubSets->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubSets->getListviewData(), + 'id' => 'used-by-itemset', + 'name' => '$LANG.tab_usedby' + ), ItemsetList::$brickFile)); + + $this->extendGlobalData($ubSets->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + + // tab: used by - item + $conditions = array( + DB::OR, + [DB::AND, ['spellTrigger1', SPELL_TRIGGER_LEARN, '!'], ['spellId1', $this->typeId]], + [DB::AND, ['spellTrigger2', SPELL_TRIGGER_LEARN, '!'], ['spellId2', $this->typeId]], + [DB::AND, ['spellTrigger3', SPELL_TRIGGER_LEARN, '!'], ['spellId3', $this->typeId]], + [DB::AND, ['spellTrigger4', SPELL_TRIGGER_LEARN, '!'], ['spellId4', $this->typeId]], + [DB::AND, ['spellTrigger5', SPELL_TRIGGER_LEARN, '!'], ['spellId5', $this->typeId]] + ); + + $ubItems = new ItemList($conditions); + if (!$ubItems->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubItems->getListviewData(), + 'id' => 'used-by-item', + 'name' => '$LANG.tab_usedby' + ), ItemList::$brickFile)); + + $this->extendGlobalData($ubItems->getJSGlobals(GLOBALINFO_SELF)); + } + + // tab: used by - object + $conditions = array( + DB::OR, + ['onUseSpell', $this->typeId], ['onSuccessSpell', $this->typeId], + ['auraSpell', $this->typeId], ['triggeredSpell', $this->typeId] + ); + if (!empty($ubSAI[Type::OBJECT])) + $conditions[] = ['id', $ubSAI[Type::OBJECT]]; + + $ubObjects = new GameObjectList($conditions); + if (!$ubObjects->error) + { + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubObjects->getListviewData(), + 'id' => 'used-by-object', + 'name' => '$LANG.tab_usedby' + ), GameObjectList::$brickFile)); + + $this->extendGlobalData($ubObjects->getJSGlobals()); + } + + // tab: used by - areatrigger + if (User::isInGroup(U_GROUP_EMPLOYEE)) + { + if (!empty($ubSAI[Type::AREATRIGGER])) + { + $ubTriggers = new AreaTriggerList(array(['id', $ubSAI[Type::AREATRIGGER]])); + if (!$ubTriggers->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubTriggers->getListviewData(), + 'id' => 'used-by-areatrigger', + 'name' => '$LANG.tab_usedby' + ), AreaTriggerList::$brickFile, 'areatrigger')); + } + } + } + + // tab: criteria of + $conditions = array( + DB::AND, + ['ac.type', [ACHIEVEMENT_CRITERIA_TYPE_BE_SPELL_TARGET, ACHIEVEMENT_CRITERIA_TYPE_BE_SPELL_TARGET2, ACHIEVEMENT_CRITERIA_TYPE_CAST_SPELL, + ACHIEVEMENT_CRITERIA_TYPE_CAST_SPELL2, ACHIEVEMENT_CRITERIA_TYPE_LEARN_SPELL] + ], + ['ac.value1', $this->typeId] + ); + + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` IN %in AND `value1` = %i', [ACHIEVEMENT_CRITERIA_DATA_TYPE_S_AURA, ACHIEVEMENT_CRITERIA_DATA_TYPE_T_AURA], $this->typeId)) + $conditions = [DB::OR, $conditions, ['ac.id', $extraCrt]]; + + $coAchievemnts = new AchievementList($conditions); + if (!$coAchievemnts->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $coAchievemnts->getListviewData(), + 'id' => 'criteria-of', + 'name' => '$LANG.tab_criteriaof' + ), AchievementList::$brickFile)); + + $this->extendGlobalData($coAchievemnts->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + + // tab: contains + // spell_loot_template + $spellLoot = new LootByContainer(); + if ($spellLoot->getByContainer(Loot::SPELL, [$this->typeId])) + { + $this->extendGlobalData($spellLoot->jsGlobals); + + $extraCols = $spellLoot->extraCols; + $extraCols[] = '$Listview.extraCols.percent'; + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $spellLoot->getResult(), + 'name' => '$LANG.tab_contains', + 'id' => 'contains', + 'hiddenCols' => ['side', 'slot', 'source', 'reqlevel'], + 'extraCols' => array_unique($extraCols), + 'computeDataFunc' => '$Listview.funcBox.initLootTable' + ), ItemList::$brickFile)); + } + + // tab: bonus loot + if ($extraItemData = DB::World()->selectAssoc('SELECT `spellId` AS ARRAY_KEY, `additionalCreateChance` AS "0", `additionalMaxNum` AS "1" FROM skill_extra_item_template WHERE `requiredSpecialization` = %i', $this->typeId)) + { + $extraSpells = new SpellList(array(['id', array_keys($extraItemData)])); + if (!$extraSpells->error) + { + $this->extendGlobalData($extraSpells->getJSGlobals(GLOBALINFO_RELATED)); + $lvItems = $extraSpells->getListviewData(); + + foreach ($lvItems as $iId => $data) + { + [$chance, $maxItr] = $extraItemData[$iId]; + + $lvItems[$iId]['count'] = 1; // expected by js or the pct-col becomes unsortable + $lvItems[$iId]['percent'] = $chance; + $lvItems[$iId]['pctstack'] = $this->buildPctStack($chance / 100, $maxItr, $data['creates'][1]); + $lvItems[$iId]['creates'][2] *= $maxItr; + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lvItems, + 'name' => '$LANG.tab_bonusloot', + 'id' => 'bonusloot', + 'hiddenCols' => ['side', 'reqlevel'], + 'extraCols' => ['$Listview.extraCols.percent'] + ), SpellList::$brickFile)); + } + } + + // tab: exclusive with + if ($this->firstRank && DB::World()->selectCell('SELECT 1 FROM spell_group WHERE `spell_id` = %i', $this->firstRank)) + { + $groups = DB::World()->selectCol('SELECT `id` AS ARRAY_KEY, `spell_id` AS ARRAY_KEY2, `spell_id` FROM spell_group'); + // unpack recursion + foreach ($groups as $i => $group) + { + foreach ($group as $j => $g) + { + if ($g > 0) + continue; + + foreach ($groups[-$g] ?? [] as $new) + $groups[$i][] = $new; + + unset($group[$j]); + } + } + + // find ourselves + if ($filtered = array_filter($groups, fn($x) => in_array($this->firstRank, $x))) + { + // get rule set + $rules = DB::World()->selectCol('SELECT `group_id` AS ARRAY_KEY, `stack_rule` FROM spell_group_stack_rules WHERE `group_id` IN %in', array_keys($filtered)); + + // only use groups that have rules set + if ($filtered = array_intersect_key($filtered, $rules)) + { + $cnd = [DB::OR]; + foreach ($filtered as $gr) + $cnd[] = ['s.id', $gr]; + + $stacks = new SpellList($cnd); + if (!$stacks->error) + { + $lvData = $stacks->getListviewData(); + $this->extendGlobalData($stacks->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + if (!$stacks->hasSetFields('skillLines')) + $sH = ['skill']; + + foreach ($filtered as $gId => $spellIds) + { + $data = []; + foreach ($spellIds as $id) + if (isset($lvData[$id]) && $id != $this->firstRank) + $data[] = array_merge($lvData[$id], ['stackRule' => $rules[$gId]]); + + if (!$data) + continue; + + $tabData = array( + 'data' => $data, + 'id' => 'spell-group-stack-'.$gId, + 'name' => Lang::spell('stackGroup'), + 'visibleCols' => ['stackRules'] + ); + + if (isset($sH)) + $tabData['hiddenCols'] = $sH; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + } + } + } + } + + // tab: linked with + $rows = DB::World()->selectAssoc( + 'SELECT `spell_trigger` AS "trigger", `spell_effect` AS "effect", `type`, IF(ABS(`spell_effect`) = %i, ABS(`spell_trigger`), ABS(`spell_effect`)) AS "related" + FROM spell_linked_spell + WHERE ABS(`spell_effect`) = %i OR ABS(`spell_trigger`) = %i', + $this->typeId, $this->typeId, $this->typeId + ); + + $related = []; + foreach ($rows as $row) + $related[] = $row['related']; + + if ($related) + $linked = new SpellList(array(['s.id', $related])); + + if (isset($linked) && !$linked->error) + { + $lv = $linked->getListviewData(); + $data = []; + + foreach ($rows as $r) + { + foreach ($lv as $dk => $d) + { + if ($r['related'] != $dk) + continue; + + $lv[$dk]['linked'] = [$r['trigger'], $r['effect'], $r['type']]; + $data[] = $lv[$dk]; + break; + } + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $data, + 'id' => 'spell-link', + 'name' => Lang::spell('linkedWith'), + 'hiddenCols' => ['skill', 'name'], + 'visibleCols' => ['linkedTrigger', 'linkedEffect'] + ), SpellList::$brickFile)); + + $this->extendGlobalData($linked->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + + + // tab: triggered by + $conditions = array( + DB::OR, + [DB::AND, [DB::OR, ['effect1Id', SpellList::EFFECTS_TRIGGER], ['effect1AuraId', SpellList::AURAS_TRIGGER]], ['effect1TriggerSpell', $this->typeId]], + [DB::AND, [DB::OR, ['effect2Id', SpellList::EFFECTS_TRIGGER], ['effect2AuraId', SpellList::AURAS_TRIGGER]], ['effect2TriggerSpell', $this->typeId]], + [DB::AND, [DB::OR, ['effect3Id', SpellList::EFFECTS_TRIGGER], ['effect3AuraId', SpellList::AURAS_TRIGGER]], ['effect3TriggerSpell', $this->typeId]], + ); + + $trigger = new SpellList($conditions); + if (!$trigger->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $trigger->getListviewData(), + 'id' => 'triggered-by', + 'name' => '$LANG.tab_triggeredby' + ), SpellList::$brickFile)); + + $this->extendGlobalData($trigger->getJSGlobals(GLOBALINFO_SELF)); + } + + // tab: used by - creature + $conditions = array( + DB::OR, + ['spell1', $this->typeId], ['spell2', $this->typeId], ['spell3', $this->typeId], ['spell4', $this->typeId], + ['spell5', $this->typeId], ['spell6', $this->typeId], ['spell7', $this->typeId], ['spell8', $this->typeId] + ); + if (!empty($ubSAI[Type::NPC])) + $conditions[] = ['id', $ubSAI[Type::NPC]]; + + if ($auras = DB::World()->selectCol('SELECT `entry` FROM creature_template_addon WHERE `auras` REGEXP %s', '\\b'.$this->typeId.'\\b')) + $conditions[] = ['id', $auras]; + + if ($spellClick = DB::World()->selectCol('SELECT `npc_entry` FROM npc_spellclick_spells WHERE `spell_id` = %i', $this->typeId)) + $conditions[] = ['id', $spellClick]; + + $ubCreature = new CreatureList($conditions); + if (!$ubCreature->error) + { + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $ubCreature->getListviewData(), + 'id' => 'used-by-npc', + 'name' => '$LANG.tab_usedby' + ), CreatureList::$brickFile)); + + $this->extendGlobalData($ubCreature->getJSGlobals(GLOBALINFO_SELF)); + } + + // tab: zone + if ($areaSpells = DB::World()->selectAssoc('SELECT `area` AS ARRAY_KEY, `aura_spell` AS "0", `quest_start` AS "1", `quest_end` AS "2", `quest_start_status` AS "3", `quest_end_status` AS "4", `racemask` AS "5", `gender` AS "6" FROM spell_area WHERE `spell` = %i', $this->typeId)) + { + $zones = new ZoneList(array(['id', array_keys($areaSpells)])); + if (!$zones->error) + { + $lvZones = $zones->getListviewData(); + $this->extendGlobalData($zones->getJSGlobals()); + + $resultLv = []; + $parents = []; + $extraCols = []; + foreach ($areaSpells as $areaId => $condition) + { + if (empty($lvZones[$areaId])) + continue; + + $row = $lvZones[$areaId]; + + // attach to lv row and evaluate after merging + $row['__condition'] = $condition; + + // merge subzones, into one row, if: spell_area data is identical && parentZone is shared + if ($p = $zones->getEntry($areaId)['parentArea']) + { + $parents[] = $p; + $row['__parent'] = $p; + $row['subzones'] = [$areaId]; + } + else + $row['__parent'] = 0; + + $set = false; + foreach ($resultLv as &$v) + { + if ($v['__parent'] != $row['__parent'] && $v['id'] != $row['__parent']) + continue; + + if ($v['__condition'] != $row['__condition']) + continue; + + if (!$row['__parent'] && $v['id'] != $row['__parent']) + continue; + + $set = true; + $v['subzones'][] = $row['id']; + break; + } + + // add self as potential subzone; IF we are a parentZone without added children, we get filtered in JScript + if (!$set) + { + $row['subzones'] = [$row['id']]; + $resultLv[] = $row; + } + } + + // overwrite lvData with parent-lvData (condition and subzones are kept) + if ($parents) + { + $parents = (new ZoneList(array(['id', $parents])))->getListviewData(); + foreach ($resultLv as &$_) + if (isset($parents[$_['__parent']])) + $_ = array_merge($_, $parents[$_['__parent']]); + } + + $cnd = new Conditions(); + foreach ($resultLv as $idx => $lv) + { + [$auraSpell, $questStart, $questEnd, $questStartState, $questEndState, $raceMask, $gender] = $lv['__condition']; + + if ($auraSpell) + $cnd->addExternalCondition(Conditions::SRC_NONE, $lv['id'], [$auraSpell > 0 ? Conditions::AURA : -Conditions::AURA, abs($auraSpell)]); + + if ($questStart) + $cnd->addExternalCondition(Conditions::SRC_NONE, $lv['id'], [Conditions::QUESTSTATE, $questStart, $questStartState]); + + if ($questEnd && $questEnd != $questStart) + $cnd->addExternalCondition(Conditions::SRC_NONE, $lv['id'], [Conditions::QUESTSTATE, $questEnd, $questEndState]); + + if ($raceMask) + $cnd->addExternalCondition(Conditions::SRC_NONE, $lv['id'], [Conditions::CHR_RACE, $raceMask]); + + if ($gender != 2) // 2: both + $cnd->addExternalCondition(Conditions::SRC_NONE, $lv['id'], [Conditions::GENDER, $gender]); + + // remove temp storage from result + unset($resultLv[$idx]['__condition']); + unset($resultLv[$idx]['__parent']); + } + + if ($cnd->toListviewColumn($resultLv, $extraCols)) + $this->extendGlobalData($cnd->getJsGlobals()); + + $tabData = ['data' => $resultLv]; + + if ($extraCols) + { + $tabData['extraCols'] = $extraCols; + $tabData['hiddenCols'] = ['instancetype']; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, ZoneList::$brickFile)); + } + } + + // tab: teaches + if ($ids = Game::getTaughtSpells($this->subject)) + { + $teaches = new SpellList(array(['id', $ids])); + if (!$teaches->error) + { + $this->extendGlobalData($teaches->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + $vis = ['level', 'schools']; + + foreach ($teaches->iterate() as $__) + { + if (!$teaches->canCreateItem()) + continue; + + $vis[] = 'reagents'; + break; + } + + $tabData = array( + 'data' => $teaches->getListviewData(), + 'id' => 'teaches-spell', + 'name' => '$LANG.tab_teaches', + 'visibleCols' => $vis, + ); + + if (!$teaches->hasSetFields('skillLines')) + $tabData['hiddenCols'] = ['skill']; + + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + } + } + + // tab: taught by npc + if ($this->subject->getRawSource(SRC_TRAINER)) + { + $trainers = DB::World()->selectAssoc( + 'SELECT cdt.`CreatureId` AS ARRAY_KEY, ts.`ReqSkillLine` AS "reqSkillId", ts.`ReqSkillRank` AS "reqSkillValue", ts.`ReqLevel` AS "reqLevel", ts.`ReqAbility1` AS "reqSpellId1", ts.`reqAbility2` AS "reqSpellId2" + FROM creature_default_trainer cdt + JOIN trainer_spell ts ON ts.`TrainerId` = cdt.`TrainerId` + WHERE ts.`SpellId` = %i', + $this->typeId + ); + + if ($trainers) + { + $tbTrainer = new CreatureList(array(['ct.id', array_keys($trainers)], ['s.guid', null, '!'], ['ct.npcflag', NPC_FLAG_TRAINER, '&'])); + if (!$tbTrainer->error) + { + $this->extendGlobalData($tbTrainer->getJSGlobals()); + + $cnd = new Conditions(); + $skill = $this->subject->getField('skillLines'); + + foreach ($trainers as $tId => $train) + { + if ($_ = $train['reqLevel']) + $cnd->addExternalCondition(Conditions::SRC_NONE, $tId, [Conditions::LEVEL, $_, Conditions::OP_GT_E]); + + if ($_ = $train['reqSkillId']) + if (count($skill) == 1 && $_ != $skill[0]) + $cnd->addExternalCondition(Conditions::SRC_NONE, $tId, [Conditions::SKILL, $_, $train['reqSkillValue']]); + + for ($i = 1; $i < 3; $i++) + if ($_ = $train['reqSpellId'.$i]) + $cnd->addExternalCondition(Conditions::SRC_NONE, $tId, [Conditions::SPELL, $_]); + } + + $lvData = $tbTrainer->getListviewData(); + $extraCols = []; + if ($cnd->toListviewColumn($lvData, $extraCols)) + $this->extendGlobalData($cnd->getJsGlobals()); + + $tabData = array( + 'data' => $lvData, + 'id' => 'taught-by-npc', + 'name' => '$LANG.tab_taughtby', + ); + + if ($extraCols) + $tabData['extraCols'] = $extraCols; + + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile)); + } + } + } + + // tab: taught by spell + $conditions = array( + DB::OR, + [DB::AND, ['effect1Id', SpellList::EFFECTS_TEACH], ['effect1TriggerSpell', $this->typeId]], + [DB::AND, ['effect2Id', SpellList::EFFECTS_TEACH], ['effect2TriggerSpell', $this->typeId]], + [DB::AND, ['effect3Id', SpellList::EFFECTS_TEACH], ['effect3TriggerSpell', $this->typeId]], + ); + + $tbSpell = new SpellList($conditions); + $tbsData = []; + if (!$tbSpell->error) + { + $tbsData = $tbSpell->getFoundIDs(); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $tbSpell->getListviewData(), + 'id' => 'taught-by-spell', + 'name' => '$LANG.tab_taughtby' + ), SpellList::$brickFile)); + + $this->extendGlobalData($tbSpell->getJSGlobals(GLOBALINFO_SELF)); + } + + // tab: taught by quest + $conditions = array( + DB::OR, + ['sourceSpellId', $this->typeId], + ['rewardSpell', $this->typeId], + ['rewardSpellCast', $this->typeId] + ); + if ($tbsData) + array_push($conditions, ['rewardSpell', $tbsData], ['rewardSpellCast', $tbsData]); + + $tbQuest = new QuestList($conditions); + if (!$tbQuest->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $tbQuest->getListviewData(), + 'id' => 'reward-from-quest', + 'name' => '$LANG.tab_rewardfrom' + ), QuestList::$brickFile)); + + $this->extendGlobalData($tbQuest->getJSGlobals()); + } + + // tab: taught by item (i'd like to precheck $this->subject->sources, but there is no source:item only complicated crap like "drop" and "vendor") + $conditions = array( + DB::OR, + [DB::AND, ['spellTrigger1', SPELL_TRIGGER_LEARN], ['spellId1', $this->typeId]], + [DB::AND, ['spellTrigger2', SPELL_TRIGGER_LEARN], ['spellId2', $this->typeId]], + [DB::AND, ['spellTrigger3', SPELL_TRIGGER_LEARN], ['spellId3', $this->typeId]], + [DB::AND, ['spellTrigger4', SPELL_TRIGGER_LEARN], ['spellId4', $this->typeId]], + [DB::AND, ['spellTrigger5', SPELL_TRIGGER_LEARN], ['spellId5', $this->typeId]], + ); + + $tbItem = new ItemList($conditions); + if (!$tbItem->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $tbItem->getListviewData(), + 'id' => 'taught-by-item', + 'name' => '$LANG.tab_taughtby' + ), ItemList::$brickFile)); + + $this->extendGlobalData($tbItem->getJSGlobals(GLOBALINFO_SELF)); + } + + // tab: enchantments + $conditions = array( + DB::OR, + [DB::AND, ['type1', [ENCHANTMENT_TYPE_COMBAT_SPELL, ENCHANTMENT_TYPE_EQUIP_SPELL, ENCHANTMENT_TYPE_USE_SPELL]], ['object1', $this->typeId]], + [DB::AND, ['type2', [ENCHANTMENT_TYPE_COMBAT_SPELL, ENCHANTMENT_TYPE_EQUIP_SPELL, ENCHANTMENT_TYPE_USE_SPELL]], ['object2', $this->typeId]], + [DB::AND, ['type3', [ENCHANTMENT_TYPE_COMBAT_SPELL, ENCHANTMENT_TYPE_EQUIP_SPELL, ENCHANTMENT_TYPE_USE_SPELL]], ['object3', $this->typeId]] + ); + $enchList = new EnchantmentList($conditions); + if (!$enchList->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $enchList->getListviewData(), + 'name' => Util::ucFirst(Lang::game('enchantments')) + ), EnchantmentList::$brickFile, 'enchantment')); + + $this->extendGlobalData($enchList->getJSGlobals()); + } + + // tab: sounds + $data = []; + $seSounds = []; + for ($i = 1; $i < 4; $i++) // sounds from screen effect + if ($this->subject->getField('effect'.$i.'AuraId') == SPELL_AURA_SCREEN_EFFECT) + $seSounds = DB::Aowow()->selectRow('SELECT `ambienceDay`, `ambienceNight`, `musicDay`, `musicNight` FROM ::screeneffect_sounds WHERE `id` = %i', $this->subject->getField('effect'.$i.'MiscValue')); + + $activitySounds = DB::Aowow()->selectRow('SELECT * FROM ::spell_sounds WHERE `id` = %i', $this->subject->getField('spellVisualId')); + array_shift($activitySounds); // remove id-column + if ($soundIDs = $activitySounds + $seSounds) + { + $sounds = new SoundList(array(['id', $soundIDs])); + if (!$sounds->error) + { + $data = $sounds->getListviewData(); + foreach ($activitySounds as $activity => $id) + if (isset($data[$id])) + $data[$id]['activity'] = $activity; // no index, js wants a string :( + + $tabData = ['data' => $data]; + if ($activitySounds) + $tabData['visibleCols'] = ['activity']; + + $this->extendGlobalData($sounds->getJSGlobals(GLOBALINFO_SELF)); + $this->lvTabs->addListviewTab(new Listview($tabData, SoundList::$brickFile)); + } + } + + // tab: unlocks (object or item) + $lockIds = DB::Aowow()->selectCol( + 'SELECT `id` FROM ::lock WHERE (`type1` = %i AND `properties1` = %i) OR + (`type2` = %i AND `properties2` = %i) OR (`type3` = %i AND `properties3` = %i) OR + (`type4` = %i AND `properties4` = %i) OR (`type5` = %i AND `properties5` = %i)', + LOCK_TYPE_SPELL, $this->typeId, LOCK_TYPE_SPELL, $this->typeId, + LOCK_TYPE_SPELL, $this->typeId, LOCK_TYPE_SPELL, $this->typeId, + LOCK_TYPE_SPELL, $this->typeId + ); + + // we know this spell effect is only in use on index 1 + if ($this->subject->getField('effect1Id') == SPELL_EFFECT_OPEN_LOCK && ($lockId = $this->subject->getField('effect1MiscValue'))) + $lockIds += DB::Aowow()->selectCol( + 'SELECT `id` FROM ::lock WHERE (`type1` = %i AND `properties1` = %i) OR + (`type2` = %i AND `properties2` = %i) OR (`type3` = %i AND `properties3` = %i) OR + (`type4` = %i AND `properties4` = %i) OR (`type5` = %i AND `properties5` = %i)', + LOCK_TYPE_SKILL, $lockId, LOCK_TYPE_SKILL, $lockId, + LOCK_TYPE_SKILL, $lockId, LOCK_TYPE_SKILL, $lockId, + LOCK_TYPE_SKILL, $lockId + ); + + if ($lockIds) + { + // objects + $lockedObj = new GameObjectList(array(['lockId', $lockIds])); + if (!$lockedObj->error) + { + $this->addDataLoader('zones'); + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lockedObj->getListviewData(), + 'name' => '$LANG.tab_unlocks', + 'id' => 'unlocks-object', + 'visibleCols' => $lockedObj->hasSetFields('reqSkill') ? ['skill'] : null + ), GameObjectList::$brickFile)); + } + + $lockedItm = new ItemList(array(['lockId', $lockIds])); + if (!$lockedItm->error) + { + $this->extendGlobalData($lockedItm->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lockedItm->getListviewData(), + 'name' => '$LANG.tab_unlocks', + 'id' => 'unlocks-item' + ), ItemList::$brickFile)); + } + } + + // find associated NPC, Item and merge results + // taughtbypets (unused..?) + // taughtbyquest (usually the spell casted as quest reward teaches something; exclude those seplls from taughtBySpell) + // taughtbytrainers + // taughtbyitem + + // tab: conditions + $cnd = new Conditions(); + $cnd->getBySource([Conditions::SRC_SPELL_IMPLICIT_TARGET, Conditions::SRC_SPELL, Conditions::SRC_SPELL_CLICK_EVENT, Conditions::SRC_VEHICLE_SPELL, Conditions::SRC_SPELL_PROC], entry: $this->typeId) + ->getByCondition(Type::SPELL, $this->typeId) + ->prepare(); + if ($tab = $cnd->toListviewTab()) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } + + + /******************************************/ + /* SpellLoot recursive dropchance builder */ + /******************************************/ + + private function buildPctStack(float $baseChance, int $maxStack, int $baseCount = 1) : string + { + // note: pctStack does not contain absolute values but chances relative to the overall drop chance + // e.g.: dropChance is 17% then [1 => 50, 2 => 25, 3 => 25] displays > Stack of 1: 8%; Stack of 2: 4%; Stack of 3: 4% + $maxStack = $maxStack ?: 1; + $pctStack = []; + for ($i = 1; $i <= $maxStack; $i++) + { + $pctStack[$i] = (($baseChance ** $i) * 100) / $baseChance; + + // remove chance from previous stacks + if ($i > 1) + $pctStack[$i-1] -= $pctStack[$i]; + } + + // cleanup rounding errors + $pctStack = array_map(fn($x) => round($x, 3), $pctStack); + + // cleanup tiny fractions + $pctStack = array_filter($pctStack, fn($x) => ($x * $baseChance) >= 0.01); + + if ($baseCount > 1) + $pctStack = array_combine(array_map(fn($x) => $x * $baseCount, array_keys($pctStack)), $pctStack); + + return json_encode($pctStack, JSON_NUMERIC_CHECK); // do not replace with Util::toJSON ! + } + + + /**********************************/ + /* recursive reagent list builder */ + /**********************************/ + + private function appendReagentItem(array &$reagentResult, int $itemId, int $qty, int $mult, int $level, string $path, array $alreadyUsed, int $fromSpell = 0) : bool + { + if (in_array($itemId, $alreadyUsed)) + return false; + + $item = DB::Aowow()->selectRow( + 'SELECT `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8`, i.`id`, ic.`name` AS `iconString`, `quality`, `spellId1`, `spellCharges1` + FROM ::items i + LEFT JOIN ::icons ic ON ic.`id` = i.`iconId` + WHERE i.`id` = %i', + $itemId + ); + + if (!$item) + return false; + + $this->extendGlobalIds(Type::ITEM, $item['id']); + + // the spell calling this is also on the item and triggering it destroys the item + // so effectively we need one more. (see elemental particles and enchantment essences) + if ($fromSpell && $fromSpell == $item['spellId1'] && $item['spellCharges1'] == -1) + $qty++; + + $level++; + + $data = array( + 'path' => $path.'.'.Type::ITEM.'-'.$item['id'], + 'level' => $level, + 'final' => false, + 'typeStr' => Type::getFileString(Type::ITEM), + 'icon' => new IconElement(Type::ITEM, $item['id'], Util::localizedString($item, 'name'), $qty * $mult, '', $item['quality'], IconElement::SIZE_SMALL, align: 'right', element: 'iconlist-icon') + ); + + $idx = count($reagentResult); + $reagentResult[] = $data; + $alreadyUsed[] = $item['id']; + + if (!$this->appendReagentSpell($reagentResult, $item['id'], $qty * $mult, $data['level'], $data['path'], $alreadyUsed)) + $reagentResult[$idx]['final'] = true; + + return true; + } + + private function appendReagentSpell(array &$reagentResult, int $itemId, int $qty, int $level, string $path, array $alreadyUsed) : bool + { + $level++; + // assume that tradeSpells only use the first index to create items, so this runs somewhat efficiently >.< + $spells = DB::Aowow()->selectAssoc( + 'SELECT `reagent1`, `reagent2`, `reagent3`, `reagent4`, `reagent5`, `reagent6`, `reagent7`, `reagent8`, + `reagentCount1`, `reagentCount2`, `reagentCount3`, `reagentCount4`, `reagentCount5`, `reagentCount6`, `reagentCount7`, `reagentCount8`, + `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8`, + `iconIdBak`, + s.`id` AS ARRAY_KEY, ic.`name` AS `iconString` + FROM ::spell s + JOIN ::icons ic ON s.`iconId` = ic.`id` + WHERE (s.`cuFlags` & %i) = 0 AND + (`effect1CreateItemId` = %i AND `effect1Id` = %i)',// OR + // (`effect2CreateItemId` = %i AND `effect2Id` = %i) OR + // (`effect3CreateItemId` = %i AND `effect3Id` = %i)', + CUSTOM_UNAVAILABLE, $itemId, SPELL_EFFECT_CREATE_ITEM //, $itemId, SPELL_EFFECT_CREATE_ITEM, $itemId, SPELL_EFFECT_CREATE_ITEM + ); + + if (!$spells) + return false; + + $didAppendSomething = false; + foreach ($spells as $sId => $row) + { + if (in_array(-$sId, $alreadyUsed)) + continue; + + $this->extendGlobalIds(Type::SPELL, $sId); + + $data = array( + 'path' => $path.'.'.Type::SPELL.'-'.$sId, + 'level' => $level, + 'final' => false, + 'typeStr' => Type::getFileString(Type::SPELL), + 'icon' => new IconElement(Type::SPELL, $sId, Util::localizedString($row, 'name'), $qty, size: IconElement::SIZE_SMALL, align: 'right', element: 'iconlist-icon') + ); + + $reagentResult[] = $data; + $_aU = $alreadyUsed; + $_aU[] = -$sId; + + $hasUnusedReagents = false; + for ($i = 1; $i < 9; $i++) + { + if ($row['reagent'.$i] <= 0 || $row['reagentCount'.$i] <= 0) + continue; + + if ($this->appendReagentItem($reagentResult, $row['reagent'.$i], $row['reagentCount'.$i], $qty, $data['level'], $data['path'], $_aU, $sId)) + { + $hasUnusedReagents = true; + $didAppendSomething = true; + } + } + + if (!$hasUnusedReagents) // no reagents were added, remove spell from result set + array_pop($reagentResult); + } + + return $didAppendSomething; + } + + private function createReagentList() : void + { + $reagentResult = []; + $enhanced = false; + $reagents = $this->subject->getReagentsForCurrent(); + + if (!$reagents) + return; + + foreach ($reagents as [$iId, $num]) + { + $relItem = $this->subject->relItems->getEntry($iId); + + $data = array( + 'path' => Type::ITEM.'-'.$iId, // id of the html-element + 'level' => 0, // depths in array, used for indentation + 'final' => false, + 'typeStr' => Type::getFileString(Type::ITEM), + 'icon' => new IconElement( + Type::ITEM, + is_null($relItem) ? 0 : $iId, + is_null($relItem) ? 'Item #'.$iId : $this->subject->relItems->getField('name', true), + $num, + quality: $relItem['quality'] ?? 'q', + size: IconElement::SIZE_SMALL, + link: !is_null($relItem), + align: 'right', + element: 'iconlist-icon' + ) + ); + + $idx = count($reagentResult); + $reagentResult[] = $data; + + // start with self and current original item in usedEntries (spell < 0; item > 0) + if ($this->appendReagentSpell($reagentResult, $iId, $reagents[$iId][1], 0, $data['path'], [-$this->typeId, $iId])) + $enhanced = true; + else + $reagentResult[$idx]['final'] = true; + } + + // increment all indizes (by prepending null and removing it again) + array_unshift($reagentResult, null); + unset($reagentResult[0]); + + $this->reagents = [$enhanced, $reagentResult]; + } + + private function calculateEffectScaling() : array // calculation mostly like seen in TC + { + if ($this->subject->getField('attributes3') & SPELL_ATTR3_NO_DONE_BONUS) + return [0, 0, 0, 0]; + + if (!$this->subject->isScalableDamagingSpell() && !$this->subject->isScalableHealingSpell()) + return [0, 0, 0, 0]; + + $scaling = [0, 0, 0, 0]; + $pMask = $this->subject->periodicEffectsMask(); + $allDoTs = true; + + for ($i = 1; $i < 4; $i++) + { + if (!$this->subject->getField('effect'.$i.'Id')) + continue; + + if ($pMask & 1 << ($i - 1)) + { + $scaling[1] = $this->subject->getField('effect'.$i.'BonusMultiplier'); + continue; + } + else if ($this->subject->getField('damageClass') == SPELL_DAMAGE_CLASS_MAGIC) + $scaling[0] = $this->subject->getField('effect'.$i.'BonusMultiplier'); + + $allDoTs = false; + } + + if ($s = DB::World()->selectRow('SELECT `direct_bonus` AS "0", `dot_bonus` AS "1", `ap_bonus` AS "2", `ap_dot_bonus` AS "3" FROM spell_bonus_data WHERE `entry` = %i', $this->firstRank)) + $scaling = $s; + + if (!in_array($this->subject->getField('typeCat'), [-2, -3, -7, 7]) || $this->subject->getField('damageClass') == SPELL_DAMAGE_CLASS_NONE) + return array_map(fn($x) => $x < 0 ? 0 : $x, $scaling); + + foreach ($scaling as $k => $v) + { + // recalculate if spell_bonus_data says so + if ($v != -1) + continue; + + // no known calculation for physical abilities + if (in_array($k, [2, 3])) // [direct AP, DoT AP] + continue; + + // dont use spellPower to scale physical Abilities + if ($this->subject->getField('schoolMask') == (1 << SPELL_SCHOOL_NORMAL) && in_array($k, [0, 1])) + continue; + + $isDOT = false; + if (in_array($k, [1, 3])) // [DoT SP, DoT AP] + { + if ($pMask) + $isDOT = true; + else + continue; + } + else if ($allDoTs) // if all used effects are periodic, dont calculate direct component + continue; + + // damage over time spells bonus calculation + $dotFactor = 1.0; + if ($isDOT) + { + $dotDuration = $this->subject->getField('duration'); + // 200% limit + if ($dotDuration > 0) + { + if ($dotDuration > 30000) + $dotDuration = 30000; + if (!$this->subject->isChanneledSpell()) + $dotFactor = $dotDuration / 15000; + } + } + + // distribute damage over multiple effects, reduce by AoE + $castingTime = $this->subject->getCastingTimeForBonus($isDOT); + + // 50% for damage and healing spells for leech spells from damage bonus and 0% from healing + for ($j = 1; $j < 4; ++$j) + { + if ($this->subject->getField('effectId'.$j) == SPELL_EFFECT_HEALTH_LEECH || $this->subject->getField('effect'.$j.'AuraId') == SPELL_AURA_PERIODIC_LEECH) + { + $castingTime /= 2; + break; + } + } + + if ($this->subject->isScalableHealingSpell()) + $castingTime *= 1.88; + + // SPELL_SCHOOL_MASK_NORMAL + if ($this->subject->getField('schoolMask') != (1 << SPELL_SCHOOL_NORMAL)) + $scaling[$k] = ($castingTime / 3500.0) * $dotFactor; + else + $scaling[$k] = 0; // would be 1 ($dotFactor), but we dont want it to be displayed + } + + return array_map(fn($x) => $x < 0 ? 0 : $x, $scaling); + } + + private function createRequiredItems() : void + { + // parse itemClass & itemSubClassMask + $class = $this->subject->getField('equippedItemClass'); + $subClass = $this->subject->getField('equippedItemSubClassMask'); + $invType = $this->subject->getField('equippedItemInventoryTypeMask'); + + if ($class <= 0) + return; + + $tip = 'Class: '.$class.'
SubClass: '.Util::asHex($subClass); + $text = Lang::getRequiredItems($class, $subClass, false); + + if ($invType) + { + // remap some duplicated strings 'Off Hand' and 'Shield' are never used simultaneously + if ($invType & (1 << INVTYPE_ROBE)) // Robe => Chest + { + $invType &= ~(1 << INVTYPE_ROBE); + $invType |= (1 << INVTYPE_CHEST); + } + + if ($invType & (1 << INVTYPE_RANGEDRIGHT)) // Ranged2 => Ranged + { + $invType &= ~(1 << INVTYPE_RANGEDRIGHT); + $invType |= (1 << INVTYPE_RANGED); + } + + $_ = []; + $strs = Lang::item('inventoryType'); + foreach ($strs as $k => $str) + if ($invType & 1 << $k && $str) + $_[] = $str; + + $tip .= '
'.Lang::item('slot').Lang::main('colon').Util::asHex($invType); + $text .= ' '.Lang::spell('_inSlot').implode(', ', $_); + } + + $this->items = $this->fmtStaffTip($text, $tip); + } + + private function createEffects() : void + { + // proc data .. maybe use more information..? + $procData = array( + 'chance' => $this->subject->getField('procChance'), + 'cooldown' => 0 + ); + + if ($sp = DB::World()->selectRow('SELECT IF(`ProcsPerMinute` > 0, -`ProcsPerMinute`, `Chance`) AS "chance", `Cooldown` AS "cooldown" FROM `spell_proc` WHERE ABS(`SpellId`) = %i', $this->firstRank)) + { + $procData['chance'] = $sp['chance'] ?: $procData['chance']; + $procData['cooldown'] = $sp['cooldown'] ?: $procData['cooldown']; + } + + $effects = []; + $spellIdx = array_unique(array_merge($this->subject->canTriggerSpell(), $this->subject->canTeachSpell())); + $itemIdx = $this->subject->canCreateItem(); + $perfItem = DB::World()->selectRow('SELECT `perfectItemType` AS "itemId", `requiredSpecialization` AS "reqSpellId", `perfectCreateChance` AS "chance" FROM skill_perfect_item_template WHERE `spellId` = %i', $this->typeId); + $scaling = $this->calculateEffectScaling(); + + // Iterate through all effects: + for ($i = 1; $i < 4; $i++) + { + if ($this->subject->getField('effect'.$i.'Id') <= 0) + continue; + + $effId = $this->subject->getField('effect'.$i.'Id'); + $effMV = $this->subject->getField('effect'.$i.'MiscValue'); + $effMVB = $this->subject->getField('effect'.$i.'MiscValueB'); + $effBP = $this->subject->getField('effect'.$i.'BasePoints'); + $effDS = $this->subject->getField('effect'.$i.'DieSides'); + $effRPPL = $this->subject->getField('effect'.$i.'RealPointsPerLevel'); + $effPPCP = $this->subject->getField('effect'.$i.'PointsPerComboPoint'); + $effAura = $this->subject->getField('effect'.$i.'AuraId'); + + /* Effect Format + * + * EffectName<: AuraName>< (effMV)>< [effMVB]> + * Value: A<, plus y per level> + * Radius: Byd + * Interval: Cms + * Mechanic: D + * Proc Chance: E% || (E procs per minute) + * Cooldown: Fs + * < formated markup > + * < iconlist > + * < icon > + */ + + $_nameEffect = $_nameAura = $_nameMV = $_nameMVB = $_markup = ''; + $_icon = $_perfItem = $_footer = []; + + $_footer['value'] = [0, 0]; + $valueFmt = '%s'; + + // Icons: + // .. from item + if (in_array($i, $itemIdx)) + { + if ($itemId = $this->subject->getField('effect'.$i.'CreateItemId')) + { + $itemEntry = $this->subject->relItems->getEntry($itemId); + + $_icon = new IconElement( + Type::ITEM, + $itemId, + $itemEntry ? $this->subject->relItems->getField('name', true) : Util::ucFirst(Lang::game('item')).' #'.$itemId, + $this->createNumRange($effBP, $effDS), + quality: $itemEntry ? $this->subject->relItems->getField('quality') : '', + link: !empty($itemEntry) + ); + } + + // perfect Items + if ($perfItem && $this->subject->relItems->getEntry($perfItem['itemId'])) + { + $cndSpell = new SpellList(array(['id', $perfItem['reqSpellId']])); + if (!$cndSpell->error) + { + $_perfItem = array( + 'spellId' => $cndSpell->id, + 'spellName' => $cndSpell->getField('name', true), + 'icon' => $cndSpell->getField('iconString'), + 'chance' => $perfItem['chance'], + 'item' => new IconElement( + Type::ITEM, + $perfItem['itemId'], + $this->subject->relItems->getField('name', true), + quality: $this->subject->relItems->getField('quality') + ) + ); + } + } + else if ($extraItem = DB::World()->selectRow('SELECT * FROM skill_extra_item_template WHERE `spellid` = %i', $this->typeId)) + { + $cndSpell = new SpellList(array(['id', $extraItem['requiredSpecialization']])); + if (!$cndSpell->error) + { + $_perfItem = array( + 'spellId' => $cndSpell->id, + 'spellName' => $cndSpell->getField('name', true), + 'icon' => $cndSpell->getField('iconString'), + 'chance' => $extraItem['additionalCreateChance'], + 'item' => new IconElement( + Type::ITEM, + $this->subject->relItems->id, + $this->subject->relItems->getField('name', true), + num: '+'.$this->createNumRange($effBP, $effDS, $extraItem['additionalMaxNum']), + quality: $this->subject->relItems->getField('quality') + ) + ); + } + } + } + // .. from spell + else if (in_array($i, $spellIdx) || $effId == SPELL_EFFECT_UNLEARN_SPECIALIZATION) + { + if ($effId == SPELL_EFFECT_TITAN_GRIP) + $triggeredSpell = $effMV; + else + $triggeredSpell = $this->subject->getField('effect'.$i.'TriggerSpell'); + + if ($triggeredSpell > 0) // Dummy Auras are probably scripted + { + $trig = new SpellList(array(['s.id', (int)$triggeredSpell])); + + $_icon = new IconElement( + Type::SPELL, + $triggeredSpell, + $trig->error ? Util::ucFirst(Lang::game('spell')).' #'.$triggeredSpell : $trig->getField('name', true), + link: !$trig->error + ); + + $this->extendGlobalData($trig->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + } + } + + // small text under effect name and resolved links .. order of adding to _footer determines order of output. + + // cases where we dont want 'Value' to be displayed [min, max, staffTT] + if (!in_array($i, $itemIdx) && !in_array($effAura, [SPELL_AURA_MOD_TAUNT, SPELL_AURA_MOD_STUN, SPELL_AURA_MOD_SHAPESHIFT, SPELL_AURA_MECHANIC_IMMUNITY]) && !in_array($effId, [SPELL_EFFECT_PLAY_MUSIC]) && ($effBP + $effDS) != 0) + $_footer['value'] = [$effBP + ($effDS ? 1 : 0), $effBP + $effDS]; + + if ($this->subject->getField('effect'.$i.'RadiusMax') > 0) + $_footer['radius'] = Lang::spell('_radius').$this->subject->getField('effect'.$i.'RadiusMax').' '.Lang::spell('_distUnit'); + + if ($this->subject->getField('effect'.$i.'Periode') > 0) + $_footer['interval'] = Lang::spell('_interval').DateTime::formatTimeElapsedFloat($this->subject->getField('effect'.$i.'Periode')); + + if ($_ = $this->subject->getField('effect'.$i.'Mechanic')) + $_footer['mechanic'] = Lang::game('mechanic').Lang::main('colon').Lang::game('me', $_); + + if (in_array($i, $this->subject->canTriggerSpell()) && $procData['chance'] && $procData['chance'] < 100) + { + $_footer['proc'] = $procData['chance'] < 0 ? Lang::spell('ppm', [-$procData['chance']]) : Lang::spell('procChance', [$procData['chance']]); + if ($procData['cooldown']) + $_footer['procCD'] = Lang::game('cooldown', [DateTime::formatTimeElapsed($procData['cooldown'])]); + } + + // Effect Name + if ($_ = Lang::spell('effects', $effId)) + $_nameEffect = ''.$this->fmtStaffTip($_, 'EffectId: '.$effId).''; + else + $_nameEffect = Lang::spell('unkEffect', [$effId]); + + // parse masks and indizes + switch ($effId) + { + case SPELL_EFFECT_ENERGIZE_PCT: + $valueFmt = '%s%%'; + case SPELL_EFFECT_POWER_DRAIN: + case SPELL_EFFECT_ENERGIZE: + case SPELL_EFFECT_POWER_BURN: + if ($_ = Lang::spell('powerTypes', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + + if ($effMV == POWER_RAGE || $effMV == POWER_RUNIC_POWER) + array_walk($_footer['value'], fn(&$x) => $x /= 10); + break; + case SPELL_EFFECT_BIND: + if ($effMV <= 0) + $_nameMV = $this->fmtStaffTip(Lang::spell('currentArea'), 'MiscValue: '.$effMV); + else if ($a = ZoneList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('zone')).' #'.$effMV; + break; + case SPELL_EFFECT_QUEST_COMPLETE: + case SPELL_EFFECT_CLEAR_QUEST: + case SPELL_EFFECT_QUEST_FAIL: + if ($a = QuestList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('quest')).' #'.$effMV; + break; + case SPELL_EFFECT_SUMMON_PET: + $effMVB = 67; // TC uses hardcoded summon property 67 + // DO NOT BREAK ! + case SPELL_EFFECT_SUMMON: + if (($sp = DB::Aowow()->selectRow('SELECT `control`, `slot` FROM ::summonproperties WHERE `id` = %i', $effMVB))) + $_nameMVB = $this->fmtStaffTip(Lang::spell('summonControl', $sp['control']).' – '.Lang::spell('summonSlot', $sp['slot']) , 'SummonProperty: '.$effMVB); + // DO NOT BREAK ! + case SPELL_EFFECT_SUMMON_DEMON: + case SPELL_EFFECT_KILL_CREDIT: + case SPELL_EFFECT_KILL_CREDIT2: + if ($a = CreatureList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('npc')).' #'.$effMV; + break; + case SPELL_EFFECT_OPEN_LOCK: + if ($effMV && ($_ = Lang::spell('lockType', $effMV))) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_ENCHANT_ITEM: + case SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: + case SPELL_EFFECT_ENCHANT_HELD_ITEM: + case SPELL_EFFECT_ENCHANT_ITEM_PRISMATIC: + if ($a = EnchantmentList::makeLink($effMV, cssClass: 'q2')) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('enchantment')).' #'.$effMV; + break; + case SPELL_EFFECT_DISPEL: + case SPELL_EFFECT_STEAL_BENEFICIAL_BUFF: + if ($effMV && ($_ = Lang::game('dt', $effMV))) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_LANGUAGE: + if ($effMV && ($_ = Lang::game('languages', $effMV))) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_TRANS_DOOR: + case SPELL_EFFECT_SUMMON_OBJECT_WILD: + case SPELL_EFFECT_SUMMON_OBJECT_SLOT1: + case SPELL_EFFECT_SUMMON_OBJECT_SLOT2: + case SPELL_EFFECT_SUMMON_OBJECT_SLOT3: + case SPELL_EFFECT_SUMMON_OBJECT_SLOT4: + if ($a = GameobjectList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('object')).' #'.$effMV; + break; + case SPELL_EFFECT_ACTIVATE_OBJECT: + if ($_ = Lang::gameObject('actions', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_APPLY_GLYPH: + if ($_ = DB::Aowow()->selectCell('SELECT `spellId` FROM ::glyphproperties WHERE `id` = %i', $effMV)) + { + if ($a = SpellList::makeLink($_)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('spell')).' #'.$_; + } + break; + case SPELL_EFFECT_SKINNING: + $_ = match ($effMV) + { + 0 => Lang::game('ct', 1).', '.Lang::game('ct', 2), // Skinning > Beast, Dragonkin + 1, 2 => Lang::game('ct', 4), // Gathering, Mining > Elemental + 3 => Lang::game('ct', 9), // Dismantling > Mechanic + default => '' + }; + if ($_) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_DISPEL_MECHANIC: + if ($_ = Lang::game('me', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_SKILL_STEP: + case SPELL_EFFECT_SKILL: + if ($a = SkillList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('skill')).' #'.$effMV; + break; + case SPELL_EFFECT_ACTIVATE_RUNE: + if ($_ = Lang::spell('powerRunes', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_EFFECT_PLAY_SOUND: + case SPELL_EFFECT_PLAY_MUSIC: + if (DB::Aowow()->selectCell('SELECT 1 FROM ::sounds WHERE `id` = %i', $effMV)) + { + $_markup = '[sound='.$effMV.']'; + $effMV = 0; // prevent default display + } + else + $_nameMV = Util::ucFirst(Lang::game('sound')).' #'.$effMV; + break; + case SPELL_EFFECT_REPUTATION: + if ($a = FactionList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('faction')).' #'.$effMV; + + // apply custom reward rated + if ($cuRate = DB::World()->selectCell('SELECT `spell_rate` FROM reputation_reward_rate WHERE `spell_rate` <> 1 AND `faction` = %i', $effMV)) + $_footer['value'][2] = sprintf(Util::$dfnString, Lang::faction('customRewRate'), ' ('.(($cuRate < 1 ? '-' : '+').intVal(($cuRate - 1) * $_footer['value'][0])).')'); + break; + case SPELL_EFFECT_SEND_TAXI: + $_ = DB::Aowow()->selectRow( + 'SELECT tn1.`areaId` AS "startAreaId", tn1.`areaX` AS "startPosX", tn1.`areaY` AS "startPosY", tn1.`name_loc0` AS "start_loc0", tn1.name_loc%i AS start_loc%i, + tn2.`areaId` AS "endAreaId", tn2.`areaX` AS "endPosX", tn2.`areaY` AS "endPosY", tn2.`name_loc0` AS "end_loc0", tn2.name_loc%i AS end_loc%i + FROM ::taxipath tp + JOIN ::taxinodes tn1 ON tp.`startNodeId` = tn1.`id` + JOIN ::taxinodes tn2 ON tp.`endNodeId` = tn2.`id` + WHERE tp.`id` = %i', + Lang::getLocale()->value, Lang::getLocale()->value, Lang::getLocale()->value, Lang::getLocale()->value, $effMV + ); + if ($_) + { + $start = Util::localizedString($_, 'start'); + if ($_['startAreaId']) + $start = sprintf('%s', $_['startAreaId'], $_['startPosX'] * 10, $_['startPosY'] * 10, $start); + $end = Util::localizedString($_, 'end'); + if ($_['endAreaId']) + $end = sprintf('%s', $_['endAreaId'], $_['endPosX'] * 10, $_['endPosY'] * 10, $end); + + $_nameMV = $this->fmtStaffTip(''.$start.''.$end, 'MiscValue: '.$effMV); + } + break; + case SPELL_EFFECT_TITAN_GRIP: + $effMV = 0; // effMV is trigger spell and was handled earlier + break; + case SPELL_EFFECT_CAST_BUTTON: + $_nameMV = $effMV; // has a valid 0 value + break; + // Aura + case SPELL_EFFECT_APPLY_AURA: + case SPELL_EFFECT_PERSISTENT_AREA_AURA: + case SPELL_EFFECT_APPLY_AREA_AURA_PARTY: + case SPELL_EFFECT_APPLY_AREA_AURA_RAID: + case SPELL_EFFECT_APPLY_AREA_AURA_PET: + case SPELL_EFFECT_APPLY_AREA_AURA_FRIEND: + case SPELL_EFFECT_APPLY_AREA_AURA_ENEMY: + case SPELL_EFFECT_APPLY_AREA_AURA_OWNER: + { + if ($effAura > 0 && ($_ = Lang::spell('auras', $effAura))) + $_nameAura = ''.$this->fmtStaffTip($_, 'AuraId: '.$effAura).''; + else if ($effAura > 0) + $_nameAura = Lang::spell('unkAura', [$effAura]); + + switch ($effAura) + { + case SPELL_AURA_MOD_STEALTH_DETECT: + if ($_ = Lang::spell('stealthType', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_INVISIBILITY: + case SPELL_AURA_MOD_INVISIBILITY_DETECT: + if ($_ = Lang::spell('invisibilityType', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_POWER_REGEN_PERCENT: + case SPELL_AURA_MOD_INCREASE_ENERGY_PERCENT: + case SPELL_AURA_OBS_MOD_POWER: + $valueFmt = '%s%%'; + case SPELL_AURA_PERIODIC_ENERGIZE: + case SPELL_AURA_MOD_INCREASE_ENERGY: + case SPELL_AURA_MOD_POWER_REGEN: + if ($_ = Lang::spell('powerTypes', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + + if ($effMV == POWER_RAGE || $effMV == POWER_RUNIC_POWER) + array_walk($_footer['value'], fn(&$x) => $x /= 10); + break; + case SPELL_AURA_MOD_PERCENT_STAT: + case SPELL_AURA_MOD_TOTAL_STAT_PERCENTAGE: + case SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT: + case SPELL_AURA_MOD_RANGED_ATTACK_POWER_OF_STAT_PERCENT: + case SPELL_AURA_MOD_ATTACK_POWER_OF_STAT_PERCENT: + case SPELL_AURA_MOD_MANA_REGEN_FROM_STAT: + $valueFmt = '%s%%'; + case SPELL_AURA_MOD_STAT: + if ($effMV < 0) + $_nameMV = $this->fmtStaffTip(Lang::main('all'), 'MiscValue: '.$effMV); + else if ($_ = Lang::game('stats', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_SHAPESHIFT: + if ($_ = Lang::game('st', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_EFFECT_IMMUNITY: + if ($_ = Lang::spell('effects', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_STATE_IMMUNITY: + if ($_ = Lang::spell('auras', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_DEBUFF_RESISTANCE: + case SPELL_AURA_MOD_AURA_DURATION_BY_DISPEL: + case SPELL_AURA_MOD_AURA_DURATION_BY_DISPEL_NOT_STACK: + $valueFmt = '%s%%'; + case SPELL_AURA_DISPEL_IMMUNITY: + if ($_ = Lang::game('dt', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_TRACK_CREATURES: + if ($_ = Lang::game('ct', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_TRACK_RESOURCES: + if ($_ = Lang::spell('lockType', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_LANGUAGE: + case SPELL_AURA_COMPREHEND_LANGUAGE: + if ($_ = Lang::game('languages', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_MECHANIC_DAMAGE_TAKEN_PERCENT: + case SPELL_AURA_MOD_MECHANIC_RESISTANCE: + case SPELL_AURA_MECHANIC_DURATION_MOD: + case SPELL_AURA_MECHANIC_DURATION_MOD_NOT_STACK: + case SPELL_AURA_MOD_DAMAGE_DONE_FOR_MECHANIC: + $valueFmt = '%s%%'; + case SPELL_AURA_MECHANIC_IMMUNITY: + if ($_ = Lang::game('me', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MECHANIC_IMMUNITY_MASK: + $_ = []; + foreach (Lang::game('me') as $k => $str) + if ($k && ($effMV & (1 << $k - 1))) + $_[] = $str; + + if ($_ = implode(', ', $_)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.Util::asHex($effMV)); + break; + case SPELL_AURA_MOD_SPELL_DAMAGE_OF_STAT_PERCENT: + case SPELL_AURA_MOD_RESISTANCE_OF_STAT_PERCENT: + if ($_ = Lang::game('stats', $effMVB)) + $_nameMVB = $this->fmtStaffTip($_, 'MiscValueB: '.$effMVB); + // ! DO NOT BREAK ! + case SPELL_AURA_MOD_POWER_COST_SCHOOL_PCT: + case SPELL_AURA_SPLIT_DAMAGE_PCT: + case SPELL_AURA_MOD_RESISTANCE_PCT: + case SPELL_AURA_MOD_HEALING_PCT: + case SPELL_AURA_MOD_BASE_RESISTANCE_PCT: + case SPELL_AURA_MOD_INCREASES_SPELL_PCT_TO_HIT: + case SPELL_AURA_MOD_HOT_PCT: + case SPELL_AURA_SHARE_DAMAGE_PCT: + case SPELL_AURA_MOD_THREAT: + case SPELL_AURA_MOD_DAMAGE_PERCENT_DONE: + case SPELL_AURA_MOD_DAMAGE_PERCENT_TAKEN: + case SPELL_AURA_MOD_HEALING_DONE_PERCENT: + case SPELL_AURA_MOD_WEAPON_CRIT_PERCENT: + case SPELL_AURA_MOD_CRITICAL_HEALING_AMOUNT: + case SPELL_AURA_MOD_SPELL_CRIT_CHANCE_SCHOOL: + case SPELL_AURA_REFLECT_SPELLS_SCHOOL: + case SPELL_AURA_REDUCE_PUSHBACK: + case SPELL_AURA_MOD_CRIT_DAMAGE_BONUS: + case SPELL_AURA_MOD_ATTACKER_SPELL_HIT_CHANCE: + case SPELL_AURA_MOD_TARGET_ABSORB_SCHOOL: + case SPELL_AURA_MOD_TARGET_ABILITY_ABSORB_SCHOOL: + case SPELL_AURA_MOD_AOE_DAMAGE_AVOIDANCE: + case SPELL_AURA_MOD_DAMAGE_FROM_CASTER: + case SPELL_AURA_MOD_CREATURE_AOE_DAMAGE_AVOIDANCE: + case SPELL_AURA_MOD_SPELL_DAMAGE_OF_ATTACK_POWER: + case SPELL_AURA_MOD_SPELL_HEALING_OF_ATTACK_POWER: + case SPELL_AURA_MOD_SPELL_DAMAGE_FROM_HEALING: // ? Mod Spell & Healing Power by % of Int + $valueFmt = '%s%%'; + case SPELL_AURA_SCHOOL_ABSORB: + case SPELL_AURA_MOD_DAMAGE_DONE: + case SPELL_AURA_MOD_DAMAGE_TAKEN: + case SPELL_AURA_MOD_RESISTANCE: + case SPELL_AURA_SCHOOL_IMMUNITY: + case SPELL_AURA_DAMAGE_IMMUNITY: + case SPELL_AURA_MOD_POWER_COST_SCHOOL: + case SPELL_AURA_MOD_BASE_RESISTANCE: + case SPELL_AURA_MANA_SHIELD: + case SPELL_AURA_MOD_HEALING: + case SPELL_AURA_MOD_TARGET_RESISTANCE: + case SPELL_AURA_MOD_HEALING_DONE: + case SPELL_AURA_MOD_RESISTANCE_EXCLUSIVE: + case SPELL_AURA_MOD_IMMUNE_AURA_APPLY_SCHOOL: // ? Cancel Aura Buffer at % of Caster Health + case SPELL_AURA_MOD_IGNORE_TARGET_RESIST: + case SPELL_AURA_MOD_ATTACK_POWER_OF_ARMOR: // ? Mod Attack Power by School Resistance + case SPELL_AURA_SCHOOL_HEAL_ABSORB: + if ($_ = Lang::getMagicSchools($effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.Util::asHex($effMV)); + break; + case SPELL_AURA_MOD_SKILL: // temp + case SPELL_AURA_MOD_SKILL_TALENT: // perm + $valueFmt = '%+d'; + if ($a = SkillList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('skill')).' #'.$effMV; + break; + case SPELL_AURA_ADD_PCT_MODIFIER: + $valueFmt = '%s%%'; + case SPELL_AURA_ADD_FLAT_MODIFIER: + if ($_ = Lang::spell('spellModOp', $effMV)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.$effMV); + break; + case SPELL_AURA_MOD_RATING_FROM_STAT: + $valueFmt = '%s%%'; + if ($_ = Lang::game('stats', $effMVB)) + $_nameMVB = $this->fmtStaffTip($_, 'MiscValueB: '.$effMVB); + // DO NOT BREAK ! + case SPELL_AURA_MOD_RATING: + foreach (Lang::spell('combatRatingMask') as $m => $str) + { + if ($effMV != $m) + continue; + $_nameMV = $this->fmtStaffTip($str, 'MiscValue: '.Util::asHex($effMV)); + break 2; + } + + $_ = []; + foreach (Lang::spell('combatRating') as $k => $str) + if ((1 << $k) & $effMV) + $_[] = $str; + + if ($_ = implode(', ', $_)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.Util::asHex($effMV)); + break; + case SPELL_AURA_MOD_DAMAGE_DONE_VERSUS: + $valueFmt = '%s%%'; + case SPELL_AURA_MOD_DAMAGE_DONE_CREATURE: + case SPELL_AURA_MOD_MELEE_ATTACK_POWER_VERSUS: + case SPELL_AURA_MOD_RANGED_ATTACK_POWER_VERSUS: + case SPELL_AURA_MOD_FLAT_SPELL_DAMAGE_VERSUS: + $_ = []; + foreach (Lang::game('ct') as $k => $str) + if ($k && ($effMV & (1 << $k - 1))) + $_[] = $str; + + if ($_ = implode(', ', $_)) + $_nameMV = $this->fmtStaffTip($_, 'MiscValue: '.Util::asHex($effMV)); + break; + case SPELL_AURA_CONVERT_RUNE: + $from = $effMV; + if ($_ = Lang::spell('powerRunes', $effMV)) + $from = $_; + + $to = $effMVB; + if ($_ = Lang::spell('powerRunes', $effMVB)) + $to = $_; + + $effMVB = 0; // prevent default display + $_nameMV = $this->fmtStaffTip(''.$from.'', 'MiscValue: '.$effMV).$this->fmtStaffTip($to, 'MiscValueB: '.$effMVB); + break; + case SPELL_AURA_MOUNTED: + case SPELL_AURA_TRANSFORM: + case SPELL_AURA_CHANGE_MODEL_FOR_ALL_HUMANOIDS: + case SPELL_AURA_X_RAY: + case SPELL_AURA_MOD_FAKE_INEBRIATE: + if ($effMV && $a = CreatureList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('npc')).' #'.$effMV; + break; + case SPELL_AURA_FORCE_REACTION: + $_footer['value'][1] = $this->fmtStaffTip(Lang::game('rep', $_footer['value'][1]), $_footer['value'][1]); + $_footer['value'][0] = null; // disable range here as the string replacement will fail the comparison at the end + // DO NOT BREAK ! + case SPELL_AURA_MOD_FACTION_REPUTATION_GAIN: + if ($effAura == SPELL_AURA_MOD_FACTION_REPUTATION_GAIN) + $valueFmt = '%s%%'; + if ($a = FactionList::makeLink($effMV)) + $_nameMV = $a; + else + $_nameMV = Util::ucFirst(Lang::game('faction')).' #'.$effMV; + break; // also breaks for SPELL_AURA_FORCE_REACTION + case SPELL_AURA_OVERRIDE_SPELLS: + if ($so = DB::Aowow()->selectRow('SELECT `spellId1`, `spellId2`, `spellId3`, `spellId4`, `spellId5` FROM ::spelloverride WHERE `id` = %i', $effMV)) + { + if ($so = array_filter($so)) + { + $this->extendGlobalData([Type::SPELL => $so]); + $_markup = '[spell='.implode('], [spell=', $so).']'; + $effMV = 0; // prevent default display + } + } + break; + case SPELL_AURA_MOD_COMBAT_RESULT_CHANCE: + $valueFmt = '%s%%'; + case SPELL_AURA_IGNORE_COMBAT_RESULT: + $what = match ($effMV) + { + 2 => Lang::spell('combatRating', 2), // Dodged + 3 => Lang::spell('combatRating', 4), // Blocked + 4 => Lang::spell('combatRating', 3), // Parried + default => '' // Evaded(0) Missed(1) Glanced(5) Crited'ed..ed(6) Crushed(7) Regular(8) + }; + + if ($what) + $_nameMV = $this->fmtStaffTip($what, 'MiscValue: '.$effMV); + else + trigger_error('unused case #'.$effMV.' found for aura #'.$effAura); + break; + case SPELL_AURA_SCREEN_EFFECT: + if ($ses = DB::Aowow()->selectRow('SELECT `name`, `ambienceDay` AS "0", IF(`ambienceNight` <> `ambienceDay`, `ambienceNight`, 0) AS "1", `musicDay` AS "2", IF(`musicNight` <> `musicDay`, `musicNight`, 0) AS "3" FROM ::screeneffect_sounds WHERE `id` = %i', $effMV)) + { + $_nameMV = $this->fmtStaffTip($ses['name'], 'MiscValue: '.$effMV); + for ($j = 0; $j < 4; $j++) + if ($ses[$j]) + $_markup .= '[sound='.$ses[$j].']'; + } + break; + case SPELL_AURA_MOD_HEALTH_REGEN_PERCENT: + case SPELL_AURA_OBS_MOD_HEALTH: + case SPELL_AURA_MOD_INCREASE_HEALTH_PERCENT: + case SPELL_AURA_MOD_ARMOR_PENETRATION_PCT: + case SPELL_AURA_MOD_SCALE: + case SPELL_AURA_MOD_SCALE_2: + case SPELL_AURA_MOD_SPEED_ALWAYS: + case SPELL_AURA_MOD_SPEED_SLOW_ALL: + case SPELL_AURA_MOD_SPEED_NOT_STACK: + case SPELL_AURA_MOD_INCREASE_SPEED: + case SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED: + case SPELL_AURA_MOD_DECREASE_SPEED: + case SPELL_AURA_MOD_INCREASE_SWIM_SPEED: + case SPELL_AURA_MOD_PARRY_PERCENT: + case SPELL_AURA_MOD_DODGE_PERCENT: + case SPELL_AURA_MOD_BLOCK_PERCENT: + case SPELL_AURA_MOD_BLOCK_CRIT_CHANCE: + case SPELL_AURA_MOD_HIT_CHANCE: + case SPELL_AURA_MOD_CRIT_PCT: + case SPELL_AURA_MOD_SPELL_HIT_CHANCE: + case SPELL_AURA_MOD_SPELL_CRIT_CHANCE: + case SPELL_AURA_MOD_MELEE_RANGED_HASTE: + case SPELL_AURA_MOD_CASTING_SPEED_NOT_STACK: + $valueFmt = '%s%%'; + break; + } + break; + } + case SPELL_EFFECT_RESURRECT: + case SPELL_EFFECT_SPIRIT_HEAL: + case SPELL_EFFECT_WEAPON_PERCENT_DAMAGE: + case SPELL_EFFECT_DURABILITY_DAMAGE_PCT: + case SPELL_EFFECT_MODIFY_THREAT_PERCENT: + case SPELL_EFFECT_REDIRECT_THREAT: + case SPELL_EFFECT_HEAL_PCT: + $valueFmt = '%s%%'; + break; + } + + if ($_footer['value'][1]) + { + $buffer = Lang::spell('_value').Lang::main('colon').sprintf($valueFmt, $_footer['value'][0]); + if ($_footer['value'][0] != $_footer['value'][1]) + $buffer .= Lang::game('valueDelim').sprintf($valueFmt, $_footer['value'][1]); + if ($effRPPL != 0) + $buffer .= Lang::spell('costPerLevel', [sprintf($valueFmt, $effRPPL)]); + if ($effPPCP != 0) + $buffer .= Lang::spell('pointsPerCP', [sprintf($valueFmt, $effPPCP)]); + if (isset($_footer['value'][2])) + $buffer .= $_footer['value'][2]; + + if (in_array($effId, SpellList::EFFECTS_SCALING_DAMAGE)) + { + if ($scaling[2]) + $buffer .= Lang::spell('apMod', [$scaling[2]]); + if ($scaling[0]) + $buffer .= Lang::spell('spMod', [$scaling[0]]); + } + if (in_array($effAura, SpellList::AURAS_SCALING_DAMAGE)) + { + if ($scaling[3]) + $buffer .= Lang::spell('apMod', [$scaling[3]]); + if ($scaling[1]) + $buffer .= Lang::spell('spMod', [$scaling[1]]); + } + + $_footer['value'] = $buffer; + } + else + unset($_footer['value']); + + $_header = $_nameEffect; + + if ($_nameAura) + $_header .= Lang::main('colon').$_nameAura; + + if (strlen($_nameMV)) + $_header .= ' ('.$_nameMV.')'; + else if ($effMV) + $_header .= ' ('.$effMV.')'; + + if (strlen($_nameMVB)) + $_header .= ' ['.$_nameMVB.']'; + else if ($effMVB) + $_header .= ' ['.$effMVB.']'; + + $effects[$i] = array( + 'icon' => $_icon, + 'perfectItem' => $_perfItem, + 'name' => $_header, + 'footer' => $_footer, + 'markup' => $_markup, + 'modifies' => [] // may be set later + ); + } + + $this->effects = $effects; + } + + private function createAttributesList() : void + { + $list = []; + for ($i = 0; $i < 8; $i++) + { + $attributes = $this->subject->getField('attributes'.$i); + for ($j = 1; $j <= (1 << 31); $j <<= 1) + { + if (!($attributes & $j)) + continue; + + $listItem = Lang::spell('attributes'.$i, $j); + if (!$listItem && User::isInGroup(U_GROUP_STAFF)) + $listItem = 'Unknown SpellAttribute'.$i.''; + else if (!$listItem) + continue; + + if ($crId = (SpellListFilter::$attributesFilter[$i][$j] ?? 0)) + $listItem = sprintf('%1$s', $listItem, abs($crId), $crId > 0 ? 1 : 2); + + $list[] = $this->fmtStaffTip($listItem, 'Attributes'.$i.': '.Util::asHex($j)); + } + } + + $this->attributes = $list; + } + + private function createNumRange(int $bp, int $ds, int $mult = 1) : string + { + return Util::createNumRange($bp + 1, ($bp + $ds) * $mult, '-'); + } + + private function generatePath() + { + $cat = $this->subject->getField('typeCat'); + $cf = $this->subject->getField('cuFlags'); + $sl = $this->subject->getField('skillLines'); + + $this->breadcrumb[] = $cat; + + // reconstruct path + switch ($cat) + { + case -2: + case 7: + case -13: + if ($cl = $this->subject->getField('reqClassMask')) + $this->breadcrumb[] = ChrClass::fromMask($cl)[0]; + else if ($sf = $this->subject->getField('spellFamilyId')) + foreach (ChrClass::cases() as $cl) + if ($cl->spellFamily() == $sf) + { + $this->breadcrumb[] = $cl->value; + break; + } + + if ($cat == -13) + $this->breadcrumb[] = ($cf & (SPELL_CU_GLYPH_MAJOR | SPELL_CU_GLYPH_MINOR)) >> 6; + else if ($sl) + $this->breadcrumb[] = $sl[0]; + + break; + case 9: + case -3: + case 11: + if ($sl) + $this->breadcrumb[] = $sl[0]; + + if ($cat == 11) + if ($_ = $this->subject->getField('reqSpellId')) + $this->breadcrumb[] = $_; + + break; + case -11: + foreach (SpellList::$skillLines as $line => $skills) + if (in_array($sl[0] ?? [], $skills)) + $this->breadcrumb[] = $line; + break; + case -7: // only spells unique in skillLineAbility will always point to the right skillLine :/ + if ($cf & SPELL_CU_PET_TALENT_TYPE0) + $this->breadcrumb[] = 411; // Ferocity + else if ($cf & SPELL_CU_PET_TALENT_TYPE1) + $this->breadcrumb[] = 409; // Tenacity + else if ($cf & SPELL_CU_PET_TALENT_TYPE2) + $this->breadcrumb[] = 410; // Cunning + break; + case -5: + if ($this->subject->getField('effect2AuraId') == SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED || + $this->subject->getField('effect3AuraId') == SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED) + $this->breadcrumb[] = 2; // flying (also contains SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED, so checked first) + else if ($this->subject->getField('effect2AuraId') == SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED || + $this->subject->getField('effect3AuraId') == SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED) + $this->breadcrumb[] = 1; // ground + else + $this->breadcrumb[] = 3; // misc + } + } + + private function createInfobox() : void + { + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + $typeCat = $this->subject->getField('typeCat'); + $hasCompletion = in_array($typeCat, [-5, -6]) && !($this->subject->getField('cuFlags') & CUSTOM_EXCLUDE_FOR_LISTVIEW); + + // level + if (!in_array($typeCat, [-5, -6])) // not mount or vanity pet + { + if ($_ = $this->subject->getField('talentLevel')) + $infobox[] = (in_array($typeCat, [-2, 7, -13]) ? Lang::game('reqLevel', [$_]) : Lang::game('level').Lang::main('colon').$_); + else if ($_ = $this->subject->getField('spellLevel')) + $infobox[] = (in_array($typeCat, [-2, 7, -13]) ? Lang::game('reqLevel', [$_]) : Lang::game('level').Lang::main('colon').$_); + } + + // races + $jsg = []; + if ($_ = Lang::getRaceString($this->subject->getField('reqRaceMask'), $jsg, Lang::FMT_MARKUP)) + { + $this->extendGlobalIds(Type::CHR_RACE, ...$jsg); + $t = count($jsg) == 1 ? Lang::game('race') : Lang::game('races'); + $infobox[] = Util::ucFirst($t).Lang::main('colon').$_; + } + + // classes + $jsg = []; + if ($_ = Lang::getClassString($this->subject->getField('reqClassMask'), $jsg, Lang::FMT_MARKUP)) + { + $this->extendGlobalIds(Type::CHR_CLASS, ...$jsg); + $t = count($jsg) == 1 ? Lang::game('class') : Lang::game('classes'); + $infobox[] = Util::ucFirst($t).Lang::main('colon').$_; + } + + // spell focus + if ($_ = $this->subject->getField('spellFocusObject')) + { + if ($sfObj = DB::Aowow()->selectRow('SELECT * FROM ::spellfocusobject WHERE `id` = %i', $_)) + { + $n = Util::localizedString($sfObj, 'name'); + if (!is_null(GameObjectListFilter::getCriteriaIndex(50, $_))) + $n = '[url=?objects&filter=cr=50;crs='.$_.';crv=0]'.$n.'[/url]'; + else if ($objId = DB::Aowow()->selectCell('SELECT `id` FROM ::objects WHERE `spellFocusId` = %i', $_)) + $n = '[url=?object='.$objId.']'.$n.'[/url]'; + + $infobox[] = Lang::game('requires2').' '.$n; + } + } + + // primary & secondary trades + if (in_array($typeCat, [9, 11])) + { + // skill + if ($_ = $this->subject->getField('skillLines')) + { + $this->extendGlobalIds(Type::SKILL, $_[0]); + + $bar = Lang::game('requires', [' [skill='.$_[0].']']); + if ($_ = $this->subject->getField('learnedAt')) + $bar .= ' ('.$_.')'; + + $infobox[] = $bar; + } + + // specialization + if ($_ = $this->subject->getField('reqSpellId')) + { + $this->extendGlobalIds(Type::SPELL, $_); + $infobox[] = Lang::game('requires2').' [spell='.$_.']'; + } + + // difficulty + if ($_ = $this->subject->getColorsForCurrent()) + $infobox[] = Lang::formatSkillBreakpoints($_); + } + + // accquisition.. 10: starter spell; 7: discovery + if ($this->subject->getRawSource(SRC_STARTER)) + $infobox[] = Lang::spell('starter'); + else if ($this->subject->getRawSource(SRC_DISCOVERY)) + $infobox[] = Lang::spell('discovered'); + + // training cost + if ($cost = $this->subject->getField('trainingCost')) + $infobox[] = Lang::spell('trainingCost').'[money='.$cost.']'; + + // id + $infobox[] = Lang::spell('id') . $this->typeId; + + // icon + if ($_ = $this->subject->getField('iconId')) + { + $infobox[] = Util::ucFirst(Lang::game('icon')).Lang::main('colon').'[icondb='.$_.' name=true]'; + $this->extendGlobalIds(Type::ICON, $_); + } + + // profiler relateed (note that this is part of the cache. I don't think this is important enough to calc for every view) + if (Cfg::get('PROFILER_ENABLE') && $hasCompletion) + { + $x = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_completion_spells WHERE `spellId` = %i', $this->typeId); + $y = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_profiles WHERE `custom` = 0 AND `stub` = 0'); + $infobox[] = Lang::profiler('attainedBy', [round(($x ?: 0) * 100 / ($y ?: 1))]); + + // completion row added by InfoboxMarkup + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + // used in mode + foreach ($this->difficulties as $n => $id) + if ($id == $this->typeId) + $infobox[] = Lang::game('mode').Lang::game('modes', $this->mapType, $n); + + // Creature Type from Aura: Shapeshift + foreach ($this->modelInfo as $mI) + { + if (!isset($mI['creatureType'])) + continue; + + if ($mI['creatureType'] > 0) + $infobox[] = Lang::game('type').Lang::game('ct', $mI['creatureType']); + + break; + } + + // spell script + if (User::isInGroup(U_GROUP_STAFF)) + if ($_ = DB::World()->selectCell('SELECT `ScriptName` FROM spell_script_names WHERE ABS(`spell_id`) = %i', $this->firstRank)) + $infobox[] = 'Script'.Lang::main('colon').$_; + + + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0', $hasCompletion); + + // append glyph symbol if available + $glyphId = 0; + for ($i = 1; $i < 4; $i++) + if ($this->subject->getField('effect'.$i.'Id') == SPELL_EFFECT_APPLY_GLYPH) + $glyphId = $this->subject->getField('effect'.$i.'MiscValue'); + + if ($_ = DB::Aowow()->selectCell('SELECT ic.`name` FROM ::glyphproperties gp JOIN ::icons ic ON gp.`iconId` = ic.`id` WHERE %if', $glyphId, 'gp.`id` = %i OR', $glyphId, '%end gp.`spellId` = %i', $this->typeId)) + if (file_exists('static/images/wow/Interface/Spellbook/'.$_.'.png')) + $this->infobox->append('[img src='.Cfg::get('STATIC_URL').'/images/wow/Interface/Spellbook/'.$_.'.png border=0 float=center margin=15]'); + } +} + +?> diff --git a/endpoints/spell/spell_power.php b/endpoints/spell/spell_power.php new file mode 100644 index 00000000..957232f0 --- /dev/null +++ b/endpoints/spell/spell_power.php @@ -0,0 +1,58 @@ + ['filter' => FILTER_CALLBACK, 'options' => [Locale::class, 'tryFromDomain']] + ); + + public function __construct(string $param) + { + parent::__construct($param); + + // temp locale + if ($this->_get['domain']) + Lang::load($this->_get['domain']); + + $this->typeId = intVal($param); + } + + protected function generate() : void + { + $spell = new SpellList([['id', $this->typeId]]); + if ($spell->error) + $this->cacheType = CACHE_TYPE_NONE; + else + { + $tooltip = $spell->renderTooltip(ttSpells: $ttSpells); + $buff = $spell->renderBuff(buffSpells: $bfSpells); + + $opts = array( + 'name' => $spell->getField('name', true), + 'icon' => $spell->getField('iconString'), + 'tooltip' => $tooltip, + 'spells' => $ttSpells, + 'buff' => $buff, + 'buffspells' => $bfSpells + ); + } + + $this->result = new Tooltip(self::POWER_TEMPLATE, $this->typeId, $opts ?? []); + } +} + +?> diff --git a/pages/spells.php b/endpoints/spells/spells.php similarity index 62% rename from pages/spells.php rename to endpoints/spells/spells.php index 6e6073dd..eadebde5 100644 --- a/pages/spells.php +++ b/endpoints/spells/spells.php @@ -1,25 +1,43 @@ [ 6, 7], + SKILL_BLACKSMITHING => [ 2, 4], + SKILL_LEATHERWORKING => [ 8, 1], + SKILL_ALCHEMY => [ 1, 6], + SKILL_COOKING => [ 3, 5], + SKILL_MINING => [ 9, 0], + SKILL_TAILORING => [10, 2], + SKILL_ENGINEERING => [ 5, 3], + SKILL_ENCHANTING => [ 4, 8], + SKILL_FISHING => [ 0, 9], + SKILL_JEWELCRAFTING => [ 7, 10], + SKILL_INSCRIPTION => [15, 0], + ); - protected $_get = ['filter' => ['filter' => FILTER_UNSAFE_RAW]]; + protected int $type = Type::SPELL; + protected int $cacheType = CACHE_TYPE_LIST_PAGE; - protected $validCats = array( + protected string $template = 'spells'; + protected string $pageName = 'spells'; + protected ?int $activeTab = parent::TAB_DATABASE; + protected array $breadcrumb = [0, 1]; + + protected array $scripts = [[SC_JS_FILE, 'js/filters.js']]; + protected array $expectedGET = array( + 'filter' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => Filter::PATTERN_PARAM]] + ); + protected array $validCats = array( -2 => array( // Talents: Class => Skill 1 => [ 26, 256, 257], 2 => [594, 267, 184], @@ -56,57 +74,105 @@ class SpellsPage extends GenericPage ), 9 => [129, 185, 356, 762], // Secondary Skills 11 => array( // Professions: Skill => Spell - 171 => true, - 164 => [9788, 9787, 17041, 17040, 17039], - 333 => true, - 202 => [20219, 20222], - 182 => true, - 773 => true, - 755 => true, - 165 => [10656, 10658, 10660], - 186 => true, - 393 => true, - 197 => [26798, 26801, 26797], + SKILL_ALCHEMY => true, + SKILL_BLACKSMITHING => [9788, 9787, 17041, 17040, 17039], + SKILL_ENCHANTING => true, + SKILL_ENGINEERING => [20219, 20222], + SKILL_HERBALISM => true, + SKILL_INSCRIPTION => true, + SKILL_JEWELCRAFTING => true, + SKILL_LEATHERWORKING => [10656, 10658, 10660], + SKILL_MINING => true, + SKILL_SKINNING => true, + SKILL_TAILORING => [26798, 26801, 26797], ) ); - private $shortFilter = array( - 129 => [ 6, 7], // First Aid - 164 => [ 2, 4], // Blacksmithing - 165 => [ 8, 1], // Leatherworking - 171 => [ 1, 6], // Alchemy - 185 => [ 3, 5], // Cooking - 186 => [ 9, 0], // Mining - 197 => [10, 2], // Tailoring - 202 => [ 5, 3], // Engineering - 333 => [ 4, 8], // Enchanting - 356 => [ 0, 9], // Fishing - 755 => [ 7, 10], // Jewelcrafting - 773 => [15, 0], // Inscription - ); + public bool $classPanel = false; + public bool $glyphPanel = false; - - public function __construct($pageCall, $pageParam) + public function __construct(string $rawParam) { - $this->getCategoryFromUrl($pageParam);; - $this->filterObj = new SpellListFilter(false, ['parentCats' => $this->category]); + $this->getCategoryFromUrl($rawParam); - parent::__construct($pageCall, $pageParam); + parent::__construct($rawParam); - $this->name = Util::ucFirst(Lang::game('spells')); - $this->subCat = $pageParam !== '' ? '='.$pageParam : ''; + if ($this->category) + $this->subCat = '='.implode('.', $this->category); - $this->classPanel = false; - $this->glyphPanel = false; + $this->filter = new SpellListFilter($this->_get['filter'] ?? '', ['parentCats' => $this->category]); + if ($this->filter->shouldReload) + { + $_SESSION['error']['fi'] = $this->filter::class; + $get = $this->filter->buildGETParam(); + $this->forward('?' . $this->pageName . $this->subCat . ($get ? '&filter=' . $get : '')); + } + $this->filterError = $this->filter->error; } - protected function generateContent() + protected function generate() : void { - $conditions = []; - $visibleCols = []; - $hiddenCols = []; - $extraCols = []; - $tabData = ['data' => []]; + $this->h1 = Util::ucFirst(Lang::game('spells')); + + $conditions = [Listview::DEFAULT_SIZE]; + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($_ = $this->filter->getConditions()) + $conditions[] = $_; + + + /*************/ + /* Menu Path */ + /*************/ + + foreach ($this->category as $c) + $this->breadcrumb[] = $c; + + $fiForm = $this->filter->values; + if (count($this->breadcrumb) == 4 && $this->category[0] == -13 && count($fiForm['gl']) == 1) + $this->breadcrumb[] = $fiForm['gl']; + + + /**************/ + /* Page Title */ + /**************/ + + $foo = []; + $c = $this->category; // shothand + if (isset($c[2]) && $c[0] == 11) + array_unshift($foo, Lang::spell('cat', $c[0], $c[1], $c[2])); + else if (isset($c[1])) + { + $_ = in_array($c[0], [-2, -13, 7]) ? Lang::game('cl') : Lang::spell('cat', $c[0]); + array_unshift($foo, is_array($_[$c[1]]) ? $_[$c[1]][0] : $_[$c[1]]); + } + + if (isset($c[0]) && count($foo) < 2) + { + $_ = Lang::spell('cat', $c[0]); + array_unshift($foo, is_array($_) ? $_[0] : $_); + } + + if (count($foo) < 2) + array_unshift($foo, $this->h1); + + foreach ($foo as $bar) + array_unshift($this->title, $bar); + + + /****************/ + /* Main Content */ + /****************/ + + $visibleCols = []; + $hiddenCols = []; + $extraCols = []; + $tabData = ['data' => []]; + + $this->redButtons[BUTTON_WOWHEAD] = true; + if ($fiQuery = $this->filter->buildGETParam()) + $this->wowheadLink .= '&filter='.$fiQuery; // the next lengthy ~250 lines determine $conditions and lvParams if ($this->category) @@ -137,30 +203,30 @@ class SpellsPage extends GenericPage $xCond = null; for ($i = -2; $i < 0; $i++) { - foreach (Game::$skillLineMask[$i] as $idx => $pair) + foreach (Game::$skillLineMask[$i] as $idx => [, $skillLineId]) { - if ($pair[1] == $this->category[1]) + if ($skillLineId == $this->category[1]) { - $xCond = ['AND', ['s.skillLine1', $i], ['s.skillLine2OrMask', 1 << $idx, '&']]; + $xCond = [DB::AND, ['s.skillLine1', $i], ['s.skillLine2OrMask', 1 << $idx, '&']]; break; } } } $conditions[] = [ - 'OR', + DB::OR, $xCond, ['s.skillLine1', $this->category[1]], - ['AND', ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->category[1]]] + [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->category[1]]] ]; } else { $conditions[] = [ - 'OR', + DB::OR, ['s.skillLine1', [-1, -2]], ['s.skillLine1', $this->validCats[-3]], - ['AND', ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->validCats[-3]]] + [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->validCats[-3]]] ]; } @@ -182,16 +248,19 @@ class SpellsPage extends GenericPage switch ($this->category[1]) { case 1: - $conditions[] = ['OR', - ['AND', ['effect2AuraId', 32], ['effect3AuraId', 207, '!']], - ['AND', ['effect3AuraId', 32], ['effect2AuraId', 207, '!']] + $conditions[] = [DB::OR, + [DB::AND, ['effect2AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED], ['effect3AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED, '!']], + [DB::AND, ['effect3AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED], ['effect2AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED, '!']] ]; break; case 2: - $conditions[] = ['OR', ['effect2AuraId', 207], ['effect3AuraId', 207]]; + $conditions[] = [DB::OR, ['effect2AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED], ['effect3AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED]]; break; case 3: - $conditions[] = ['AND', ['effect2AuraId', 32, '!'], ['effect2AuraId', 207, '!'], ['effect3AuraId', 32, '!'],['effect3AuraId', 207, '!']]; + $conditions[] = [DB::AND, + ['effect2AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED, '!'], ['effect2AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED, '!'], + ['effect3AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED, '!'], ['effect3AuraId', SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED, '!'] + ]; break; } } @@ -208,15 +277,15 @@ class SpellsPage extends GenericPage { switch ($this->category[1]) // Spells can be used by multiple specs { - case 409: // Tenacity + case 409: // TalentTab - Tenacity $conditions[] = ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE1, '&']; $url = '?pets=1'; break; - case 410: // Cunning + case 410: // TalentTab - Cunning $conditions[] = ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE2, '&']; $url = '?pets=2'; break; - case 411: // Ferocity + case 411: // TalentTab - Ferocity $conditions[] = ['s.cuFlags', SPELL_CU_PET_TALENT_TYPE0, '&']; $url = '?pets=0'; break; @@ -225,7 +294,7 @@ class SpellsPage extends GenericPage $tabData['note'] = '$$WH.sprintf(LANG.lvnote_pettalents, "'.$url.'")'; } - $tabData['_petTalents'] = 1; // not conviced, this is correct, but .. it works + $tabData['_petTalents'] = 1; break; case -11: // Proficiencies ... the subIds are actually SkillLineCategories @@ -237,7 +306,7 @@ class SpellsPage extends GenericPage if (isset($this->category[1])) { if ($this->category[1] == 6) // todo (med): we know Weapon(6) includes spell Shoot(3018), that has a mask; but really, ANY proficiency or petSkill should be in that mask so there is no need to differenciate - $conditions[] = ['OR', ['s.skillLine1', SpellList::$skillLines[$this->category[1]]], ['s.skillLine1', -3]]; + $conditions[] = [DB::OR, ['s.skillLine1', SpellList::$skillLines[$this->category[1]]], ['s.skillLine1', -3]]; else $conditions[] = ['s.skillLine1', SpellList::$skillLines[$this->category[1]]]; } @@ -270,24 +339,24 @@ class SpellsPage extends GenericPage // $conditions[] = [ // [['s.attributes0', 0x80, '&'], 0], // ~SPELL_ATTR0_HIDDEN_CLIENTSIDE // ['s.attributes0', 0x20, '&'], // SPELL_ATTR0_TRADESPELL (DK: Runeforging) - // 'OR' + // DB::OR // ]; if (isset($this->category[2])) { $conditions[] = [ - 'OR', + DB::OR, ['s.skillLine1', $this->category[2]], - ['AND', ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->category[2]]] + [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->category[2]]] ]; } else if (isset($this->category[1])) { $conditions[] = [ - 'OR', + DB::OR, ['s.skillLine1', $this->validCats[7][$this->category[1]]], - ['AND', ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->validCats[7][$this->category[1]]]] + [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->validCats[7][$this->category[1]]]] ]; } @@ -301,27 +370,27 @@ class SpellsPage extends GenericPage if (isset($this->category[1])) { $conditions[] = [ - 'OR', + DB::OR, ['s.skillLine1', $this->category[1]], - ['AND', ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->category[1]]] + [DB::AND, ['s.skillLine1', 0, '>'], ['s.skillLine2OrMask', $this->category[1]]] ]; - if (!empty($this->shortFilter[$this->category[1]])) + if (!empty(self::SHORT_FILTER[$this->category[1]])) { - $sf = $this->shortFilter[$this->category[1]]; + [$crafted, $relItems] = self::SHORT_FILTER[$this->category[1]]; $txt = ''; - if ($sf[0] && $sf[1]) - $txt = sprintf(Lang::spell('relItems', 'crafted'), $sf[0]) . Lang::spell('relItems', 'link') . sprintf(Lang::spell('relItems', 'recipes'), $sf[1]); - else if ($sf[0]) - $txt = sprintf(Lang::spell('relItems', 'crafted'), $sf[0]); - else if ($sf[1]) - $txt = sprintf(Lang::spell('relItems', 'recipes'), $sf[1]); + if ($crafted && $relItems) + $txt = Lang::spell('relItems', 'crafted', [$crafted]) . Lang::spell('relItems', 'link') . Lang::spell('relItems', 'recipes', [$relItems]); + else if ($crafted) + $txt = Lang::spell('relItems', 'crafted', [$crafted]); + else if ($relItems) + $txt = Lang::spell('relItems', 'recipes', [$relItems]); $note = Lang::spell('cat', $this->category[0], $this->category[1]); if (is_array($note)) $note = $note[0]; - $tabData['note'] = sprintf(Lang::spell('relItems', 'base'), $txt, $note); + $tabData['note'] = Lang::spell('relItems', 'base', [$txt, $note]); $tabData['sort'] = ['skill', 'name']; } } @@ -346,22 +415,22 @@ class SpellsPage extends GenericPage { $conditions[] = ['s.skillLine1', $this->category[1]]; - if (!empty($this->shortFilter[$this->category[1]])) + if (!empty(self::SHORT_FILTER[$this->category[1]])) { - $sf = $this->shortFilter[$this->category[1]]; + [$crafted, $relItems] = self::SHORT_FILTER[$this->category[1]]; $txt = ''; - if ($sf[0] && $sf[1]) - $txt = sprintf(Lang::spell('relItems', 'crafted'), $sf[0]) . Lang::spell('relItems', 'link') . sprintf(Lang::spell('relItems', 'recipes'), $sf[1]); - else if ($sf[0]) - $txt = sprintf(Lang::spell('relItems', 'crafted'), $sf[0]); - else if ($sf[1]) - $txt = sprintf(Lang::spell('relItems', 'recipes'), $sf[1]); + if ($crafted && $relItems) + $txt = Lang::spell('relItems', 'crafted', [$crafted]) . Lang::spell('relItems', 'link') . Lang::spell('relItems', 'recipes', [$relItems]); + else if ($crafted) + $txt = Lang::spell('relItems', 'crafted', [$crafted]); + else if ($relItems) + $txt = Lang::spell('relItems', 'recipes', [$relItems]); $note = Lang::spell('cat', $this->category[0], $this->category[1]); if (is_array($note)) $note = $note[0]; - $tabData['note'] = sprintf(Lang::spell('relItems', 'base'), $txt, $note); + $tabData['note'] = Lang::spell('relItems', 'base', [$txt, $note]); $tabData['sort'] = ['skill', 'name']; } } @@ -371,22 +440,16 @@ class SpellsPage extends GenericPage array_push($visibleCols, 'level'); $conditions[] = [ - 'OR', + DB::OR, ['s.typeCat', 0], - ['AND', ['s.cuFlags', SPELL_CU_TRIGGERED, '&'], ['s.typeCat', [7, -2]]] + [DB::AND, ['s.cuFlags', SPELL_CU_TRIGGERED, '&'], ['s.typeCat', [7, -2]]] ]; break; } } - if (!User::isInGroup(U_GROUP_EMPLOYEE)) - $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; - - if ($_ = $this->filterObj->getConditions()) - $conditions[] = $_; - - $spells = new SpellList($conditions); + $spells = new SpellList($conditions, ['calcTotal' => true]); $this->extendGlobalData($spells->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); @@ -399,62 +462,45 @@ class SpellsPage extends GenericPage { $lvData[$spellId]['speed'] = 0; - if (in_array($spells->getField('effect2AuraId'), [32, 207, 58])) + if (in_array($spells->getField('effect2AuraId'), [SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED, SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED, SPELL_AURA_MOD_INCREASE_SWIM_SPEED])) $lvData[$spellId]['speed'] = $spells->getField('effect2BasePoints') + 1; - if (in_array($spells->getField('effect3AuraId'), [32, 207, 58])) + if (in_array($spells->getField('effect3AuraId'), [SPELL_AURA_MOD_INCREASE_MOUNTED_SPEED, SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED, SPELL_AURA_MOD_INCREASE_SWIM_SPEED])) $lvData[$spellId]['speed'] = max($lvData[$spellId]['speed'], $spells->getField('effect3BasePoints') + 1); - if (!$lvData[$spellId]['speed'] && ($spells->getField('effect2AuraId') == 4 || $spells->getField('effect3AuraId') == 4)) + if (!$lvData[$spellId]['speed'] && ($spells->getField('effect2AuraId') == SPELL_AURA_DUMMY || $spells->getField('effect3AuraId') == SPELL_AURA_DUMMY)) $lvData[$spellId]['speed'] = '?'; else $lvData[$spellId]['speed'] = '+'.$lvData[$spellId]['speed'].'%'; } } - $tabData['data'] = array_values($lvData); + $tabData['data'] = $lvData; - // recreate form selection - $this->filter = $this->filterObj->getForm(); - $this->filter['query'] = $this->_get['filter']; - $this->filter['initData'] = ['init' => 'spells']; - - if ($ec = $this->filterObj->getExtraCols()) - { - $this->filter['initData']['ec'] = $ec; + if ($this->filter->fiExtraCols) $tabData['extraCols'] = '$fi_getExtraCols(fi_extraCols, 0, 0)'; - } else if ($extraCols) $tabData['extraCols'] = $extraCols; - if ($sc = $this->filterObj->getSetCriteria()) - { - $this->filter['initData']['sc'] = $sc; - - // add source to cols if explicitly searching for it - if (in_array(9, $sc['cr']) && !in_array('source', $visibleCols)) - $visibleCols[] = 'source'; - } + // add source to cols if explicitly searching for it + if ($this->filter->getSetCriteria(9) && !in_array('source', $visibleCols)) + $visibleCols[] = 'source'; // create note if search limit was exceeded; overwriting 'note' is intentional - if ($spells->getMatches() > CFG_SQL_LIMIT_DEFAULT) + if ($spells->getMatches() > Listview::DEFAULT_SIZE) { - $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_spellsfound', $spells->getMatches(), CFG_SQL_LIMIT_DEFAULT); + $tabData['note'] = sprintf(Util::$tryFilteringString, 'LANG.lvnote_spellsfound', $spells->getMatches(), Listview::DEFAULT_SIZE); $tabData['_truncated'] = 1; } - if ($this->filterObj->error) - $tabData['_errors'] = 1; - - - $mask = $spells->hasSetFields(['reagent1', 'skillLines', 'trainingCost', 'reqClassMask']); - if ($mask & 0x1) - $visibleCols[] = 'reagents'; - if (!($mask & 0x2) && $this->category && !in_array($this->category[0], [9, 11])) + $mask = $spells->hasSetFields('skillLines', 'trainingCost', 'reqClassMask', null, 'reagent1', 'reagent2', 'reagent3', 'reagent4', 'reagent5', 'reagent6', 'reagent7', 'reagent8'); + if (!($mask & 0x1) && $this->category && !in_array($this->category[0], [9, 11])) $hiddenCols[] = 'skill'; - if ($mask & 0x4) + if ($mask & 0x2) $visibleCols[] = 'trainingcost'; - if (($mask & 0x8) && !in_array('singleclass', $visibleCols)) + if (($mask & 0x4) && !in_array('singleclass', $visibleCols)) $visibleCols[] = 'classes'; + if ($mask & 0xFF0) + $visibleCols[] = 'reagents'; if ($visibleCols) @@ -463,49 +509,22 @@ class SpellsPage extends GenericPage if ($hiddenCols) $tabData['hiddenCols'] = array_unique($hiddenCols); - $this->lvTabs[] = ['spell', $tabData]; + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + $this->lvTabs->addListviewTab(new Listview($tabData, SpellList::$brickFile)); + parent::generate(); + + $this->setOnCacheLoaded([self::class, 'onBeforeDisplay']); + } + + protected static function onBeforeDisplay() : void + { // sort for dropdown-menus Lang::sort('game', 'ra'); Lang::sort('game', 'cl'); Lang::sort('game', 'sc'); Lang::sort('game', 'me'); } - - protected function generateTitle() - { - $foo = []; - $c = $this->category; // shothand - if (isset($c[2]) && $c[0] == 11) - array_unshift($foo, Lang::spell('cat', $c[0], $c[1], $c[2])); - else if (isset($c[1])) - { - $_ = in_array($c[0], [-2, -13, 7]) ? Lang::game('cl') : Lang::spell('cat', $c[0]); - array_unshift($foo, is_array($_[$c[1]]) ? $_[$c[1]][0] : $_[$c[1]]); - } - - if (isset($c[0]) && count($foo) < 2) - { - $_ = Lang::spell('cat', $c[0]); - array_unshift($foo, is_array($_) ? $_[0] : $_); - } - - if (count($foo) < 2) - array_unshift($foo, $this->name); - - foreach ($foo as $bar) - array_unshift($this->title, $bar); - } - - protected function generatePath() - { - foreach ($this->category as $c) - $this->path[] = $c; - - $form = $this->filterObj->getForm(); - if (count($this->path) == 4 && $this->category[0] == -13 && isset($form['gl']) && count($form['gl']) == 1) - $this->path[] = $form['gl']; - } } ?> diff --git a/endpoints/talent/talent.php b/endpoints/talent/talent.php new file mode 100644 index 00000000..0ea35fd7 --- /dev/null +++ b/endpoints/talent/talent.php @@ -0,0 +1,39 @@ +h1 = Lang::main('talentCalc'); + $this->chooseType = Lang::main('chooseClass'); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/title/title.php b/endpoints/title/title.php new file mode 100644 index 00000000..21959316 --- /dev/null +++ b/endpoints/title/title.php @@ -0,0 +1,203 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new TitleList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('title'), Lang::title('notFound')); + + $this->h1 = $this->subject->getHtmlizedName(); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_title = Util::ucFirst(trim(strtr($this->subject->getField('male', true), ['%s' => '', ',' => '']))); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('category');; + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $_title, Util::ucFirst(Lang::game('title'))); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + $infobox[] = Lang::main('side') . match ($this->subject->getField('side')) + { + SIDE_ALLIANCE => '[span class=icon-alliance]'.Lang::game('si', SIDE_ALLIANCE).'[/span]', + SIDE_HORDE => '[span class=icon-horde]'.Lang::game('si', SIDE_HORDE).'[/span]', + default => Lang::game('si', SIDE_BOTH) // 0, 3 + }; + + if ($g = $this->subject->getField('gender')) + $infobox[] = Lang::main('gender').Lang::main('colon').'[span class=icon-'.($g == 2 ? 'female' : 'male').']'.Lang::main('sex', $g).'[/span]'; + + if ($eId = $this->subject->getField('eventId')) + { + $this->extendGlobalIds(Type::WORLDEVENT, $eId); + $infobox[] = Lang::game('eventShort', ['[event='.$eId.']']); + } + + // id + $infobox[] = Lang::title('id') . $this->typeId; + + // profiler relateed (note that this is part of the cache. I don't think this is important enough to calc for every view) + if (Cfg::get('PROFILER_ENABLE')) + { + $x = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_completion_titles WHERE `titleId` = %i', $this->typeId); + $y = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::profiler_profiles WHERE `custom` = 0 AND `stub` = 0'); + $infobox[] = Lang::profiler('attainedBy', [round(($x ?: 0) * 100 / ($y ?: 1))]); + + // completion row added by InfoboxMarkup + } + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0', 1); + + + /****************/ + /* Main Content */ + /****************/ + + $this->expansion = Util::$expansionString[$this->subject->getField('expansion')]; + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId] + ); + + // factionchange-equivalent + if ($pendant = DB::World()->selectCell('SELECT IF(`horde_id` = %i, `alliance_id`, -`horde_id`) FROM player_factionchange_titles WHERE `alliance_id` = %i OR `horde_id` = %i', $this->typeId, $this->typeId, $this->typeId)) + { + $altTitle = new TitleList(array(['id', abs($pendant)])); + if (!$altTitle->error) + { + $this->transfer = Lang::title('_transfer', array( + $altTitle->id, + $altTitle->getHtmlizedName(), + $pendant > 0 ? 'alliance' : 'horde', + $pendant > 0 ? Lang::game('si', SIDE_ALLIANCE) : Lang::game('si', SIDE_HORDE) + )); + } + } + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: reward-from-quest + $quests = new QuestList(array(['rewardTitleId', $this->typeId])); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals(GLOBALINFO_REWARDS)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $quests->getListviewData(), + 'id' => 'reward-from-quest', + 'name' => '$LANG.tab_rewardfrom', + 'hiddenCols' => ['experience', 'money'], + 'visibleCols' => ['category'] + ), QuestList::$brickFile)); + } + + // tab: reward-from-achievement + if ($aIds = DB::World()->selectCol('SELECT `ID` FROM achievement_reward WHERE `TitleA` = %i OR `TitleH` = %i', $this->typeId, $this->typeId)) + { + $acvs = new AchievementList(array(['id', $aIds])); + if (!$acvs->error) + { + $this->extendGlobalData($acvs->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $acvs->getListviewData(), + 'id' => 'reward-from-achievement', + 'name' => '$LANG.tab_rewardfrom', + 'visibleCols' => ['category'], + 'sort' => ['reqlevel', 'name'] + ), AchievementList::$brickFile)); + } + } + + // tab: criteria-of + if ($crt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` = %i AND `value1` = %i', ACHIEVEMENT_CRITERIA_DATA_TYPE_S_KNOWN_TITLE, $this->typeId)) + { + $acvs = new AchievementList(array(['ac.id', $crt])); + if (!$acvs->error) + { + $this->extendGlobalData($acvs->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $acvs->getListviewData(), + 'id' => 'criteria-of', + 'name' => '$LANG.tab_criteriaof', + 'visibleCols' => ['category'] + ), AchievementList::$brickFile)); + } + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::TITLE, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/titles/titles.php b/endpoints/titles/titles.php new file mode 100644 index 00000000..a6c3d4d5 --- /dev/null +++ b/endpoints/titles/titles.php @@ -0,0 +1,75 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('titles')); + + + if ($this->category) + $this->breadcrumb[] = $this->category[0]; + + + array_unshift($this->title, $this->h1); + if ($this->category) + array_unshift($this->title, Lang::title('cat', $this->category[0])); + + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = [Listview::DEFAULT_SIZE]; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) // hide unused titles + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($this->category) + $conditions[] = ['category', $this->category[0]]; + + $tabData = ['data' => []]; + $titles = new TitleList($conditions); + if (!$titles->error) + { + $tabData['data'] = $titles->getListviewData(); + + if ($titles->hasDiffFields('category')) + $tabData['visibleCols'] = ['category']; + + if (!$titles->hasAnySource()) + $tabData['hiddenCols'] = ['source']; + } + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview($tabData, TitleList::$brickFile)); + + parent::generate(); + } +} + +?> diff --git a/endpoints/tooltips/tooltips.php b/endpoints/tooltips/tooltips.php new file mode 100644 index 00000000..7ead16a0 --- /dev/null +++ b/endpoints/tooltips/tooltips.php @@ -0,0 +1,34 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/top-users/top-users.php b/endpoints/top-users/top-users.php new file mode 100644 index 00000000..464fea0a --- /dev/null +++ b/endpoints/top-users/top-users.php @@ -0,0 +1,96 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + $tabs = array( + [0, 'top-users-alltime', '$LANG.alltime_stc' ], + [time() - MONTH, 'top-users-monthly', '$LANG.lastmonth_stc'], + [time() - WEEK, 'top-users-weekly', '$LANG.lastweek_stc' ] + ); + + // expected by javascript but metrics are not used by us + $nullFields = array( + 'uploads' => 0, // wow client cache uploads + 'posts' => 0, // forum posts + 'gold' => 0, // site achievements + 'silver' => 0, + 'copper' => 0 + ); + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], __forceTabs: true); + + foreach ($tabs as [$time, $tabId, $tabName]) + { + // stuff received + $res = DB::Aowow()->selectAssoc( + 'SELECT a.`id` AS ARRAY_KEY, a.`username`, a.`userGroups` AS "groups", a.`joinDate` AS "creation", + SUM(r.`amount`) AS "reputation", SUM(IF(r.`action` = %i, 1, 0)) AS "comments", SUM(IF(r.`action` = %i, 1, 0)) AS "screenshots", SUM(IF(r.`action` = %i, 1, 0)) AS "reports" + FROM ::account_reputation r + JOIN ::account a ON a.`id` = r.`userId`', + SITEREP_ACTION_COMMENT, SITEREP_ACTION_SUBMIT_SCREENSHOT, SITEREP_ACTION_GOOD_REPORT, + '%if', $time, 'WHERE r.`date` > %i', $time, '%end + GROUP BY a.`id` + ORDER BY reputation DESC + LIMIT %i', + self::MAX_RESULTS + ); + + $data = []; + if ($res) + { + // stuff given + $votes = DB::Aowow()->selectCol('SELECT `sourceB` AS ARRAY_KEY, SUM(1) FROM ::account_reputation WHERE %if', $time, '`date` > %i AND', $time, '%end `action` IN %in AND `sourceB` IN %in GROUP BY `sourceB`', + [SITEREP_ACTION_UPVOTED, SITEREP_ACTION_DOWNVOTED], array_keys($res), + ); + foreach ($res as $uId => &$r) + { + $r['creation'] = date('c', $r['creation']); + $r['votes'] = empty($votes[$uId]) ? 0 : $votes[$uId]; + $r += $nullFields; + } + + $data = $res; + } + + $this->lvTabs->addListviewTab(new Listview(array( + 'hiddenCols' => ['achievements', 'posts', 'uploads', 'reports'], + 'visibleCols' => ['created'], + 'name' => '$LANG.lastweek_stc', + 'name' => $tabName, + 'id' => $tabId, + 'data' => $data + ), 'topusers')); + } + + parent::generate(); + } +} + +?> diff --git a/endpoints/unrated-comments/unrated-comments.php b/endpoints/unrated-comments/unrated-comments.php new file mode 100644 index 00000000..2b7fefc5 --- /dev/null +++ b/endpoints/unrated-comments/unrated-comments.php @@ -0,0 +1,41 @@ + Util > Unrated Comments + + protected function generate() : void + { + $this->h1 = Lang::main('utilities', 5); + + + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, $this->h1); + + + /****************/ + /* Main Content */ + /****************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $data = CommunityContent::getCommentPreviews(['unrated' => true, 'comments' => true], resultLimit: Listview::DEFAULT_SIZE); + $this->lvTabs->addListviewTab(new Listview(['data' => $data], 'commentpreview')); + + parent::generate(); + } +} + +?> diff --git a/endpoints/upload/image-complete.php b/endpoints/upload/image-complete.php new file mode 100644 index 00000000..ab9c111d --- /dev/null +++ b/endpoints/upload/image-complete.php @@ -0,0 +1,88 @@ + ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkCoords']], + ); + + public string $imgHash; + public int $newId; + + public function __construct(string $rawParam) + { + if (User::isBanned()) + $this->generate404(); + + parent::__construct($rawParam); + + if (!preg_match('/^upload=image-complete&(\d+)\.(\w{16})$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generate404(); + + [, $this->newId, $this->imgHash] = $m; + + if (!$this->imgHash || !$this->newId) + $this->generate404(); + } + + protected function generate() : void + { + if (!$this->handleComplete()) + $_SESSION['msg'] = ['avatar', false, AvatarMgr::$error ?: Lang::main('intError')]; + } + + private function handleComplete() : bool + { + if (!$this->assertPOST('coords')) + return false; + + if (!AvatarMgr::init()) + return false; + + if (!AvatarMgr::loadFile(AvatarMgr::PATH_TEMP, User::$username.'-avatar-'.$this->newId.'-'.$this->imgHash.'_original')) + return false; + + if (!AvatarMgr::cropImg(...$this->_post['coords'])) + return false; + + if (!AvatarMgr::createAtlas($this->newId)) + return false; + + $fSize = filesize(sprintf(AvatarMgr::PATH_AVATARS, $this->newId)); + if (!$fSize) + return false; + + $newId = DB::Aowow()->qry('INSERT INTO ::account_avatars (`id`, `userId`, `name`, `when`, `size`) VALUES (%i, %i, %s, %i, %i)', $this->newId, User::$id, 'Avatar '.$this->newId, time(), $fSize); + if (!is_int($newId)) + { + trigger_error('UploadImagecompleteResponse - avatar query failed', E_USER_ERROR); + return false; + } + + // delete temp files + unlink(sprintf(AvatarMgr::PATH_TEMP, User::$username.'-avatar-'.$this->newId.'-'.$this->imgHash.'_original')); + unlink(sprintf(AvatarMgr::PATH_TEMP, User::$username.'-avatar-'.$this->newId.'-'.$this->imgHash)); + + return true; + } + + protected static function checkCoords(string $val) : ?array + { + if (preg_match('/^[01]\.[0-9]{3}(,[01]\.[0-9]{3}){3}$/', $val)) + return explode(',', $val); + + return null; + } +} + +?> diff --git a/endpoints/upload/image-crop.php b/endpoints/upload/image-crop.php new file mode 100644 index 00000000..cf7f8279 --- /dev/null +++ b/endpoints/upload/image-crop.php @@ -0,0 +1,84 @@ +generateError(); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + if ($err = $this->handleUpload()) + { + $_SESSION['msg'] = ['avatar', false, $err]; + $this->forward('?account#community'); + } + + $this->h1 = Lang::account('avatarSubmit'); + + $fileBase = User::$username.'-avatar-'.$this->nextId.'-'.$this->imgHash; + $dimensions = AvatarMgr::calcImgDimensions(); + + $this->cropper = $dimensions + array( + 'url' => Cfg::get('STATIC_URL').'/uploads/temp/'.$fileBase.'.jpg', + 'parent' => 'av-container', + 'minCrop' => ICON_SIZE_LARGE, // optional; defaults to 150 - min selection size (a square) + 'type' => Type::NPC, // NPC: 15384 [OLDWorld Trigger (DO NOT DELETE)] + 'typeId' => 15384, // = arbitrary image upload + 'constraint' => [1, 1] // [xMult, yMult] - relative size to each other (here: be square) + ); + + parent::generate(); + } + + private function handleUpload() : string + { + if (!AvatarMgr::init()) + return Lang::main('intError'); + + if (!AvatarMgr::validateUpload()) + return AvatarMgr::$error; + + if (!AvatarMgr::loadUpload()) + return Lang::main('intError'); + + $n = DB::Aowow()->selectCell('SELECT COUNT(1) FROM ::account_avatars WHERE `userId` = %i', User::$id); + if ($n && $n > Cfg::get('ACC_MAX_AVATAR_UPLOADS')) + return Lang::main('intError'); + + // why is ++(); illegal syntax? WHO KNOWS!? + $this->nextId = (DB::Aowow()->selectCell('SELECT MAX(`id`) FROM ::account_avatars') ?: 0) + 1; + + if (!AvatarMgr::tempSaveUpload(['avatar', $this->nextId], $this->imgHash)) + return Lang::main('intError'); + + return ''; + } +} + +?> diff --git a/endpoints/user/user.php b/endpoints/user/user.php new file mode 100644 index 00000000..cff2c560 --- /dev/null +++ b/endpoints/user/user.php @@ -0,0 +1,390 @@ +forward('?user='.User::$username); + + if (!$rawParam) + $this->forwardToSignIn('user'); + + if ($user = DB::Aowow()->selectRow('SELECT a.`id`, a.`username`, a.`consecutiveVisits`, a.`userGroups`, a.`avatar`, a.`avatarborder`, a.`wowicon`, a.`title`, a.`description`, a.`joinDate`, a.`prevLogin`, IFNULL(SUM(ar.`amount`), 0) AS "sumRep", a.`prevIP`, a.`email` FROM ::account a LEFT JOIN ::account_reputation ar ON a.`id` = ar.`userId` WHERE a.`id` <> 0 AND LOWER(a.`username`) = LOWER(%s) GROUP BY a.`id`', $rawParam)) + $this->user = $user; + else + $this->generateNotFound(Lang::user('notFound', [Util::htmlEscape($rawParam)])); + } + + protected function generate() : void + { + /*********/ + /* Title */ + /*********/ + + array_unshift($this->title, Lang::user('profileTitle', [$this->user['username']])); + + + /***********/ + /* Infobox */ + /***********/ + + $infobox = $contrib = $groups = []; + + foreach (Lang::account('groups') as $idx => $grp) + if ($idx >= 0 && $this->user['userGroups'] & (1 << $idx)) + $groups[] = (!fMod(count($groups) + 1, 3) ? '[br]' : '').$grp; + + if (User::isInGroup(U_GROUP_STAFF)) + { + $infobox[] = Lang::account('lastIP'). $this->user['prevIP']; + $infobox[] = Lang::account('email') . Lang::main('colon') . $this->user['email']; + } + + if ($this->user['joinDate']) + $infobox[] = Lang::user('joinDate') . '[tooltip name=joinDate]'. date('l, G:i:s', $this->user['joinDate']). '[/tooltip][span class=tip tooltip=joinDate]'.(new DateTime())->formatDate($this->user['joinDate']). '[/span]'; + if ($this->user['prevLogin']) + $infobox[] = Lang::user('lastLogin') . '[tooltip name=lastLogin]'.date('l, G:i:s', $this->user['prevLogin']).'[/tooltip][span class=tip tooltip=lastLogin]'.(new DateTime())->formatDate($this->user['prevLogin']).'[/span]'; + if ($groups) + $infobox[] = Lang::user('userGroups') . implode(', ', $groups); + + $infobox[] = Lang::user('consecVisits'). $this->user['consecutiveVisits']; + + if ($this->user['sumRep']) + $infobox[] = Lang::main('siteRep') . Lang::nf($this->user['sumRep']); + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF], 'infobox-contents0'); + + if ($_ = $this->getCommentStats()) + $contrib[] = $_; + + if ($_ = $this->getScreenshotStats()) + $contrib[] = $_; + + if ($_ = $this->getVideoStats()) + $contrib[] = $_; + + if ($_ = $this->getForumStats()) + $contrib[] = $_; + + // $contrib[] = [url=http://www.wowhead.com/client]Data uploads: n [small]([tooltip=tooltip_totaldatauploads]xx.y MB[/tooltip])[/small][/url] + + if ($contrib) + $this->contributions = new InfoboxMarkup($contrib, ['allow' => Markup::CLASS_STAFF], 'infobox-contents1'); + + + /****************/ + /* Main Content */ + /****************/ + + $this->h1 = $this->user['title'] ? $this->user['username'].' <'.$this->user['title'].'>' : Lang::user('profileTitle', [$this->user['username']]); + + if ($this->user['avatar']) + { + $avatarMore = match ((int)$this->user['avatar']) + { + 1 => $this->user['wowicon'], + 2 => DB::Aowow()->selectCell('SELECT `id` FROM ::account_avatars WHERE `current` = 1 AND `userId` = %i', $this->user['id']), + default => '' + }; + + if (!($this->user['userGroups'] & U_GROUP_PREMIUM)) + $this->user['avatarborder'] = 2; + + $this->userIcon = array( // JS: Icon.createUser() + $this->user['avatar'], // avatar: 1(iconString), 2(customId) + $avatarMore, // avatarMore: iconString or customId + IconElement::SIZE_MEDIUM, // size: (always medium) + null, // url: (always null) + $this->user['avatarborder'], // premiumLevel: affixes css class ['-premium', '-gold', '', '-premiumred', '-red'] + false, // noBorder: always false + '$Icon.getPrivilegeBorder('.$this->user['sumRep'].')' // reputationLevel: calculated in js from passed rep points + ); + } + + $this->username = $this->user['username']; + + if ($this->user['description']) // seen CLASS_STAFF, but wouldn't dare.. filtered for restricted tags before sent? + $this->description = new Markup($this->user['description'], ['allow' => ($this->user['userGroups'] & U_GROUP_PREMIUM) ? Markup::CLASS_PREMIUM : Markup::CLASS_USER], 'description-generic'); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // [unused] Site Achievements + + // Reputation changelog (params only for comment-events) + if (User::$id == $this->user['id'] || User::isInGroup(U_GROUP_MODERATOR)) + if ($repData = DB::Aowow()->selectAssoc('SELECT `action`, `amount`, `date` AS "when", IF(`action` IN (3, 4, 5), `sourceA`, 0) AS "param" FROM ::account_reputation WHERE `userId` = %i', $this->user['id'])) + { + array_walk($repData, fn(&$x) => $x['when'] = date(Util::$dateFormatInternal, $x['when'])); + $this->lvTabs->addListviewTab(new Listview(['data' => $repData], 'reputationhistory')); + } + + // Comments + if ($_ = CommunityContent::getCommentPreviews(['user' => $this->user['id'], 'comments' => true], $nFound, resultLimit: Listview::DEFAULT_SIZE)) + { + $tabData = array( + 'data' => $_, + 'hiddenCols' => ['author'], + 'onBeforeCreate' => '$Listview.funcBox.beforeUserComments', + '_totalCount' => $nFound + ); + + if ($nFound > Listview::DEFAULT_SIZE) + { + $tabData['name'] = '$LANG.tab_latestcomments'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_usercomments, '.$nFound.')'; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, 'commentpreview')); + } + + // Comment Replies + if ($_ = CommunityContent::getCommentPreviews(['user' => $this->user['id'], 'replies' => true], $nFound, resultLimit: Listview::DEFAULT_SIZE)) + { + $tabData = array( + 'data' => $_, + 'hiddenCols' => ['author'], + 'onBeforeCreate' => '$Listview.funcBox.beforeUserComments', + '_totalCount' => $nFound + ); + + if ($nFound > Listview::DEFAULT_SIZE) + { + $tabData['name'] = '$LANG.tab_latestreplies'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_userreplies, '.$nFound.')'; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, 'replypreview')); + } + + // Screenshots + if ($_ = CommunityContent::getScreenshots(-$this->user['id'], 0, $nFound, resultLimit: Listview::DEFAULT_SIZE)) + { + $tabData = array( + 'data' => $_, + '_totalCount' => $nFound + ); + + if ($nFound > Listview::DEFAULT_SIZE) + { + $tabData['name'] = '$LANG.tab_latestscreenshots'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_userscreenshots, '.$nFound.')'; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, 'screenshot')); + } + + // Videos + if ($_ = CommunityContent::getVideos(-$this->user['id'], 0, $nFound, resultLimit: Listview::DEFAULT_SIZE)) + { + $tabData = array( + 'data' => $_, + '_totalCount' => $nFound + ); + + if ($nFound > Listview::DEFAULT_SIZE) + { + $tabData['name'] = '$LANG.tab_latestvideos'; + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_uservideos, '.$nFound.')'; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, 'video')); + } + + // forum -> latest topics [unused] + + // forum -> latest replies [unused] + + if (Cfg::get('PROFILER_ENABLE')) + { + $conditions = [['user', $this->user['id']]]; + if (User::$id != $this->user['id'] && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $conditions[] = ['cuFlags', PROFILER_CU_PUBLISHED, '&']; + if (!User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $conditions[] = ['deleted', 0]; + + $profiles = new LocalProfileList($conditions); + if (!$profiles->error) + { + $this->addDataLoader('weight-presets'); + + if ($prof = $profiles->getListviewData(PROFILEINFO_PROFILE | PROFILEINFO_USER)) + $this->profilesLvData = array_values($prof); + } + + $conditions = [['ap.accountId', $this->user['id']]]; + if (User::$id != $this->user['id'] && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $conditions[] = ['ap.extraFlags', PROFILER_CU_PUBLISHED, '&']; + + $characters = new LocalProfileList($conditions); + if (!$characters->error) + { + $this->addDataLoader('weight-presets'); + + if ($chars = $characters->getListviewData(PROFILEINFO_CHARACTER | PROFILEINFO_USER)) + $this->charactersLvData = array_values($chars); + } + + // signatures + /* $this->lvTabs->addListviewTab(new Listview(array( + * 'id' => 'signatures', + * 'name' => '$LANG.tab_signatures', + * 'hiddenCols' => ['name','faction','location','guild'], + * 'extraCols' => ['$Listview.extraCols.signature'], + * 'onBeforeCreate' => '$Listview.funcBox.beforeUserSignatures', + * 'data' => [ ProfileList->getListviewData() ] // no extra signature related data observed + * ), 'profile')); + */ + } + + // My Guides + $guides = new GuideList(array(['status', [GuideMgr::STATUS_APPROVED, GuideMgr::STATUS_ARCHIVED]], ['userId', $this->user['id']])); + if (!$guides->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $guides->getListviewData(), + 'hiddenCols' => ['patch'] + ), GuideList::$brickFile)); + } + + parent::generate(); + } + + private function getCommentStats() : ?string + { + $co = DB::Aowow()->selectRow( + 'SELECT COUNT(DISTINCT c.`id`) AS "0", SUM(IFNULL(ur.`value`, 0)) AS "1" FROM ::comments c LEFT JOIN ::user_ratings ur ON ur.`entry` = c.`id` AND ur.`type` = %i AND ur.`userId` <> 0 WHERE c.`replyTo` = 0 AND c.`userId` = %i', + RATING_COMMENT, $this->user['id'] + ); + + if (!$co) + return null; + + [$sum, $nRatings] = $co; + + if (!$sum) + return null; + + return Lang::user('comments').$sum.($nRatings ? ' [small]([tooltip=tooltip_totalratings]'.$nRatings.'[/tooltip])[/small]' : ''); + } + + private function getScreenshotStats() : ?string + { + $ss = DB::Aowow()->selectRow( + 'SELECT COUNT(*) AS "0", SUM(IF(`status` & %i, 1, 0)) AS "1", SUM(IF(`status` & %i, 0, 1)) AS "2" FROM ::screenshots WHERE `userIdOwner` = %i AND (`status` & %i) = 0', + CC_FLAG_STICKY, CC_FLAG_APPROVED, $this->user['id'], CC_FLAG_DELETED + ); + + if (!$ss) + return null; + + [$sum, $nSticky, $nPending] = $ss; + + if (!$sum) + return null; + + $buff = []; + if ($nSticky || $nPending) + { + if ($normal = ($sum - $nSticky - $nPending)) + $buff[] = '[tooltip=tooltip_normal]'.$normal.'[/tooltip]'; + + if ($nSticky) + $buff[] = '[tooltip=tooltip_sticky]'.$nSticky.'[/tooltip]'; + + if ($nPending) + $buff[] = '[tooltip=tooltip_pending]'.$nPending.'[/tooltip]'; + } + + return Lang::user('screenshots').$sum.($buff ? ' [small]('.implode(' + ', $buff).')[/small]' : ''); + } + + private function getVideoStats() : ?string + { + $vi = DB::Aowow()->selectRow( + 'SELECT COUNT(*) AS "0", SUM(IF(`status` & %i, 1, 0)) AS "1", SUM(IF(`status` & %i, 0, 1)) AS "2" FROM ::videos WHERE `userIdOwner` = %i AND (`status` & %i) = 0', + CC_FLAG_STICKY, CC_FLAG_APPROVED, $this->user['id'], CC_FLAG_DELETED + ); + + if (!$vi) + return null; + + [$sum, $nSticky, $nPending] = $vi; + + if (!$sum) + return null; + + $buff = []; + if ($nSticky || $nPending) + { + if ($normal = ($sum - $nSticky - $nPending)) + $buff[] = '[tooltip=tooltip_normal]'.$normal.'[/tooltip]'; + + if ($nSticky) + $buff[] = '[tooltip=tooltip_sticky]'.$nSticky.'[/tooltip]'; + + if ($nPending) + $buff[] = '[tooltip=tooltip_pending]'.$nPending.'[/tooltip]'; + } + + return Lang::user('videos').$sum.($buff ? ' [small]('.implode(' + ', $buff).')[/small]' : ''); + } + + private function getForumStats() : ?string + { + $fo = null; // some query + + if (!$fo) + return null; + + [$nTopics, $nReplies] = $fo; + + $buff = []; + if ($nTopics) + $buff[] = '[tooltip=topics]'.$nTopics.'[/tooltip]'; + + if ($nReplies) + $buff[] = '[tooltip=replies]'.$nReplies.'[/tooltip]'; + + if (!$buff) + return null; + + return Lang::user('posts').($nTopics + $nReplies).($buff ? ' [small]('.implode(' + ', $buff).')[/small]' : ''); + } +} + +?> diff --git a/endpoints/video/add.php b/endpoints/video/add.php new file mode 100644 index 00000000..9459e9a9 --- /dev/null +++ b/endpoints/video/add.php @@ -0,0 +1,124 @@ + 1. =add: receives user upload + 1.1. checks and processing on the upload + 1.2. forward to =confirm or blank response + 2. =confirm: user edites upload + 3. =complete: store edited video file and data + 4. =thankyou +*/ + +class VideoAddResponse extends TextResponse +{ + protected bool $requiresLogin = true; + + protected array $expectedPOST = array( + 'videourl' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + private string $videoHash = ''; + private int $destType = 0; + private int $destTypeId = 0; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get video destination + // target delivered as video=&.. (hash is optional) + if (!preg_match('/^video=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generate404(); + + [, $this->destType, $this->destTypeId, , $videoHash] = $m; + + // no such type or this type cannot receive videos + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_VI)) + $this->generate404(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generate404(); + + // only accept/expect hash for confirm & complete + if ($videoHash) + $this->generate404(); + } + + protected function generate() : void + { + if ($this->handleAdd()) + $this->redirectTo = '?video=confirm&'.$this->destType.'.'.$this->destTypeId.'.'.$this->videoHash; + else if ($this->destType && $this->destTypeId) + $this->redirectTo = '?'.Type::getFileString($this->destType).'='.$this->destTypeId.'#suggest-a-video'; + else + $this->generate404(); + } + + private function handleAdd() : bool + { + if (!User::canSuggestVideo()) + { + $_SESSION['error']['vi'] = Lang::video('error', 'notAllowed'); + return false; + } + + if (!$this->assertPOST('videourl')) + { + $_SESSION['error']['vi'] = Lang::video('error', 'selectVI'); + return false; + } + + $videoId = ''; + if (preg_match('/^https?:\/\/(www\.)?youtu(\.be|be\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/', $this->_post['videourl'], $m)) + $videoId = $m[3]; + else + { + $_SESSION['error']['vi'] = Lang::video('error', 'selectVI'); + return false; + } + + $curl = curl_init('https://youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v='.$videoId); + if (!$curl) + { + trigger_error('VideoAddResponse - curl_init fail', E_USER_ERROR); + $_SESSION['error']['vi'] = Lang::main('intError'); + return false; + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + $ytOembed = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + + if ($status == 401) + { + $_SESSION['error']['vi'] = Lang::video('error', 'isPrivate'); + return false; + } + else if ($status != 200) // 404, 500 seen .. does it matter why its inaccessible? + { + $_SESSION['error']['vi'] = Lang::video('error', 'noExist'); + return false; + } + + $videoInfo = json_decode($ytOembed); + $videoInfo->id = $videoId; + + if (!VideoMgr::saveSuggestion($videoInfo, $this->destType, $this->destTypeId, $this->videoHash)) + { + $_SESSION['error']['ss'] = Lang::main('intError'); + return false; + } + + return true; + } +} + +?> diff --git a/endpoints/video/complete.php b/endpoints/video/complete.php new file mode 100644 index 00000000..3bb554aa --- /dev/null +++ b/endpoints/video/complete.php @@ -0,0 +1,92 @@ + 3. =complete: store edited video file and data + 4. =thankyou +*/ + +class VideoCompleteResponse extends TextResponse +{ + use TrCommunityHelper; + + protected bool $requiresLogin = true; + + protected array $expectedPOST = array( + 'caption' => ['filter' => FILTER_CALLBACK, 'options' => [self::class, 'checkTextLine']] + ); + + private string $videoHash = ''; + private int $destType = 0; + private int $destTypeId = 0; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get video destination + // target delivered as video=&.. (hash is optional) + if (!preg_match('/^video=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generate404(); + + [, $this->destType, $this->destTypeId, , $this->videoHash] = $m; + + // no such type or this type cannot receive videos + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_VI)) + $this->generate404(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generate404(); + } + + protected function generate() : void + { + if ($this->handleComplete()) + $this->forward('?video=thankyou&'.$this->destType.'.'.$this->destTypeId); + else + $this->generate404(); + } + + private function handleComplete() : bool + { + if (!VideoMgr::loadSuggestion($videoInfo, $this->destType, $this->destTypeId, $this->videoHash)) + $this->generate404(); + + $pos = DB::Aowow()->selectCell('SELECT MAX(`pos`) FROM ::videos WHERE `type` = %i AND `typeId` = %i AND (`status` & %i) = 0', $this->destType, $this->destTypeId, CC_FLAG_DELETED); + if (!is_int($pos)) + $pos = -1; + + // write to db + $newId = DB::Aowow()->qry( + 'INSERT INTO ::videos (`type`, `typeId`, `userIdOwner`, `date`, `videoId`, `pos`, `url`, `width`, `height`, `name`, `caption`, `status`) VALUES (%i, %i, %i, UNIX_TIMESTAMP(), %s, %i, %s, %i, %i, %s, %s, 0)', + $this->destType, $this->destTypeId, User::$id, + $videoInfo->id, + $pos + 1, + $videoInfo->thumbnail_url, + $videoInfo->thumbnail_width, + $videoInfo->thumbnail_height, + $videoInfo->title, + $this->handleCaption($this->_post['caption']) + ); + + if (!is_int($newId)) // 0 is valid, NULL or FALSE is not + { + trigger_error('VideoCompleteResponse - video query failed', E_USER_ERROR); + return false; + } + + VideoMgr::dropTempFile(); + + return true; + } +} + +?> diff --git a/endpoints/video/confirm.php b/endpoints/video/confirm.php new file mode 100644 index 00000000..46ed385a --- /dev/null +++ b/endpoints/video/confirm.php @@ -0,0 +1,81 @@ + 2. =crop: user edites upload + 2.1. just show edit page + 2.2. user submits coords and description to =complete + 3. =complete: store edited video file and data + 4. =thankyou +*/ + +class VideoConfirmResponse extends TemplateResponse +{ + protected bool $requiresLogin = true; + + protected string $template = 'video'; + protected string $pageName = 'video'; + + public ?Markup $infobox = null; + public string $videoHash = ''; + public int $destType = 0; + public int $destTypeId = 0; + public string $url = ''; + public int $width = 0; + public int $height = 0; + public array $video = []; + public string $viTitle = ''; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get video destination + // target delivered as video=&.. (hash is optional) + if (!preg_match('/^video=\w+&(-?\d+)\.(-?\d+)(\.(\w{16}))?$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generateError(); + + [, $this->destType, $this->destTypeId, , $this->videoHash] = $m; + + // no such type or this type cannot receive videos + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_VI)) + $this->generateError(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::video('submission'); + array_unshift($this->title, $this->h1); + + if (!VideoMgr::loadSuggestion($videoInfo, $this->destType, $this->destTypeId, $this->videoHash)) + $this->generateError(); + + $this->viTitle = $videoInfo->title; + $this->url = $videoInfo->thumbnail_url; + $this->width = $videoInfo->thumbnail_width; + $this->height = $videoInfo->thumbnail_height; + $this->video = [[ + 'videoType' => VideoMgr::TYPE_YOUTUBE, + 'videoId' => $videoInfo->id, + 'caption' => $videoInfo->title + ]]; + + // target + $this->infobox = new Markup(Lang::screenshot('displayOn', [Lang::typeName($this->destType), Type::getFileString($this->destType), $this->destTypeId]), ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + $this->extendGlobalIds($this->destType, $this->destTypeId); + + parent::generate(); + } +} + +?> diff --git a/endpoints/video/thankyou.php b/endpoints/video/thankyou.php new file mode 100644 index 00000000..f07822ee --- /dev/null +++ b/endpoints/video/thankyou.php @@ -0,0 +1,60 @@ + 4. =thankyou +*/ + +class VideoThankyouResponse extends TemplateResponse +{ + protected bool $requiresLogin = true; + + protected string $template = 'text-page-generic'; + protected string $pageName = 'video'; + + private int $destType = 0; + private int $destTypeId = 0; + + public function __construct(string $rawParam) + { + parent::__construct($rawParam); + + // get video destination + // target delivered as video=&. + if (!preg_match('/^video=\w+&(-?\d+)\.(-?\d+)$/i', $_SERVER['QUERY_STRING'] ?? '', $m, PREG_UNMATCHED_AS_NULL)) + $this->generateError(); + + [, $this->destType, $this->destTypeId] = $m; + + // no such type or this type cannot receive videos + if (!Type::checkClassAttrib($this->destType, 'contribute', CONTRIBUTE_VI)) + $this->generateError(); + + // no such typeId + if (!Type::validateIds($this->destType, $this->destTypeId)) + $this->generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::video('submission'); + + array_unshift($this->title, $this->h1); + + $this->extraHTML = Lang::video('thanks', 'contrib').'

'; + $this->extraHTML .= Lang::video('thanks', 'goBack', [Type::getFileString($this->destType), $this->destTypeId])."

\n"; + $this->extraHTML .= ''.Lang::video('thanks', 'note').''; + + parent::generate(); + } +} + +?> diff --git a/endpoints/whats-new/whats-new.php b/endpoints/whats-new/whats-new.php new file mode 100644 index 00000000..63124dfe --- /dev/null +++ b/endpoints/whats-new/whats-new.php @@ -0,0 +1,34 @@ +generateError(); + } + + protected function generate() : void + { + $this->h1 = Lang::main('moreTitles', $this->pageName); + + array_unshift($this->title, $this->h1); + + parent::generate(); + } +} + +?> diff --git a/endpoints/zone/zone.php b/endpoints/zone/zone.php new file mode 100644 index 00000000..3e54eb4f --- /dev/null +++ b/endpoints/zone/zone.php @@ -0,0 +1,952 @@ +typeId = intVal($id); + $this->contribute = Type::getClassAttrib($this->type, 'contribute') ?? CONTRIBUTE_NONE; + } + + protected function generate() : void + { + $this->subject = new ZoneList(array(['id', $this->typeId])); + if ($this->subject->error) + $this->generateNotFound(Lang::game('zone'), Lang::zone('notFound')); + + $this->h1 = $this->subject->getField('name', true); + + $this->gPageInfo += array( + 'type' => $this->type, + 'typeId' => $this->typeId, + 'name' => $this->h1 + ); + + $_parentArea = $this->subject->getField('parentArea'); + $_type = $this->subject->getField('type'); + + + /*************/ + /* Menu Path */ + /*************/ + + $this->breadcrumb[] = $this->subject->getField('category'); + + if (in_array($this->subject->getField('category'), [MAP_TYPE_DUNGEON, MAP_TYPE_RAID])) + $this->breadcrumb[] = $this->subject->getField('expansion'); + + + /**************/ + /* Page Title */ + /**************/ + + array_unshift($this->title, $this->h1, Util::ucFirst(Lang::game('zone'))); + + + /***********/ + /* Infobox */ + /***********/ + + $quickFactsRows = DB::Aowow()->selectCol('SELECT `orderIdx` AS ARRAY_KEY, `row` FROM ::quickfacts WHERE `type` = %i AND `typeId` = %i ORDER BY `orderIdx` ASC', $this->type, $this->typeId); + $quickFactsRows = preg_replace_callback('/\|L:(\w+)((:\w+)+)\|/i', function ($m) + { + [, $grp, $args] = $m; + $args = array_filter(explode(':', $args), fn($x) => $x != ''); + + return Lang::$grp(...$args); + }, $quickFactsRows); + + foreach ($quickFactsRows as $er) + $this->extendGlobalData(Markup::parseTags($er)); + + $infobox = Lang::getInfoBoxForFlags($this->subject->getField('cuFlags')); + + if ($topRows = array_filter($quickFactsRows, fn($x) => $x < 0, ARRAY_FILTER_USE_KEY)) + $infobox = array_merge($infobox, $topRows); + + // City + if ($this->subject->getField('flags') & AREA_FLAG_SLAVE_CAPITAL && !$_parentArea) + $infobox[] = Lang::zone('city'); + + // Auto repop + if ($this->subject->getField('flags') & AREA_FLAG_NEED_FLY && !$_parentArea) + $infobox[] = Lang::zone('autoRez'); + + // Level + if ($_ = $this->subject->getField('levelMin')) + { + if ($_ < $this->subject->getField('levelMax')) + $_ .= ' - '.$this->subject->getField('levelMax'); + + $infobox[] = Lang::game('level').Lang::main('colon').$_; + } + + // required Level + if ($_ = $this->subject->getField('levelReq')) + { + if ($__ = $this->subject->getField('levelReqLFG')) + $buff = Lang::zone('reqLevels', [$_, $__]); + else + $buff = Lang::main('_reqLevel').Lang::main('colon').$_; + + $infobox[] = $buff; + } + + // Territory + $faction = $this->subject->getField('faction'); + $wrap = match ($faction) + { + TEAM_ALLIANCE => '[span class=icon-alliance]%s[/span]', + TEAM_HORDE => '[span class=icon-horde]%s[/span]', + 4, 5 => '[span class=icon-ffa]%s[/span]', + default => '%s' + }; + + $infobox[] = Lang::zone('territory').sprintf($wrap, Lang::zone('territories', $faction)); + + // Instance Type + $infobox[] = Lang::zone('instanceType').'[span class=icon-instance'.$this->subject->getField('type').']'.Lang::zone('instanceTypes', $this->subject->getField('type')).'[/span]'; + + // Heroic mode + if ($_ = $this->subject->getField('levelHeroic')) + $infobox[] = '[icon preset=heroic]'.Lang::zone('hcAvailable', [$_]).'[/icon]'; + + // number of players + if ($_ = $this->subject->getField('maxPlayer')) + { + if (in_array($this->subject->getField('category'), [6, 9])) + $infobox[] = Lang::zone('numPlayersVs', [$_]); + else + $infobox[] = Lang::zone('numPlayers', [$_ == -2 ? '10/25' : $_]); + } + + // Instances + if ($_ = DB::Aowow()->selectCol('SELECT `typeId` FROM ::spawns WHERE `type`= %i AND `areaId` = %i ', Type::ZONE, $this->typeId)) + { + $this->extendGlobalIds(Type::ZONE, ...$_); + $infobox[] = Lang::maps('Instances').Lang::main('colon').Lang::concat($_, Lang::CONCAT_NONE, fn($x) => "\n[zone=".$x."]"); + } + + // start area + if ($_ = DB::Aowow()->selectCol('SELECT `id` FROM ::races WHERE `startAreaId` = %i', $this->typeId)) + { + $this->extendGlobalIds(Type::CHR_RACE, ...$_); + $infobox[] = Lang::concat($_, Lang::CONCAT_NONE, fn($x) => '[race='.$x.']').' '.Lang::race('startZone'); + } + + parent::generate(); // calls applyGlobals .. probably too early here, but addMoveLocationMenu requires PageTemplate to be initialized + + // location (if instance) + if ($pa = DB::Aowow()->selectRow('SELECT `areaId`, `posX`, `posY`, `floor` FROM ::spawns WHERE `type`= %i AND `typeId` = %i ', Type::ZONE, $this->typeId)) + { + $this->addMoveLocationMenu($pa['areaId'], $pa['floor']); + + $pins = str_pad($pa['posX'] * 10, 3, '0', STR_PAD_LEFT) . str_pad($pa['posY'] * 10, 3, '0', STR_PAD_LEFT); + $infobox[] = Lang::zone('location').'[lightbox=map zone='.$pa['areaId'].' '.($pa['floor'] > 1 ? 'floor='.--$pa['floor'] : '').' pins='.$pins.']'.ZoneList::getName($pa['areaId']).'[/lightbox]'; + } + + // Attunement Quest/Achievements & Keys + if ($attmnt = $this->subject->getField('attunes')) + { + foreach ($attmnt as $type => $ids) + { + $this->extendGlobalIds($type, ...array_map('abs', $ids)); + foreach ($ids as $id) + { + if ($type == Type::ITEM) + $infobox[] = Lang::zone('key', (int)($id < 0)).'[item='.abs($id).']'; + else + $infobox[] = Lang::zone('attunement', (int)($id < 0)).'['.Type::getFileString($type).'='.abs($id).']'; + } + } + } + + // id + $infobox[] = Lang::zone('id') . $this->typeId; + + // original name + if (Lang::getLocale() != Locale::EN) + $infobox[] = Util::ucFirst(Lang::lang(Locale::EN->value) . Lang::main('colon')) . '[copy button=false]'.$this->subject->getField('name_loc0').'[/copy][/li]'; + + if ($botRows = array_filter($quickFactsRows, fn($x) => $x > 0, ARRAY_FILTER_USE_KEY)) + $infobox = array_merge($infobox, $botRows); + + if ($infobox) + $this->infobox = new InfoboxMarkup($infobox, ['allow' => Markup::CLASS_STAFF, 'dbpage' => true], 'infobox-contents0'); + + + /****************/ + /* Main Content */ + /****************/ + + $addToSOM = function (string $what, string $group, array $entry) use (&$som) : void + { + // entry always contains: type, id, name, level, coords[] + if (!isset($som[$what][$group])) // not found yet + $som[$what][$group][] = $entry; + else // found .. something.. + { + // check for identical floors + foreach ($som[$what][$group] as &$byFloor) + { + if ($byFloor['level'] != $entry['level']) + continue; + + // found existing floor, ammending coords + $byFloor['coords'][] = $entry['coords'][0]; + return; + } + + // floor not used yet, create it + $som[$what][$group][] = $entry; + } + }; + + if ($_parentArea) + { + $this->extraText = new Markup(Lang::zone('zonePartOf', [$_parentArea]), ['dbpage' => true, 'allow' => Markup::CLASS_ADMIN], 'text-generic'); + $this->extendGlobalIds(Type::ZONE, $_parentArea); + } + + // we cannot fetch spawns via lists. lists are grouped by entry + $oSpawns = DB::Aowow()->selectAssoc('SELECT * FROM ::spawns WHERE `areaId` = %i AND `type` = %i AND `posX` > 0 AND `posY` > 0', $this->typeId, Type::OBJECT); + $cSpawns = DB::Aowow()->selectAssoc('SELECT * FROM ::spawns WHERE `areaId` = %i AND `type` = %i AND `posX` > 0 AND `posY` > 0', $this->typeId, Type::NPC); + $aSpawns = User::isInGroup(U_GROUP_STAFF) ? DB::Aowow()->selectAssoc('SELECT * FROM ::spawns WHERE `areaId` = %i AND `type` = %i AND `posX` > 0 AND `posY` > 0', $this->typeId, Type::AREATRIGGER) : []; + + $conditions = [['s.areaId', $this->typeId]]; + if (!User::isInGroup(U_GROUP_STAFF)) + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + $objectSpawns = new GameObjectList($conditions, ['calcTotal' => true]); + $creatureSpawns = new CreatureList($conditions, ['calcTotal' => true]); + $atSpawns = new AreaTriggerList($conditions); + + $questsLV = $rewardsLV = []; + + $relQuestZOS = [$this->typeId]; + foreach (Game::$questSubCats as $parent => $children) + { + if (in_array($this->typeId, $children)) + $relQuestZOS[] = $parent; + else if ($this->typeId == $parent) + $relQuestZOS = array_merge($relQuestZOS, $children); + } + + // see if we can actually display a map + $mapFilePath = 'static/images/wow/maps/%s/normal/%d%s.jpg'; + $options = array( + [Lang::getLocale()->json(), ''], // default case + [Lang::getLocale()->json(), '-1'], // try multifloor + ['enus', ''], // try english fallback + ['enus', '-1'] // try english fallback, multifloor + ); + $hasMap = false; + foreach ($options as [$lang, $floor]) + { + if (!file_exists(sprintf($mapFilePath, $lang, $this->typeId, $floor))) + continue; + + $hasMap = true; + break; + } + + if ($hasMap) + { + $som = []; + foreach ($oSpawns as $spawn) + { + $tpl = $objectSpawns->getEntry($spawn['typeId']); + if (!$tpl) + continue; + + $n = Util::localizedString($tpl, 'name'); + + $what = match ((int)$tpl['typeCat']) + { + -3 => 'herb', + -4 => 'vein', + 9 => 'book', + 25 => 'pool', + 0 => $tpl['type'] == 19 ? 'mail' : '', + -6 => $tpl['spellFocusId'] == 1 ? 'anvil' : ($tpl['spellFocusId'] == 3 ? 'forge' : ''), + default => '' + }; + + if ($what) + { + $blob = array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::OBJECT, + 'id' => $tpl['id'] + ); + + if ($what == 'mail') + { + $blob['side'] = (($tpl['A'] < 0 ? 0 : SIDE_ALLIANCE) | ($tpl['H'] < 0 ? 0 : SIDE_HORDE)); + $addToSOM($what, $tpl['id'], $blob); + } + else + $addToSOM($what, $n, $blob); + } + + if ($tpl['startsQuests']) + { + $started = new QuestList(array(['qse.method', 1, '&'], ['qse.type', Type::OBJECT], ['qse.typeId', $tpl['id']])); + if ($started->error) + continue; + + // store data for misc tabs + foreach ($started->getListviewData() as $id => $data) + { + if ($started->getField('questSortId') > 0 && !in_array($started->getField('questSortId'), $relQuestZOS)) + continue; + + if (!empty($started->rewards[$id][Type::ITEM])) + $rewardsLV = array_merge($rewardsLV, array_keys($started->rewards[$id][Type::ITEM])); + + if (!empty($started->choices[$id][Type::ITEM])) + $rewardsLV = array_merge($rewardsLV, array_keys($started->choices[$id][Type::ITEM])); + + $questsLV[$id] = $data; + } + + $this->extendGlobalData($started->getJSGlobals()); + + if (($tpl['A'] != -1) && ($_ = $started->getSOMData(SIDE_ALLIANCE))) + $addToSOM('alliancequests', $n, array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::OBJECT, + 'id' => $tpl['id'], + 'side' => (($tpl['A'] < 0 ? 0 : SIDE_ALLIANCE) | ($tpl['H'] < 0 ? 0 : SIDE_HORDE)), + 'quests' => array_values($_) + )); + + if (($tpl['H'] != -1) && ($_ = $started->getSOMData(SIDE_HORDE))) + $addToSOM('hordequests', $n, array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::OBJECT, + 'id' => $tpl['id'], + 'side' => (($tpl['A'] < 0 ? 0 : SIDE_ALLIANCE) | ($tpl['H'] < 0 ? 0 : SIDE_HORDE)), + 'quests' => array_values($_) + )); + } + } + + $flightNodes = []; + foreach ($cSpawns as $spawn) + { + $tpl = $creatureSpawns->getEntry($spawn['typeId']); + if (!$tpl) + continue; + + $n = Util::localizedString($tpl, 'name'); + $sn = Util::localizedString($tpl, 'subname'); + + $flagsMap = array( + NPC_FLAG_REPAIRER => 'repair', + NPC_FLAG_AUCTIONEER => 'auctioneer', + NPC_FLAG_BANKER => 'banker', + NPC_FLAG_BATTLEMASTER => 'battlemaster', + NPC_FLAG_INNKEEPER => 'innkeeper', + NPC_FLAG_TRAINER => 'trainer', + NPC_FLAG_VENDOR => 'vendor', + NPC_FLAG_FLIGHT_MASTER => 'flightmaster', + NPC_FLAG_STABLE_MASTER => 'stablemaster', + NPC_FLAG_GUILD_MASTER => 'guildmaster', + NPC_FLAG_SPIRIT_HEALER | + NPC_FLAG_SPIRIT_GUIDE => 'spirithealer', + 0 => '' // set 'unused' if no match + ); + + if ($creatureSpawns->isBoss()) + $what = 'boss'; + else if ($tpl['rank'] == NPC_RANK_RARE_ELITE || $tpl['rank'] == NPC_RANK_RARE) + $what = 'rare'; + else + foreach ($flagsMap as $flag => $what) + if ($tpl['npcflag'] & $flag) + break; + + if ($what == 'flightmaster') + $flightNodes[$tpl['id']] = [$spawn['posX'], $spawn['posY']]; + + if ($what) + $addToSOM($what, $n, array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::NPC, + 'id' => $tpl['id'], + 'reacthorde' => $tpl['H'] ?: 1, // no neutral (0) setting + 'reactalliance' => $tpl['A'] ?: 1, + 'description' => $sn + )); + + if ($tpl['startsQuests']) + { + $started = new QuestList(array(['qse.method', 1, '&'], ['qse.type', Type::NPC], ['qse.typeId', $tpl['id']])); + if ($started->error) + continue; + + // store data for misc tabs + foreach ($started->getListviewData() as $id => $data) + { + if ($started->getField('questSortId') > 0 && !in_array($started->getField('questSortId'), $relQuestZOS)) + continue; + + if (!empty($started->rewards[$id][Type::ITEM])) + $rewardsLV = array_merge($rewardsLV, array_keys($started->rewards[$id][Type::ITEM])); + + if (!empty($started->choices[$id][Type::ITEM])) + $rewardsLV = array_merge($rewardsLV, array_keys($started->choices[$id][Type::ITEM])); + + $questsLV[$id] = $data; + } + + $this->extendGlobalData($started->getJSGlobals()); + + if (($tpl['A'] != -1) && ($_ = $started->getSOMData(SIDE_ALLIANCE))) + $addToSOM('alliancequests', $n, array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::NPC, + 'id' => $tpl['id'], + 'reacthorde' => $tpl['H'], + 'reactalliance' => $tpl['A'], + 'side' => (($tpl['A'] < 0 ? 0 : SIDE_ALLIANCE) | ($tpl['H'] < 0 ? 0 : SIDE_HORDE)), + 'quests' => array_values($_) + )); + + if (($tpl['H'] != -1) && ($_ = $started->getSOMData(SIDE_HORDE))) + $addToSOM('hordequests', $n, array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::NPC, + 'id' => $tpl['id'], + 'reacthorde' => $tpl['H'], + 'reactalliance' => $tpl['A'], + 'side' => (($tpl['A'] < 0 ? 0 : SIDE_ALLIANCE) | ($tpl['H'] < 0 ? 0 : SIDE_HORDE)), + 'quests' => array_values($_) + )); + } + } + + foreach ($aSpawns as $spawn) + { + if ($spawn['guid'] < 0) // skip teleporter endpoints + continue; + + $tpl = $atSpawns->getEntry($spawn['typeId']); + if (!$tpl) + continue; + + $n = Util::localizedString($tpl, 'name', true, true); + $addToSOM('areatrigger', $n, array( + 'coords' => [[$spawn['posX'], $spawn['posY']]], + 'level' => $spawn['floor'], + 'name' => $n, + 'type' => Type::AREATRIGGER, + 'id' => $spawn['typeId'], + 'description' => Lang::game('type').Lang::areatrigger('types', $tpl['type']) + )); + } + + // remove unwanted indizes + foreach ($som as $what => &$dataz) + { + if (empty($som[$what])) + continue; + + foreach ($dataz as &$data) + $data = array_values($data); + + if (!in_array($what, ['vein', 'herb', 'rare', 'pool'])) + { + $foo = []; + foreach ($dataz as $d) + foreach ($d as $_) + $foo[] = $_; + + $dataz = $foo; + } + } + + unset($data); + + // append paths between nodes + if ($flightNodes) + { + // neutral nodes come last as the line is colored by the node it's attached to + usort($som['flightmaster'], function($a, $b) { + $n1 = (int)$a['reactalliance'] == $a['reacthorde']; + $n2 = (int)$b['reactalliance'] == $b['reacthorde']; + + return $n1 <=> $n2; + }); + + $paths = DB::Aowow()->selectAssoc('SELECT n1.`typeId` AS "0", n2.`typeId` AS "1" FROM ::taxipath p JOIN ::taxinodes n1 ON n1.`id` = p.`startNodeId` JOIN ::taxinodes n2 ON n2.`id` = p.`endNodeId` WHERE n1.`typeId` IN %in AND n2.`typeId` IN %in', array_keys($flightNodes), array_keys($flightNodes)); + + foreach ($paths as $k => $path) + { + foreach ($som['flightmaster'] as &$fm) + { + if ($fm['id'] != $path[0] && $fm['id'] != $path[1]) + continue; + + if ($fm['id'] == $path[0]) + $fm['paths'][] = $flightNodes[$path[1]]; + + if ($fm['id'] == $path[1]) + $fm['paths'][] = $flightNodes[$path[0]]; + + unset($paths[$k]); + break; + } + } + } + + // preselect bosses for raids/dungeons + if (in_array($_type, [MAP_TYPE_DUNGEON, MAP_TYPE_RAID, MAP_TYPE_BATTLEGROUND, MAP_TYPE_DUNGEON_HC, MAP_TYPE_MMODE_RAID, MAP_TYPE_MMODE_RAID_HC])) + $som['instance'] = true; + + $this->map = array( + array( // Mapper + 'parent' => 'mapper-generic', + 'zone' => $this->typeId, + 'zoneLink' => false + ), + null, // mapperData + $som, // ShowOnMap + null // foundIn + ); + } + + $this->expansion = Util::$expansionString[$this->subject->getField('expansion')]; + $this->redButtons = array( + BUTTON_WOWHEAD => true, + BUTTON_LINKS => ['type' => $this->type, 'typeId' => $this->typeId] + ); + + + /**************/ + /* Extra Tabs */ + /**************/ + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"], 'tabsRelated', true); + + // tab: drops + if (in_array($this->subject->getField('category'), [MAP_TYPE_DUNGEON, MAP_TYPE_RAID])) + { + // Issue 1 - if the bosses drop items that are also sold by vendors moreZoneId will be 0 as vendor location and boss location are likely in conflict with each other + // Issue 2 - if the boss/chest isn't spawned the loot will not show up + $items = new ItemList(array(['src.moreZoneId', $this->typeId], ['src.src2', 0, '>'], ['quality', ITEM_QUALITY_UNCOMMON, '>=']), ['calcTotal' => true]); + $data = $items->getListviewData(); + $subTabs = false; + foreach ($items->iterate() as $id => $__) + { + $src = $items->getRawSource(SRC_DROP); + $map = ($items->getField('moreMask') ?: 0) & (SRC_FLAG_DUNGEON_DROP | SRC_FLAG_RAID_DROP); + if (!$src || !$map) + continue; + + $subTabs = true; + + if ($map & SRC_FLAG_RAID_DROP) + $mode = ($src[0] << 3); + else + $mode = ($src[0] & 0x1 ? 0x2 : 0) | ($src[0] & 0x2 ? 0x1 : 0); + + $data[$id] += ['modes' => ['mode' => $mode]]; + } + + $tabData = array( + 'data' => $data, + 'id' => 'drops', + 'name' => '$LANG.tab_drops', + 'extraCols' => $subTabs ? ['$Listview.extraCols.mode'] : null, + 'computeDataFunc' => '$Listview.funcBox.initLootTable', + 'onAfterCreate' => $subTabs ? '$Listview.funcBox.addModeIndicator' : null + ); + + if (!is_null(ItemListFilter::getCriteriaIndex(16, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?items&filter=cr=16;crs='.$this->typeId.';crv=0'); + + $this->extendGlobalData($items->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview($tabData, ItemList::$brickFile)); + } + + // tab: npcs + if ($cSpawns && !$creatureSpawns->error) + { + $tabData = ['data' => $creatureSpawns->getListviewData()]; + + if (!is_null(CreatureListFilter::getCriteriaIndex(6, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?npcs&filter=cr=6;crs='.$this->typeId.';crv=0'); + + if ($creatureSpawns->getMatches() > Listview::DEFAULT_SIZE) + $tabData['_truncated'] = 1; + + $this->extendGlobalData($creatureSpawns->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview($tabData, CreatureList::$brickFile)); + } + + // tab: objects + if ($oSpawns && !$objectSpawns->error) + { + $tabData = ['data' => $objectSpawns->getListviewData()]; + + if (!is_null(GameObjectListFilter::getCriteriaIndex(1, $this->typeId))) + $tabData['note'] = sprintf(Util::$filterResultString, '?objects&filter=cr=1;crs='.$this->typeId.';crv=0'); + + if ($objectSpawns->getMatches() > Listview::DEFAULT_SIZE) + $tabData['_truncated'] = 1; + + $this->extendGlobalData($objectSpawns->getJSGlobals(GLOBALINFO_SELF)); + + $this->lvTabs->addListviewTab(new Listview($tabData, GameObjectList::$brickFile)); + } + + $quests = new QuestList(array(['questSortId', $this->typeId])); + if (!$quests->error) + { + $this->extendGlobalData($quests->getJSGlobals()); + foreach ($quests->getListviewData() as $id => $data) + { + if (!empty($quests->rewards[$id][Type::ITEM])) + $rewardsLV = array_merge($rewardsLV, array_keys($quests->rewards[$id][Type::ITEM])); + + if (!empty($quests->choices[$id][Type::ITEM])) + $rewardsLV = array_merge($rewardsLV, array_keys($quests->choices[$id][Type::ITEM])); + + $questsLV[$id] = $data; + } + } + + // tab: quests [including data collected by SOM-routine] + if ($questsLV) + { + $tabData = ['data' => $questsLV]; + + foreach (Game::QUEST_CLASSES as $parent => $children) + { + if (!in_array($this->typeId, $children)) + continue; + + if (!is_null(ItemListFilter::getCriteriaIndex(126, $this->typeId))) + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_zonequests, '.$parent.', '.$this->typeId.',"'.$this->subject->getField('name', true).'", '.$this->typeId.')'; + else + $tabData['note'] = '$$WH.sprintf(LANG.lvnote_questsind, '.$parent.', '.$this->typeId.',"'.$this->subject->getField('name', true).'")'; + break; + } + + $this->lvTabs->addListviewTab(new Listview($tabData, QuestList::$brickFile)); + } + + // tab: starts-quest + // select every quest starter, that is a drop + $questStartItem = DB::Aowow()->selectAssoc( + 'SELECT qse.`typeId` AS ARRAY_KEY, `moreType`, `moreTypeId`, `moreZoneId` + FROM ::quests_startend qse JOIN ::source src ON src.`type` = qse.`type` AND src.`typeId` = qse.`typeId` + WHERE src.`src2` IS NOT NULL AND qse.`type` = %i AND (`moreZoneId` = %i OR (`moreType` = %i AND `moreTypeId` IN %in) OR (`moreType` = %i AND `moreTypeId` IN %in))', + Type::ITEM, $this->typeId, + Type::NPC, array_unique(array_column($cSpawns, 'typeId')) ?: [0], + Type::OBJECT, array_unique(array_column($oSpawns, 'typeId')) ?: [0] + ); + + if ($questStartItem) + { + $qsiList = new ItemList(array(['id', array_keys($questStartItem)])); + if (!$qsiList->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $qsiList->getListviewData(), + 'name' => '$LANG.tab_startsquest', + 'id' => 'starts-quest' + ), ItemList::$brickFile)); + + $this->extendGlobalData($qsiList->getJSGlobals(GLOBALINFO_SELF)); + } + } + + // tab: quest-rewards [ids collected by SOM-routine] + if ($rewardsLV) + { + $rewards = new ItemList(array(['id', array_unique($rewardsLV)])); + if (!$rewards->error) + { + $note = null; + if (!is_null(ItemListFilter::getCriteriaIndex(126, $this->typeId))) + $note = sprintf(Util::$filterResultString, '?items&filter=cr=126;crs='.$this->typeId.';crv=0'); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $rewards->getListviewData(), + 'name' => '$LANG.tab_questrewards', + 'id' => 'quest-rewards', + 'note' => $note + ), ItemList::$brickFile)); + + $this->extendGlobalData($rewards->getJSGlobals(GLOBALINFO_SELF)); + } + } + + // tab: achievements + + // tab: criteria-of + $conditions = array(DB::OR, + array( + DB::AND, + ['ac.type', [ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_QUESTS_IN_ZONE, ACHIEVEMENT_CRITERIA_TYPE_HONORABLE_KILL_AT_AREA]], + ['ac.value1', $this->typeId] + ) + ); + + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` = %i AND `value1` = %i', ACHIEVEMENT_CRITERIA_DATA_TYPE_S_AREA, $this->typeId)) + $conditions[] = ['ac.id', $extraCrt]; + + if ($this->subject->getField('category') != MAP_TYPE_ZONE) + { + $conditions[] = array ( + DB::AND, + ['ac.type', [ACHIEVEMENT_CRITERIA_TYPE_WIN_BG, ACHIEVEMENT_CRITERIA_TYPE_WIN_ARENA, + ACHIEVEMENT_CRITERIA_TYPE_PLAY_ARENA, ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_BATTLEGROUND, + ACHIEVEMENT_CRITERIA_TYPE_DEATH_AT_MAP] + ], + ['ac.value1', $this->subject->getField('mapId')] + ); + + if ($extraCrt = DB::World()->selectCol('SELECT `criteria_id` FROM achievement_criteria_data WHERE `type` = %i AND `value1` = %i', ACHIEVEMENT_CRITERIA_DATA_TYPE_MAP_ID, $this->subject->getField('mapId'))) + $conditions[] = ['ac.id', $extraCrt]; + + } + + $crtOf = new AchievementList($conditions); + if (!$crtOf->error) + { + $this->extendGlobalData($crtOf->getJSGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $crtOf->getListviewData(), + 'name' => '$LANG.tab_criteriaof', + 'id' => 'criteria-of' + ), AchievementList::$brickFile)); + } + + // tab: fishing + $fish = new LootByContainer(); + if ($fish->getByContainer(Loot::FISHING, [$this->typeId])) + { + $this->extendGlobalData($fish->jsGlobals); + $xCols = array_merge(['$Listview.extraCols.percent'], $fish->extraCols); + + $note = null; + if ($skill = DB::World()->selectCell('SELECT `skill` FROM skill_fishing_base_level WHERE `entry` = %i', $this->typeId)) + $note = sprintf(Util::$lvTabNoteString, Lang::zone('fishingSkill'), Lang::formatSkillBreakpoints(Game::getBreakpointsForSkill(SKILL_FISHING, $skill), Lang::FMT_HTML)); + else if ($_parentArea && ($skill = DB::World()->selectCell('SELECT `skill` FROM skill_fishing_base_level WHERE `entry` = %i', $_parentArea))) + $note = sprintf(Util::$lvTabNoteString, Lang::zone('fishingSkill'), Lang::formatSkillBreakpoints(Game::getBreakpointsForSkill(SKILL_FISHING, $skill), Lang::FMT_HTML)); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $fish->getResult(), + 'name' => '$LANG.tab_fishing', + 'id' => 'fishing', + 'extraCols' => array_unique($xCols), + 'hiddenCols' => ['side'], + 'note' => $note, + 'computeDataFunc' => '$Listview.funcBox.initLootTable' + ), ItemList::$brickFile)); + } + + // tab: spells + if ($saData = DB::World()->selectAssoc('SELECT * FROM spell_area WHERE `area` = %i', $this->typeId)) + { + $spells = new SpellList(array(['id', array_column($saData, 'spell')])); + if (!$spells->error) + { + $lvSpells = $spells->getListviewData(); + $this->extendGlobalData($spells->getJSGlobals()); + + $cnd = new Conditions(); + foreach ($saData as $a) + { + if (empty($lvSpells[$a['spell']])) + continue; + + if ($a['aura_spell']) + $cnd->addExternalCondition(Conditions::SRC_NONE, $a['spell'], [$a['aura_spell'] > 0 ? Conditions::AURA : -Conditions::AURA, abs($a['aura_spell'])]); + + if ($a['quest_start']) // status for quests needs work + $cnd->addExternalCondition(Conditions::SRC_NONE, $a['spell'], [Conditions::QUESTSTATE, $a['quest_start'], $a['quest_start_status']]); + + if ($a['quest_end'] && $a['quest_end'] != $a['quest_start']) + $cnd->addExternalCondition(Conditions::SRC_NONE, $a['spell'], [Conditions::QUESTSTATE, $a['quest_end'], $a['quest_end_status']]); + + if ($a['racemask']) + $cnd->addExternalCondition(Conditions::SRC_NONE, $a['spell'], [Conditions::CHR_RACE, $a['racemask']]); + + if ($a['gender'] != 2) // 2: both + $cnd->addExternalCondition(Conditions::SRC_NONE, $a['spell'], [Conditions::GENDER, $a['gender']]); + } + + if ($cnd->toListviewColumn($lvSpells, $extraCols)) + $this->extendGlobalData($cnd->getJsGlobals()); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $lvSpells, + 'hiddenCols' => ['skill'], + 'extraCols' => $extraCols ?: null + ), SpellList::$brickFile)); + } + } + + // tab: subzones + $subZones = new ZoneList(array(['parentArea', $this->typeId])); + if (!$subZones->error) + { + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $subZones->getListviewData(), + 'name' => '$LANG.tab_zones', + 'id' => 'subzones', + 'hiddenCols' => ['territory', 'instancetype'] + ), ZoneList::$brickFile)); + + $this->extendGlobalData($subZones->getJSGlobals(GLOBALINFO_SELF)); + } + + // tab: sound (including subzones; excluding parents) + $areaIds = []; + if (!$subZones->error) + $areaIds = $subZones->getFoundIDs(); + + $areaIds[] = $this->typeId; + + $soundIds = []; + $zoneMusic = DB::Aowow()->selectAssoc( + 'SELECT x.`soundId` AS ARRAY_KEY, x.`soundId`, x.`worldStateId`, x.`worldStateValue`, x.`type` + FROM (SELECT `ambienceDay` AS "soundId", `worldStateId`, `worldStateValue`, 1 AS "type" FROM ::zones_sounds WHERE `id` IN %in AND `ambienceDay` > 0 UNION + SELECT `ambienceNight` AS "soundId", `worldStateId`, `worldStateValue`, 1 AS "type" FROM ::zones_sounds WHERE `id` IN %in AND `ambienceNight` > 0 UNION + SELECT `musicDay` AS "soundId", `worldStateId`, `worldStateValue`, 2 AS "type" FROM ::zones_sounds WHERE `id` IN %in AND `musicDay` > 0 UNION + SELECT `musicNight` AS "soundId", `worldStateId`, `worldStateValue`, 2 AS "type" FROM ::zones_sounds WHERE `id` IN %in AND `musicNight` > 0 UNION + SELECT `intro` AS "soundId", `worldStateId`, `worldStateValue`, 3 AS "type" FROM ::zones_sounds WHERE `id` IN %in AND `intro` > 0) x + GROUP BY x.soundId, x.worldStateId, x.worldStateValue', + $areaIds, $areaIds, $areaIds, $areaIds, $areaIds + ); + + if ($sSpawns = DB::Aowow()->selectCol('SELECT `typeId` FROM ::spawns WHERE `areaId` = %i AND `type` = %i', $this->typeId, Type::SOUND)) + $soundIds = array_merge($soundIds, $sSpawns); + + if ($zoneMusic) + $soundIds = array_merge($soundIds, array_column($zoneMusic, 'soundId')); + + if ($soundIds) + { + $music = new SoundList(array(['id', array_unique($soundIds)])); + if (!$music->error) + { + // tab + $data = $music->getListviewData(); + $tabData = []; + + if (array_filter(array_column($zoneMusic, 'worldStateId'))) + { + $tabData['extraCols'] = ['$Listview.extraCols.condition']; + + foreach ($soundIds as $sId) + if (!empty($zoneMusic[$sId]['worldStateId'])) + Conditions::extendListviewRow($data[$sId], Conditions::SRC_NONE, $this->typeId, [Conditions::WORLD_STATE, $zoneMusic[$sId]['worldStateId'], $zoneMusic[$sId]['worldStateValue']]); + } + + $tabData['data'] = $data; + + $this->lvTabs->addListviewTab(new Listview($tabData, SoundList::$brickFile)); + + $this->extendGlobalData($music->getJSGlobals(GLOBALINFO_SELF)); + + $typeFilter = function(array $music, int $type) use ($data) : array + { + $result = []; + foreach (array_filter($music, fn ($x) => $x['type'] == $type) as $sId => $_) + $result = array_merge($result, $data[$sId]['files'] ?? []); + + return $result; + }; + + // audio controls (order how it appears on page) + // [title, data, divID, options] + if ($_ = $typeFilter($zoneMusic, 2)) + $this->zoneMusic[] = [Lang::sound('music'), $_, 'zonemusic', (object)['loop' => true]]; + + if ($_ = $typeFilter($zoneMusic, 3)) + $this->zoneMusic[] = [Lang::sound('intro'), $_, 'zonemusicintro', (object)[]]; + + if ($_ = $typeFilter($zoneMusic, 1)) + $this->zoneMusic[] = [Lang::sound('ambience'), $_, 'soundambience', (object)['loop' => true]]; + } + } + + // tab: condition-for + $cnd = new Conditions(); + $cnd->getByCondition(Type::ZONE, $this->typeId)->prepare(); + if ($tab = $cnd->toListviewTab('condition-for', '$LANG.tab_condition_for')) + { + $this->extendGlobalData($cnd->getJsGlobals()); + $this->lvTabs->addDataTab(...$tab); + } + } + + private function addMoveLocationMenu(int $_parentArea, int $parentFloor) : void + { + // hide for non-staff + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + return; + + $worldPos = WorldPosition::getForGUID(Type::ZONE, -$this->typeId); + if (!$worldPos) + return; + + $menu = Util::buildPosFixMenu($worldPos[-$this->typeId]['mapId'], $worldPos[-$this->typeId]['posX'], $worldPos[-$this->typeId]['posY'], Type::ZONE, -$this->typeId, $_parentArea, $parentFloor); + if (!$menu) + return; + + $menu = [1002, 'Edit DB Entry', null, $menu]; + + $this->addScript([SC_JS_STRING, '$(document).ready(function () { mn_staff.push('.Util::toJSON(array_values($menu)).'); });']); + } +} + +?> diff --git a/endpoints/zones/zones.php b/endpoints/zones/zones.php new file mode 100644 index 00000000..0f58fd4a --- /dev/null +++ b/endpoints/zones/zones.php @@ -0,0 +1,188 @@ +getCategoryFromUrl($rawParam); + + parent::__construct($rawParam); + } + + protected function generate() : void + { + $this->h1 = Util::ucFirst(Lang::game('zones')); + + + /*************/ + /* Menu Path */ + /*************/ + + foreach ($this->category as $c) + $this->breadcrumb[] = $c; + + + /**************/ + /* Page Title */ + /**************/ + + if (isset($this->category[1])) + array_unshift($this->title, Lang::game('expansions', $this->category[1])); + + if (isset($this->category[0])) + array_unshift($this->title, Lang::zone('cat', $this->category[0])); + + + /****************/ + /* Main Content */ + /****************/ + + $this->redButtons[BUTTON_WOWHEAD] = true; + + $conditions = []; // do not limit + $visibleCols = []; + $hiddenCols = []; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) // sub-areas and unused zones + $conditions[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + + if ($this->category) + { + $conditions[] = ['z.category', $this->category[0]]; + $hiddenCols[] = 'category'; + + if (isset($this->category[1]) && in_array($this->category[0], [2, 3])) + $conditions[] = ['z.expansion', $this->category[1]]; + + switch ($this->category[0]) + { + case 6: + case 2: + case 3: + array_push($visibleCols, 'level', 'players'); + case 9: + $hiddenCols[] = 'territory'; + break; + } + } + + $zones = new ZoneList($conditions); + + if (!$zones->hasSetFields('type')) + $hiddenCols[] = 'instancetype'; + + $this->lvTabs = new Tabs(['parent' => "\$\$WH.ge('tabs-generic')"]); + + $this->lvTabs->addListviewTab(new Listview(array( + 'data' => $zones->getListviewData(), + 'visibleCols'=> $visibleCols ?: null, + 'hiddenCols' => $hiddenCols ?: null + ), ZoneList::$brickFile)); + + + /**************/ + /* Flight Map */ + /**************/ + + [$mapFile, $spawnMap] = match ($this->category[0] ?? null) + { + 0 => [-3, 0], + 1 => [-6, 1], + 8 => [-2, 530], + 10 => [-5, 571], + default => [ 0, -1] + }; + + if ($mapFile) + { + $somData = ['flightmaster' => []]; + $nodes = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, tn.* FROM ::taxinodes tn WHERE `mapId` = %i AND `type` <> 0 AND `typeId` <> 0', $spawnMap); + $paths = DB::Aowow()->selectAssoc( + 'SELECT IF(tn1.`reactA` = tn1.`reactH` AND tn2.`reactA` = tn2.`reactH`, 1, 0) AS "neutral", + tp.`startNodeId` AS "startId", tn1.`mapX` AS "startPosX", tn1.`mapY` AS "startPosY", + tp.`endNodeId` AS "endId", tn2.`mapX` AS "endPosX", tn2.`mapY` AS "endPosY" + FROM ::taxipath tp, ::taxinodes tn1, ::taxinodes tn2 + WHERE tn1.`Id` = tp.`endNodeId` AND tn2.`Id` = tp.`startNodeId` AND + tn1.`type` <> 0 AND tn2.`type` <> 0 AND + (tp.`startNodeId` IN %in OR tp.`EndNodeId` IN %in)', + array_keys($nodes), array_keys($nodes) + ); + + foreach ($nodes as $i => $n) + { + $neutral = $n['reactH'] == $n['reactA']; + + $data = array( + 'coords' => [[$n['mapX'], $n['mapY']]], + 'level' => 0, // floor + 'name' => Util::localizedString($n, 'name'), + 'type' => $n['type'], + 'id' => $n['typeId'], + 'reacthorde' => $n['reactH'], + 'reactalliance' => $n['reactA'], + 'paths' => [] + ); + + foreach ($paths as $j => $p) + { + if ($i != $p['startId'] && $i != $p['endId']) + continue; + + if ($i == $p['startId'] && (!$neutral || $p['neutral'])) + { + $data['paths'][] = [$p['startPosX'], $p['startPosY']]; + unset($paths[$j]); + } + else if ($i == $p['endId'] && (!$neutral || $p['neutral'])) + { + $data['paths'][] = [$p['endPosX'], $p['endPosY']]; + unset($paths[$j]); + } + } + + if (empty($data['paths'])) + unset($data['paths']); + + $somData['flightmaster'][] = $data; + } + + $this->map = array( + array( // Mapper + 'parent' => 'mapper-generic', + 'zone' => $mapFile, + 'zoom' => 1, + 'overlay' => true, + 'zoomable' => false + ), + null, // mapperData + $somData, // ShowOnMap + null // foundIn + ); + } + + parent::generate(); + } +} + +?> diff --git a/includes/ajaxHandler.class.php b/includes/ajaxHandler.class.php deleted file mode 100644 index f40ed712..00000000 --- a/includes/ajaxHandler.class.php +++ /dev/null @@ -1,71 +0,0 @@ -params = $params; - - $this->initRequestData(); - } - - public function handle(string &$out) : bool - { - if (!$this->handler) - return false; - - if ($this->validParams) - { - if (count($this->params) != 1) - return false; - - if (!in_array($this->params[0], $this->validParams)) - return false; - } - - $h = $this->handler; - $out = $this->$h(); - if ($out === null) - $out = ''; - - return true; - } - - public function getContentType() : string - { - return $this->contentType; - } - - protected function reqPOST(string ...$keys) : bool - { - foreach ($keys as $k) - if (!isset($this->_post[$k]) || $this->_post[$k] === null || $this->_post[$k] === '') - return false; - - return true; - } - - protected function reqGET(string ...$keys) : bool - { - foreach ($keys as $k) - if (!isset($this->_get[$k]) || $this->_get[$k] === null || $this->_get[$k] === '') - return false; - - return true; - } -} -?> diff --git a/includes/ajaxHandler/account.class.php b/includes/ajaxHandler/account.class.php deleted file mode 100644 index e851f553..00000000 --- a/includes/ajaxHandler/account.class.php +++ /dev/null @@ -1,178 +0,0 @@ - ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'save' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'delete' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdList'], - 'name' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxAccount::checkName' ], - 'scale' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxAccount::checkScale' ], - 'reset' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'mode' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'type' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'add' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'remove' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - // 'sessionKey' => ['filter' => FILTER_SANITIZE_NUMBER_INT] - ); - protected $_get = array( - 'locale' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkLocale'] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (is_numeric($this->_get['locale'])) - User::useLocale($this->_get['locale']); - - if (!$this->params || !User::$id) - return; - - // select handler - if ($this->params[0] == 'exclude') - $this->handler = 'handleExclude'; - else if ($this->params[0] == 'weightscales') - $this->handler = 'handleWeightscales'; - else if ($this->params[0] == 'favorites') - $this->handler = 'handleFavorites'; - } - - protected function handleExclude() : void - { - if ($this->_post['mode'] == 1) // directly set exludes - { - $type = $this->_post['type']; - $ids = $this->_post['id']; - - if (!Type::exists($type) || empty($ids)) - { - trigger_error('AjaxAccount::handleExclude - invalid type #'.$type.(empty($ids) ? ' or id-list empty' : ''), E_USER_ERROR); - return; - } - - // ready for some bullshit? here it comes! - // we don't get signaled whether an id should be added to or removed from either includes or excludes - // so we throw everything into one table and toggle the mode if its already in here - - $includes = DB::Aowow()->selectCol('SELECT typeId FROM ?_profiler_excludes WHERE type = ?d AND typeId IN (?a)', $type, $ids); - - foreach ($ids as $typeId) - DB::Aowow()->query('INSERT INTO ?_account_excludes (`userId`, `type`, `typeId`, `mode`) VALUES (?a) ON DUPLICATE KEY UPDATE mode = (mode ^ 0x3)', array( - User::$id, $type, $typeId, in_array($includes, $typeId) ? 2 : 1 - )); - - return; - } - else if ($this->_post['reset'] == 1) // defaults to unavailable - { - $mask = PR_EXCLUDE_GROUP_UNAVAILABLE; - DB::Aowow()->query('DELETE FROM ?_account_excludes WHERE userId = ?d', User::$id); - } - else // clamp to real groups - $mask = $this->_post['groups'] & PR_EXCLUDE_GROUP_ANY; - - DB::Aowow()->query('UPDATE ?_account SET excludeGroups = ?d WHERE id = ?d', $mask, User::$id); - } - - protected function handleWeightscales() : string - { - if ($this->_post['save']) - { - if (!$this->_post['scale']) - { - trigger_error('AjaxAccount::handleWeightscales - scaleId empty', E_USER_ERROR); - return '0'; - } - - $id = 0; - - if ($this->_post['id'] && ($id = $this->_post['id'][0])) - { - if (!DB::Aowow()->selectCell('SELECT 1 FROM ?_account_weightscales WHERE userId = ?d AND id = ?d', User::$id, $id)) - { - trigger_error('AjaxAccount::handleWeightscales - scale #'.$id.' not in db or owned by user #'.User::$id, E_USER_ERROR); - return '0'; - } - - DB::Aowow()->query('UPDATE ?_account_weightscales SET `name` = ? WHERE id = ?d', $this->_post['name'], $id); - } - else - { - $nScales = DB::Aowow()->selectCell('SELECT COUNT(id) FROM ?_account_weightscales WHERE userId = ?d', User::$id); - if ($nScales >= 5) // more or less hard-defined in LANG.message_weightscalesaveerror - return '0'; - - $id = DB::Aowow()->query('INSERT INTO ?_account_weightscales (`userId`, `name`) VALUES (?d, ?)', User::$id, $this->_post['name']); - } - - DB::Aowow()->query('DELETE FROM ?_account_weightscale_data WHERE id = ?d', $id); - - foreach (explode(',', $this->_post['scale']) as $s) - { - [$k, $v] = explode(':', $s); - if (!in_array($k, Util::$weightScales) || $v < 1) - continue; - - DB::Aowow()->query('INSERT INTO ?_account_weightscale_data VALUES (?d, ?, ?d)', $id, $k, $v); - } - - return (string)$id; - } - else if ($this->_post['delete'] && $this->_post['id'] && $this->_post['id'][0]) - DB::Aowow()->query('DELETE FROM ?_account_weightscales WHERE id = ?d AND userId = ?d', $this->_post['id'][0], User::$id); - else - { - trigger_error('AjaxAccount::handleWeightscales - malformed request received', E_USER_ERROR); - return '0'; - } - } - - protected function handleFavorites() : void - { - // omit usage of sessionKey - if (count($this->_post['id']) != 1 || empty($this->_post['id'][0])) - { - trigger_error('AjaxAccount::handleFavorites - malformed request received', E_USER_ERROR); - return; - } - - $typeId = $this->_post['id'][0]; - - if ($type = $this->_post['add']) - { - $tc = Type::newList($type, [['id', $typeId]]); - if (!$tc || $tc->error) - { - trigger_error('AjaxAccount::handleFavorites - invalid typeId #'.$typeId.' for type #'.$type, E_USER_ERROR); - return; - } - - DB::Aowow()->query('INSERT INTO ?_account_favorites (`userId`, `type`, `typeId`) VALUES (?d, ?d, ?d)', User::$id, $type, $typeId); - } - else if ($type = $this->_post['remove']) - DB::Aowow()->query('DELETE FROM ?_account_favorites WHERE `userId` = ?d AND `type` = ?d AND `typeId` = ?d', User::$id, $type, $typeId); - } - - protected static function checkScale(string $val) : string - { - if (preg_match('/^((\w+:\d+)(,\w+:\d+)*)$/', $val)) - return $val; - - return ''; - } - - protected static function checkName(string $val) : string - { - $var = trim(urldecode($val)); - - return filter_var($var, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_AOWOW); - } -} - -?> diff --git a/includes/ajaxHandler/admin.class.php b/includes/ajaxHandler/admin.class.php deleted file mode 100644 index d048071a..00000000 --- a/includes/ajaxHandler/admin.class.php +++ /dev/null @@ -1,629 +0,0 @@ - ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW ], - 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdListUnsigned'], - 'key' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxAdmin::checkKey' ], - 'all' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkFulltext' ], - 'type' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt' ], - 'typeid' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt' ], - 'user' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxAdmin::checkUser' ], - 'val' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkFulltext' ], - 'guid' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt' ], - 'area' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt' ], - 'floor' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt' ] - ); - protected $_post = array( - 'alt' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt'], - 'scale' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxAdmin::checkScale'], - '__icon' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxAdmin::checkKey' ], - 'status' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt'], - 'msg' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$this->params) - return; - - if ($this->params[0] == 'screenshots' && $this->_get['action']) - { - if (!User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU | U_GROUP_SCREENSHOT)) - return; - - if ($this->_get['action'] == 'list') - $this->handler = 'ssList'; - else if ($this->_get['action'] == 'manage') - $this->handler = 'ssManage'; - else if ($this->_get['action'] == 'editalt') - $this->handler = 'ssEditAlt'; - else if ($this->_get['action'] == 'approve') - $this->handler = 'ssApprove'; - else if ($this->_get['action'] == 'sticky') - $this->handler = 'ssSticky'; - else if ($this->_get['action'] == 'delete') - $this->handler = 'ssDelete'; - else if ($this->_get['action'] == 'relocate') - $this->handler = 'ssRelocate'; - } - else if ($this->params[0] == 'siteconfig' && $this->_get['action']) - { - if (!User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN)) - return; - - if ($this->_get['action'] == 'add') - $this->handler = 'confAdd'; - else if ($this->_get['action'] == 'remove') - $this->handler = 'confRemove'; - else if ($this->_get['action'] == 'update') - $this->handler = 'confUpdate'; - } - else if ($this->params[0] == 'weight-presets' && $this->_get['action']) - { - if (!User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN | U_GROUP_BUREAU)) - return; - - if ($this->_get['action'] == 'save') - $this->handler = 'wtSave'; - } - else if ($this->params[0] == 'spawn-override') - { - if (!User::isInGroup(U_GROUP_MODERATOR)) - return; - - $this->handler = 'spawnPosFix'; - } - else if ($this->params[0] == 'guide') - { - if (!User::isInGroup(U_GROUP_STAFF)) - return; - - $this->handler = 'guideManage'; - } - } - - // get all => null (optional) - // evaled response .. UNK - protected function ssList() : string - { - // ssm_screenshotPages - // ssm_numPagesFound - - $pages = CommunityContent::getScreenshotPagesForManager($this->_get['all'], $nPages); - $buff = 'ssm_screenshotPages = '.Util::toJSON($pages).";\n"; - $buff .= 'ssm_numPagesFound = '.$nPages.';'; - - return $buff; - } - - // get: [type => type, typeId => typeId] || [user => username] - // evaled response .. UNK - protected function ssManage() : string - { - $res = []; - - if ($this->_get['type'] && $this->_get['type'] && $this->_get['typeid'] && $this->_get['typeid']) - $res = CommunityContent::getScreenshotsForManager($this->_get['type'], $this->_get['typeid']); - else if ($this->_get['user']) - if ($uId = DB::Aowow()->selectCell('SELECT id FROM ?_account WHERE displayName = ?', $this->_get['user'])) - $res = CommunityContent::getScreenshotsForManager(0, 0, $uId); - - return 'ssm_screenshotData = '.Util::toJSON($res); - } - - // get: id => SSid - // resp: '' - protected function ssEditAlt() : void - { - // doesn't need to be htmlEscaped, ths javascript does that - if ($this->_get['id'] && $this->_post['alt'] !== null) - DB::Aowow()->query('UPDATE ?_screenshots SET caption = ? WHERE id = ?d', trim($this->_post['alt']), $this->_get['id'][0]); - } - - // get: id => comma-separated SSids - // resp: '' - protected function ssApprove() : void - { - if (!$this->reqGET('id')) - { - trigger_error('AjaxAdmin::ssApprove - screenshotId empty', E_USER_ERROR); - return; - } - - // create resized and thumb version of screenshot - $resized = [772, 618]; - $thumb = [150, 150]; - $path = 'static/uploads/screenshots/%s/%d.jpg'; - - foreach ($this->_get['id'] as $id) - { - // must not be already approved - if ($ssEntry = DB::Aowow()->selectRow('SELECT userIdOwner, date, type, typeId FROM ?_screenshots WHERE (status & ?d) = 0 AND id = ?d', CC_FLAG_APPROVED, $id)) - { - // should also error-log - if (!file_exists(sprintf($path, 'pending', $id))) - { - trigger_error('AjaxAdmin::ssApprove - screenshot #'.$id.' exists in db but not as file', E_USER_ERROR); - continue; - } - - $srcImg = imagecreatefromjpeg(sprintf($path, 'pending', $id)); - $srcW = imagesx($srcImg); - $srcH = imagesy($srcImg); - - // write thumb - $scale = min(1.0, min($thumb[0] / $srcW, $thumb[1] / $srcH)); - $destW = $srcW * $scale; - $destH = $srcH * $scale; - $destImg = imagecreatetruecolor($destW, $destH); - - imagefill($destImg, 0, 0, imagecolorallocate($destImg, 255, 255, 255)); - imagecopyresampled($destImg, $srcImg, 0, 0, 0, 0, $destW, $destH, $srcW, $srcH); - - imagejpeg($destImg, sprintf($path, 'thumb', $id), 100); - - // write resized (only if required) - if ($srcW > $resized[0] || $srcH > $resized[1]) - { - $scale = min(1.0, min($resized[0] / $srcW, $resized[1] / $srcH)); - $destW = $srcW * $scale; - $destH = $srcH * $scale; - $destImg = imagecreatetruecolor($destW, $destH); - - imagefill($destImg, 0, 0, imagecolorallocate($destImg, 255, 255, 255)); - imagecopyresampled($destImg, $srcImg, 0, 0, 0, 0, $destW, $destH, $srcW, $srcH); - - imagejpeg($destImg, sprintf($path, 'resized', $id), 100); - } - - imagedestroy($srcImg); - - // move screenshot from pending to normal - rename(sprintf($path, 'pending', $id), sprintf($path, 'normal', $id)); - - // set as approved in DB and gain rep (once!) - DB::Aowow()->query('UPDATE ?_screenshots SET status = ?d, userIdApprove = ?d WHERE id = ?d', CC_FLAG_APPROVED, User::$id, $id); - Util::gainSiteReputation($ssEntry['userIdOwner'], SITEREP_ACTION_UPLOAD, ['id' => $id, 'what' => 1, 'date' => $ssEntry['date']]); - // flag DB entry as having screenshots - if ($tbl = Type::getClassAttrib($ssEntry['type'], 'dataTable')) - DB::Aowow()->query('UPDATE '.$tbl.' SET cuFlags = cuFlags | ?d WHERE id = ?d', CUSTOM_HAS_SCREENSHOT, $ssEntry['typeId']); - } - else - trigger_error('AjaxAdmin::ssApprove - screenshot #'.$id.' not in db or already approved', E_USER_ERROR); - } - - return; - } - - // get: id => comma-separated SSids - // resp: '' - protected function ssSticky() : void - { - if (!$this->reqGET('id')) - { - trigger_error('AjaxAdmin::ssSticky - screenshotId empty', E_USER_ERROR); - return; - } - - // approve soon to be sticky screenshots - $this->ssApprove(); - - // this one is a bit strange: as far as i've seen, the only thing a 'sticky' screenshot does is show up in the infobox - // this also means, that only one screenshot per page should be sticky - // so, handle it one by one and the last one affecting one particular type/typId-key gets the cake - foreach ($this->_get['id'] as $id) - { - // reset all others - DB::Aowow()->query('UPDATE ?_screenshots a, ?_screenshots b SET a.status = a.status & ~?d WHERE a.type = b.type AND a.typeId = b.typeId AND a.id <> b.id AND b.id = ?d', CC_FLAG_STICKY, $id); - - // toggle sticky status - DB::Aowow()->query('UPDATE ?_screenshots SET `status` = IF(`status` & ?d, `status` & ~?d, `status` | ?d) WHERE id = ?d AND `status` & ?d', CC_FLAG_STICKY, CC_FLAG_STICKY, CC_FLAG_STICKY, $id, CC_FLAG_APPROVED); - } - } - - // get: id => comma-separated SSids - // resp: '' - // 2 steps: 1) remove from sight, 2) remove from disk - protected function ssDelete() : void - { - if (!$this->reqGET('id')) - { - trigger_error('AjaxAdmin::ssDelete - screenshotId empty', E_USER_ERROR); - return; - } - - $path = 'static/uploads/screenshots/%s/%d.jpg'; - - foreach ($this->_get['id'] as $id) - { - // irrevocably remove already deleted files - if (User::isInGroup(U_GROUP_ADMIN) && DB::Aowow()->selectCell('SELECT 1 FROM ?_screenshots WHERE status & ?d AND id = ?d', CC_FLAG_DELETED, $id)) - { - DB::Aowow()->query('DELETE FROM ?_screenshots WHERE id = ?d', $id); - if (file_exists(sprintf($path, 'pending', $id))) - unlink(sprintf($path, 'pending', $id)); - - continue; - } - - // move pending or normal to pending - if (file_exists(sprintf($path, 'normal', $id))) - rename(sprintf($path, 'normal', $id), sprintf($path, 'pending', $id)); - - // remove resized and thumb - if (file_exists(sprintf($path, 'thumb', $id))) - unlink(sprintf($path, 'thumb', $id)); - - if (file_exists(sprintf($path, 'resized', $id))) - unlink(sprintf($path, 'resized', $id)); - } - - // flag as deleted if not aready - $oldEntries = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, GROUP_CONCAT(typeId) FROM ?_screenshots WHERE id IN (?a) GROUP BY `type`', $this->_get['id']); - DB::Aowow()->query('UPDATE ?_screenshots SET status = ?d, userIdDelete = ?d WHERE id IN (?a)', CC_FLAG_DELETED, User::$id, $this->_get['id']); - // deflag db entry as having screenshots - foreach ($oldEntries as $type => $typeIds) - { - $typeIds = explode(',', $typeIds); - $toUnflag = DB::Aowow()->selectCol('SELECT typeId AS ARRAY_KEY, IF(BIT_OR(`status`) & ?d, 1, 0) AS hasMore FROM ?_screenshots WHERE `type` = ?d AND typeId IN (?a) GROUP BY typeId HAVING hasMore = 0', CC_FLAG_APPROVED, $type, $typeIds); - if ($toUnflag && ($tbl = Type::getClassAttrib($type, 'dataTable'))) - DB::Aowow()->query('UPDATE '.$tbl.' SET cuFlags = cuFlags & ~?d WHERE id IN (?a)', CUSTOM_HAS_SCREENSHOT, array_keys($toUnflag)); - } - } - - // get: id => ssId, typeid => typeId (but not type..?) - // resp: '' - protected function ssRelocate() : void - { - if (!$this->reqGET('id', 'typeid')) - { - trigger_error('AjaxAdmin::ssRelocate - screenshotId or typeId empty', E_USER_ERROR); - return; - } - - $id = $this->_get['id'][0]; - [$type, $oldTypeId] = array_values(DB::Aowow()->selectRow('SELECT type, typeId FROM ?_screenshots WHERE id = ?d', $id)); - $typeId = (int)$this->_get['typeid']; - - $tc = Type::newList($type, [['id', $typeId]]); - if ($tc && !$tc->error) - { - // move screenshot - DB::Aowow()->query('UPDATE ?_screenshots SET typeId = ?d WHERE id = ?d', $typeId, $id); - - // flag target as having screenshot - DB::Aowow()->query('UPDATE '.$tc::$dataTable.' SET cuFlags = cuFlags | ?d WHERE id = ?d', CUSTOM_HAS_SCREENSHOT, $typeId); - - // deflag source for having had screenshots (maybe) - $ssInfo = DB::Aowow()->selectRow('SELECT IF(BIT_OR(~status) & ?d, 1, 0) AS hasMore FROM ?_screenshots WHERE `status`& ?d AND `type` = ?d AND typeId = ?d', CC_FLAG_DELETED, CC_FLAG_APPROVED, $type, $oldTypeId); - if($ssInfo || !$ssInfo['hasMore']) - DB::Aowow()->query('UPDATE '.$tc::$dataTable.' SET cuFlags = cuFlags & ~?d WHERE id = ?d', CUSTOM_HAS_SCREENSHOT, $oldTypeId); - } - else - trigger_error('AjaxAdmin::ssRelocate - invalid typeId #'.$typeId.' for type #'.$type, E_USER_ERROR); - } - - protected function confAdd() : string - { - $key = trim($this->_get['key']); - $val = trim(urldecode($this->_get['val'])); - - if ($key === null) - return 'empty option name given'; - - if (!strlen($key)) - return 'invalid chars in option name: [a-z 0-9 _ . -] are allowed'; - - if (ini_get($key) === false || ini_set($key, $val) === false) - return 'this configuration option cannot be set'; - - if (DB::Aowow()->selectCell('SELECT 1 FROM ?_config WHERE `flags` & ?d AND `key` = ?', CON_FLAG_PHP, $key)) - return 'this configuration option is already in use'; - - DB::Aowow()->query('INSERT IGNORE INTO ?_config (`key`, `value`, `cat`, `flags`) VALUES (?, ?, 0, ?d)', $key, $val, CON_FLAG_TYPE_STRING | CON_FLAG_PHP); - return ''; - } - - protected function confRemove() : string - { - if (!$this->reqGET('key')) - return 'invalid configuration option given'; - - if (DB::Aowow()->query('DELETE FROM ?_config WHERE `key` = ? AND (`flags` & ?d) = 0', $this->_get['key'], CON_FLAG_PERSISTENT)) - return ''; - else - return 'option name is either protected or was not found'; - } - - protected function confUpdate() : string - { - $key = trim($this->_get['key']); - $val = trim(urldecode($this->_get['val'])); - $msg = ''; - - if (!strlen($key)) - return 'empty option name given'; - - $cfg = DB::Aowow()->selectRow('SELECT `flags`, `value` FROM ?_config WHERE `key` = ?', $key); - if (!$cfg) - return 'configuration option not found'; - - if (!($cfg['flags'] & CON_FLAG_TYPE_STRING) && !strlen($val)) - return 'empty value given'; - else if ($cfg['flags'] & CON_FLAG_TYPE_INT && !preg_match('/^-?\d+$/i', $val)) - return "value must be integer"; - else if ($cfg['flags'] & CON_FLAG_TYPE_FLOAT && !preg_match('/^-?\d*(,|.)?\d+$/i', $val)) - return "value must be float"; - else if ($cfg['flags'] & CON_FLAG_TYPE_BOOL && $val != '1') - $val = '0'; - - DB::Aowow()->query('UPDATE ?_config SET `value` = ? WHERE `key` = ?', $val, $key); - if (!$this->confOnChange($key, $val, $msg)) - DB::Aowow()->query('UPDATE ?_config SET `value` = ? WHERE `key` = ?', $cfg['value'], $key); - - return $msg; - } - - protected function wtSave() : string - { - if (!$this->reqPOST('id', '__icon')) - return '3'; - - // save to db - DB::Aowow()->query('DELETE FROM ?_account_weightscale_data WHERE id = ?d', $this->_post['id']); - DB::Aowow()->query('UPDATE ?_account_weightscales SET `icon`= ? WHERE `id` = ?d', $this->_post['__icon'], $this->_post['id']); - - foreach (explode(',', $this->_post['scale']) as $s) - { - [$k, $v] = explode(':', $s); - - if (!in_array($k, Util::$weightScales) || $v < 1) - continue; - - if (DB::Aowow()->query('INSERT INTO ?_account_weightscale_data VALUES (?d, ?, ?d)', $this->_post['id'], $k, $v) === null) - return '1'; - } - - // write dataset - exec('php aowow --build=weightPresets', $out); - foreach ($out as $o) - if (strstr($o, 'ERR')) - return '2'; - - // all done - return '0'; - } - - protected function spawnPosFix() : string - { - if (!$this->reqGET('type', 'guid', 'area', 'floor')) - return '-4'; - - $guid = $this->_get['guid']; - $type = $this->_get['type']; - $area = $this->_get['area']; - $floor = $this->_get['floor']; - - if (!in_array($type, [Type::NPC, Type::OBJECT, Type::SOUND, Type::AREATRIGGER])) - return '-3'; - - DB::Aowow()->query('REPLACE INTO ?_spawns_override VALUES (?d, ?d, ?d, ?d, ?d)', $type, $guid, $area, $floor, AOWOW_REVISION); - - if ($wPos = Game::getWorldPosForGUID($type, $guid)) - { - if ($point = Game::worldPosToZonePos($wPos[$guid]['mapId'], $wPos[$guid]['posX'], $wPos[$guid]['posY'], $area, $floor)) - { - $updGUIDs = [$guid]; - $newPos = array( - 'posX' => $point[0]['posX'], - 'posY' => $point[0]['posY'], - 'areaId' => $point[0]['areaId'], - 'floor' => $point[0]['floor'] - ); - - // if creature try for waypoints - if ($type == Type::NPC) - { - $jobs = array( - 'SELECT -w.id AS `entry`, w.point AS `pointId`, w.position_y AS `posX`, w.position_x AS `posY` FROM creature_addon ca JOIN waypoint_data w ON w.id = ca.path_id WHERE ca.guid = ?d AND ca.path_id <> 0', - 'SELECT `entry`, `pointId`, `location_y` AS `posX`, `location_x` AS `posY` FROM `script_waypoint` WHERE `entry` = ?d', - 'SELECT `entry`, `pointId`, `position_y` AS `posX`, `position_x` AS `posY` FROM `waypoints` WHERE `entry` = ?d' - ); - - foreach ($jobs as $idx => $job) - { - if ($swp = DB::World()->select($job, $idx ? $wPos[$guid]['id'] : $guid)) - { - foreach ($swp as $w) - { - if ($point = Game::worldPosToZonePos($wPos[$guid]['mapId'], $w['posX'], $w['posY'], $area, $floor)) - { - $p = array( - 'posX' => $point[0]['posX'], - 'posY' => $point[0]['posY'], - 'areaId' => $point[0]['areaId'], - 'floor' => $point[0]['floor'] - ); - } - DB::Aowow()->query('UPDATE ?_creature_waypoints SET ?a WHERE `creatureOrPath` = ?d AND `point` = ?d', $p, $w['entry'], $w['pointId']); - } - } - } - - // also move linked vehicle accessories (on the very same position) - $updGUIDs = array_merge($updGUIDs, DB::Aowow()->selectCol('SELECT s2.guid FROM ?_spawns s1 JOIN ?_spawns s2 ON s1.posX = s2.posX AND s1.posY = s2.posY AND - s1.areaId = s2.areaId AND s1.floor = s2.floor AND s2.guid < 0 WHERE s1.guid = ?d', $guid)); - } - - DB::Aowow()->query('UPDATE ?_spawns SET ?a WHERE `type` = ?d AND `guid` IN (?a)', $newPos, $type, $updGUIDs); - - return '1'; - } - - return '-2'; - } - - return '-1'; - } - - protected function guideManage() : string - { - $update = function (int $id, int $status, ?string $msg = null) : bool - { - if (!DB::Aowow()->query('UPDATE ?_guides SET `status` = ?d WHERE `id` = ?d', $status, $id)) - return false; - - // set display rev to latest - if ($status == GUIDE_STATUS_APPROVED) - DB::Aowow()->query('UPDATE ?_guides SET `rev` = (SELECT `rev` FROM ?_articles WHERE `type` = ?d AND `typeId` = ?d ORDER BY `rev` DESC LIMIT 1) WHERE `id` = ?d', Type::GUIDE, $id, $id); - - DB::Aowow()->query('INSERT INTO ?_guides_changelog (`id`, `date`, `userId`, `status`) VALUES (?d, ?d, ?d, ?d)', $id, time(), User::$id, $status); - if ($msg) - DB::Aowow()->query('INSERT INTO ?_guides_changelog (`id`, `date`, `userId`, `msg`) VALUES (?d, ?d, ?d, ?)' , $id, time(), User::$id, $msg); - return true; - }; - - if (!$this->_post['id']) - trigger_error('AjaxHander::guideManage - malformed request: id: '.$this->_post['id'].', status: '.$this->_post['status']); - else - { - $guide = DB::Aowow()->selectRow('SELECT `userId`, `status` FROM ?_guides WHERE `id` = ?d', $this->_post['id']); - if (!$guide) - trigger_error('AjaxHander::guideManage - guide #'.$this->_post['id'].' not found'); - else - { - if ($this->_post['status'] == $guide['status']) - trigger_error('AjaxHander::guideManage - guide #'.$this->_post['id'].' already has status #'.$this->_post['status']); - else - { - if ($this->_post['status'] == GUIDE_STATUS_APPROVED) - { - if ($update($this->_post['id'], GUIDE_STATUS_APPROVED, $this->_post['msg'])) - { - Util::gainSiteReputation($guide['userId'], SITEREP_ACTION_ARTICLE, ['id' => $this->_post['id']]); - return '1'; - } - else - return '-2'; - } - else if ($this->_post['status'] == GUIDE_STATUS_REJECTED) - return $update($this->_post['id'], GUIDE_STATUS_REJECTED, $this->_post['msg']) ? '1' : '-2'; - else - trigger_error('AjaxHander::guideManage - unhandled status change request'); - } - } - } - - return '-1'; - } - - - /***************************/ - /* additional input filter */ - /***************************/ - - protected static function checkKey(string $val) : string - { - // expecting string - if (preg_match('/[^a-z0-9_\.\-]/i', $val)) - return ''; - - return strtolower($val); - } - - protected static function checkUser($val) : string - { - $n = Util::lower(trim(urldecode($val))); - - if (User::isValidName($n)) - return $n; - - return ''; - } - - protected static function checkScale($val) : string - { - if (preg_match('/^((\w+:\d+)(,\w+:\d+)*)$/', $val)) - return $val; - - return ''; - } - - - /**********/ - /* helper */ - /**********/ - - private static function confOnChange(string $key, string $val, string &$msg) : bool - { - $fn = $buildList = null; - - switch ($key) - { - case 'battlegroup': - $buildList = 'realms,realmMenu'; - break; - case 'name_short': - $buildList = 'searchboxBody,demo,searchplugin'; - break; - case 'site_host': - $buildList = 'searchplugin,demo,power,searchboxBody'; - break; - case 'static_host': - $buildList = 'searchplugin,power,searchboxBody,searchboxScript'; - break; - case 'contact_email': - $buildList = 'markup'; - break; - case 'locales': - $buildList = 'locales'; - $msg .= ' * remember to rebuild all static files for the language you just added.
'; - $msg .= ' * you can speed this up by supplying the regionCode to the setup:
--locales= -f
'; - break; - case 'profiler_enable': - $buildList = 'realms,realmMenu'; - $fn = function($x) use (&$msg) { - if (!$x) - return true; - - return Profiler::queueStart($msg); - }; - break; - case 'acc_auth_mode': - $fn = function($x) use (&$msg) { - if ($x == 1 && !extension_loaded('gmp')) - { - $msg .= 'PHP extension GMP is required to use TrinityCore as auth source, but is not currently enabled.
'; - return false; - } - - return true; - }; - break; - default: // nothing to do, everything is fine - return true; - } - - if ($buildList) - { - // we need to use exec as build() can only be run from CLI - exec('php aowow --build='.$buildList, $out); - foreach ($out as $o) - if (strstr($o, 'ERR')) - $msg .= explode('0m]', $o)[1]."
\n"; - } - - return $fn ? $fn($val) : true; - } -} - -?> diff --git a/includes/ajaxHandler/arenateam.class.php b/includes/ajaxHandler/arenateam.class.php deleted file mode 100644 index df95cafd..00000000 --- a/includes/ajaxHandler/arenateam.class.php +++ /dev/null @@ -1,82 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdList' ], - 'profile' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkEmptySet'], - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$this->params) - return; - - switch ($this->params[0]) - { - case 'resync': - $this->handler = 'handleResync'; - break; - case 'status': - $this->handler = 'handleStatus'; - break; - } - } - - /* params - id: - user: [optional, not used] - profile: [optional, also get related chars] - return: 1 - */ - protected function handleResync() : string - { - if ($teams = DB::Aowow()->select('SELECT realm, realmGUID FROM ?_profiler_arena_team WHERE id IN (?a)', $this->_get['id'])) - foreach ($teams as $t) - Profiler::scheduleResync(Type::ARENA_TEAM, $t['realm'], $t['realmGUID']); - - if ($this->_get['profile']) - if ($chars = DB::Aowow()->select('SELECT realm, realmGUID FROM ?_profiler_profiles p JOIN ?_profiler_arena_team_member atm ON atm.profileId = p.id WHERE atm.arenaTeamId IN (?a)', $this->_get['id'])) - foreach ($chars as $c) - Profiler::scheduleResync(Type::PROFILE, $c['realm'], $c['realmGUID']); - - return '1'; - } - - /* params - id: - return - - [ - nQueueProcesses, - [statusCode, timeToRefresh, curQueuePos, errorCode, nResyncTries], - [] - ... - ] - - not all fields are required, if zero they are omitted - statusCode: - 0: end the request - 1: waiting - 2: working... - 3: ready; click to view - 4: error / retry - errorCode: - 0: unk error - 1: char does not exist - 2: armory gone - */ - protected function handleStatus() : string - { - $response = Profiler::resyncStatus(Type::ARENA_TEAM, $this->_get['id']); - return Util::toJSON($response); - } -} - -?> diff --git a/includes/ajaxHandler/comment.class.php b/includes/ajaxHandler/comment.class.php deleted file mode 100644 index c635391f..00000000 --- a/includes/ajaxHandler/comment.class.php +++ /dev/null @@ -1,475 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdListUnsigned'], - 'body' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkFulltext' ], - 'commentbody' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkFulltext' ], - 'response' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW ], - 'reason' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW ], - 'remove' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'commentId' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'replyId' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'sticky' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - // 'username' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW ] - ); - - protected $_get = array( - 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt'], - 'type' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt'], - 'typeid' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt'], - 'rating' => ['filter' => FILTER_SANITIZE_NUMBER_INT] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$this->params || count($this->params) != 1) - return; - - // note: return values must be formated as STRICT json! - - // select handler - if ($this->params[0] == 'add') - $this->handler = 'handleCommentAdd'; - else if ($this->params[0] == 'edit') - $this->handler = 'handleCommentEdit'; - else if ($this->params[0] == 'delete') - $this->handler = 'handleCommentDelete'; - else if ($this->params[0] == 'undelete') - $this->handler = 'handleCommentUndelete'; - else if ($this->params[0] == 'rating') // up/down - distribution - $this->handler = 'handleCommentRating'; - else if ($this->params[0] == 'vote') // up, down and remove - $this->handler = 'handleCommentVote'; - else if ($this->params[0] == 'sticky') // toggle flag - $this->handler = 'handleCommentSticky'; - else if ($this->params[0] == 'out-of-date') // toggle flag - $this->handler = 'handleCommentOutOfDate'; - else if ($this->params[0] == 'show-replies') - $this->handler = 'handleCommentShowReplies'; - else if ($this->params[0] == 'add-reply') // also returns all replies on success - $this->handler = 'handleReplyAdd'; - else if ($this->params[0] == 'edit-reply') // also returns all replies on success - $this->handler = 'handleReplyEdit'; - else if ($this->params[0] == 'detach-reply') - $this->handler = 'handleReplyDetach'; - else if ($this->params[0] == 'delete-reply') - $this->handler = 'handleReplyDelete'; - else if ($this->params[0] == 'flag-reply') - $this->handler = 'handleReplyFlag'; - else if ($this->params[0] == 'upvote-reply') - $this->handler = 'handleReplyUpvote'; - else if ($this->params[0] == 'downvote-reply') - $this->handler = 'handleReplyDownvote'; - } - - // i .. have problems believing, that everything uses nifty ajax while adding comments requires a brutal header(Loacation: ), yet, thats how it is - protected function handleCommentAdd() : string - { - if (!$this->_get['typeid'] || !$this->_get['type'] || !Type::exists($this->_get['type'])) - { - trigger_error('AjaxComment::handleCommentAdd - malforemd request received', E_USER_ERROR); - return ''; // whatever, we cant even send him back - } - - // this type cannot be commented on - if (!Type::checkClassAttrib($this->_get['type'], 'contribute', CONTRIBUTE_CO)) - { - trigger_error('AjaxComment::handleCommentAdd - tried to comment on unsupported type #'.$this->_get['type'], E_USER_ERROR); - return ''; - } - - // trim to max length - if (!User::isInGroup(U_GROUP_MODERATOR) && mb_strlen($this->_post['commentbody']) > (self::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1))) - $this->_post['commentbody'] = mb_substr($this->_post['commentbody'], 0, (self::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1))); - - if (User::canComment()) - { - if (!empty($this->_post['commentbody']) && mb_strlen($this->_post['commentbody']) >= self::COMMENT_LENGTH_MIN) - { - if ($postIdx = DB::Aowow()->query('INSERT INTO ?_comments (type, typeId, userId, roles, body, date) VALUES (?d, ?d, ?d, ?d, ?, UNIX_TIMESTAMP())', $this->_get['type'], $this->_get['typeid'], User::$id, User::$groups, $this->_post['commentbody'])) - { - Util::gainSiteReputation(User::$id, SITEREP_ACTION_COMMENT, ['id' => $postIdx]); - - // every comment starts with a rating of +1 and i guess the simplest thing to do is create a db-entry with the system as owner - DB::Aowow()->query('INSERT INTO ?_user_ratings (`type`, `entry`, `userId`, `value`) VALUES (?d, ?d, 0, 1)', RATING_COMMENT, $postIdx); - - // flag target with hasComment - if ($tbl = Type::getClassAttrib($this->_get['type'], 'dataTable')) - DB::Aowow()->query('UPDATE '.$tbl.' SET cuFlags = cuFlags | ?d WHERE id = ?d', CUSTOM_HAS_COMMENT, $this->_get['typeid']); - } - else - { - $_SESSION['error']['co'] = Lang::main('intError'); - trigger_error('AjaxComment::handleCommentAdd - write to db failed', E_USER_ERROR); - } - } - else - $_SESSION['error']['co'] = Lang::main('textLength', [mb_strlen($this->_post['commentbody']), self::COMMENT_LENGTH_MIN, self::COMMENT_LENGTH_MAX]); - } - else - $_SESSION['error']['co'] = Lang::main('cannotComment'); - - $this->doRedirect = true; - - $idOrUrl = $this->_get['typeid']; - if ($this->_get['type'] == Type::GUIDE) - if ($_ = DB::Aowow()->selectCell('SELECT `url` FROM ?_guides WHERE `id` = ?d', $this->_get['typeid'])) - $idOrUrl = $_; - - return '?'.Type::getFileString($this->_get['type']).'='.$idOrUrl.'#comments'; - } - - protected function handleCommentEdit() : void - { - if (!User::canComment() && !User::isInGroup(U_GROUP_MODERATOR)) - { - trigger_error('AjaxComment::handleCommentEdit - user #'.User::$id.' not allowed to edit', E_USER_ERROR); - return; - } - - if (!$this->_get['id'] || !$this->_post['body']) - { - trigger_error('AjaxComment::handleCommentEdit - malforemd request received', E_USER_ERROR); - return; - } - - if (mb_strlen($this->_post['body']) < self::COMMENT_LENGTH_MIN) - return; // no point in reporting this trifle - - // trim to max length - if (!User::isInGroup(U_GROUP_MODERATOR) && mb_strlen($this->_post['body']) > (self::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1))) - $this->_post['body'] = mb_substr($this->_post['body'], 0, (self::COMMENT_LENGTH_MAX * (User::isPremium() ? 3 : 1))); - - $update = array( - 'body' => $this->_post['body'], - 'editUserId' => User::$id, - 'editDate' => time() - ); - - if (User::isInGroup(U_GROUP_MODERATOR)) - { - $update['responseBody'] = !$this->_post['response'] ? '' : $this->_post['response']; - $update['responseUserId'] = !$this->_post['response'] ? 0 : User::$id; - $update['responseRoles'] = !$this->_post['response'] ? 0 : User::$groups; - } - - DB::Aowow()->query('UPDATE ?_comments SET editCount = editCount + 1, ?a WHERE id = ?d', $update, $this->_get['id']); - } - - protected function handleCommentDelete() : void - { - if (!$this->_post['id'] || !User::$id) - { - trigger_error('AjaxComment::handleCommentDelete - commentId empty or user not logged in', E_USER_ERROR); - return; - } - - // in theory, there is a username passed alongside... lets just use the current user (see user.js) - $ok = DB::Aowow()->query('UPDATE ?_comments SET flags = flags | ?d, deleteUserId = ?d, deleteDate = UNIX_TIMESTAMP() WHERE id IN (?a){ AND userId = ?d}', - CC_FLAG_DELETED, - User::$id, - $this->_post['id'], - User::isInGroup(U_GROUP_MODERATOR) ? DBSIMPLE_SKIP : User::$id - ); - - // deflag hasComment - if ($ok) - { - $coInfo = DB::Aowow()->selectRow('SELECT IF(BIT_OR(~b.flags) & ?d, 1, 0) as hasMore, b.type, b.typeId FROM ?_comments a JOIN ?_comments b ON a.type = b.type AND a.typeId = b.typeId WHERE a.id = ?d', - CC_FLAG_DELETED, - $this->_post['id'] - ); - - if (!$coInfo['hasMore'] && ($tbl = Type::getClassAttrib($coInfo['type'], 'dataTable'))) - DB::Aowow()->query('UPDATE '.$tbl.' SET cuFlags = cuFlags & ~?d WHERE id = ?d', CUSTOM_HAS_COMMENT, $coInfo['typeId']); - } - else - trigger_error('AjaxComment::handleCommentDelete - user #'.User::$id.' could not flag comment #'.$this->_post['id'].' as deleted', E_USER_ERROR); - } - - protected function handleCommentUndelete() : void - { - if (!$this->_post['id'] || !User::$id) - { - trigger_error('AjaxComment::handleCommentUndelete - commentId empty or user not logged in', E_USER_ERROR); - return; - } - - // in theory, there is a username passed alongside... lets just use the current user (see user.js) - $ok = DB::Aowow()->query('UPDATE ?_comments SET flags = flags & ~?d WHERE id IN (?a){ AND userId = deleteUserId AND deleteUserId = ?d}', - CC_FLAG_DELETED, - $this->_post['id'], - User::isInGroup(U_GROUP_MODERATOR) ? DBSIMPLE_SKIP : User::$id - ); - - // reflag hasComment - if ($ok) - { - $coInfo = DB::Aowow()->selectRow('SELECT type, typeId FROM ?_comments WHERE id = ?d', $this->_post['id']); - if ($tbl = Type::getClassAttrib($coInfo['type'], 'dataTable')) - DB::Aowow()->query('UPDATE '.$tbl.' SET cuFlags = cuFlags | ?d WHERE id = ?d', CUSTOM_HAS_COMMENT, $coInfo['typeId']); - } - else - trigger_error('AjaxComment::handleCommentUndelete - user #'.User::$id.' could not unflag comment #'.$this->_post['id'].' as deleted', E_USER_ERROR); - } - - protected function handleCommentRating() : string - { - if (!$this->_get['id']) - return Util::toJSON(['success' => 0]); - - if ($votes = DB::Aowow()->selectRow('SELECT 1 AS success, SUM(IF(`value` > 0, `value`, 0)) AS up, SUM(IF(`value` < 0, -`value`, 0)) AS down FROM ?_user_ratings WHERE `type` = ?d AND `entry` = ?d AND userId <> 0 GROUP BY `entry`', RATING_COMMENT, $this->_get['id'])) - return Util::toJSON($votes); - else - return Util::toJSON(['success' => 1, 'up' => 0, 'down' => 0]); - } - - protected function handleCommentVote() : string - { - if (!User::$id || !$this->_get['id'] || !$this->_get['rating']) - return Util::toJSON(['error' => 1, 'message' => Lang::main('genericError')]); - - $target = DB::Aowow()->selectRow('SELECT c.`userId` AS owner, ur.`value` FROM ?_comments c LEFT JOIN ?_user_ratings ur ON ur.`type` = ?d AND ur.`entry` = c.id AND ur.`userId` = ?d WHERE c.id = ?d', RATING_COMMENT, User::$id, $this->_get['id']); - $val = User::canSupervote() ? 2 : 1; - if ($this->_get['rating'] < 0) - $val *= -1; - - if (User::getCurDailyVotes() <= 0) - return Util::toJSON(['error' => 1, 'message' => Lang::main('tooManyVotes')]); - else if (!$target || $val != $this->_get['rating']) - return Util::toJSON(['error' => 1, 'message' => Lang::main('genericError')]); - else if (($val > 0 && !User::canUpvote()) || ($val < 0 && !User::canDownvote())) - return Util::toJSON(['error' => 1, 'message' => Lang::main('bannedRating')]); - - $ok = false; - // old and new have same sign; undo vote (user may have gained/lost access to superVote in the meantime) - if ($target['value'] && ($target['value'] < 0) == ($val < 0)) - $ok = DB::Aowow()->query('DELETE FROM ?_user_ratings WHERE `type` = ?d AND `entry` = ?d AND `userId` = ?d', RATING_COMMENT, $this->_get['id'], User::$id); - else // replace, because we may be overwriting an old, opposing vote - if ($ok = DB::Aowow()->query('REPLACE INTO ?_user_ratings (`type`, `entry`, `userId`, `value`) VALUES (?d, ?d, ?d, ?d)', RATING_COMMENT, (int)$this->_get['id'], User::$id, $val)) - User::decrementDailyVotes(); // do not refund retracted votes! - - if (!$ok) - return Util::toJSON(['error' => 1, 'message' => Lang::main('genericError')]); - - if ($val > 0) // gain rep - Util::gainSiteReputation($target['owner'], SITEREP_ACTION_UPVOTED, ['id' => $this->_get['id'], 'voterId' => User::$id]); - else if ($val < 0) - Util::gainSiteReputation($target['owner'], SITEREP_ACTION_DOWNVOTED, ['id' => $this->_get['id'], 'voterId' => User::$id]); - - return Util::toJSON(['error' => 0]); - } - - protected function handleCommentSticky() : void - { - if (!$this->_post['id'] || !User::isInGroup(U_GROUP_MODERATOR)) - { - trigger_error('AjaxComment::handleCommentSticky - commentId empty or user #'.User::$id.' not moderator', E_USER_ERROR); - return; - } - - if ($this->_post['sticky']) - DB::Aowow()->query('UPDATE ?_comments SET flags = flags | ?d WHERE id = ?d', CC_FLAG_STICKY, $this->_post['id'][0]); - else - DB::Aowow()->query('UPDATE ?_comments SET flags = flags & ~?d WHERE id = ?d', CC_FLAG_STICKY, $this->_post['id'][0]); - } - - protected function handleCommentOutOfDate() : string - { - $this->contentType = MIME_TYPE_TEXT; - - if (!$this->_post['id']) - { - trigger_error('AjaxComment::handleCommentOutOfDate - commentId empty', E_USER_ERROR); - return Lang::main('intError'); - } - - $ok = false; - if (User::isInGroup(U_GROUP_MODERATOR)) // directly mark as outdated - { - if (!$this->_post['remove']) - $ok = DB::Aowow()->query('UPDATE ?_comments SET flags = flags | 0x4 WHERE id = ?d', $this->_post['id'][0]); - else - $ok = DB::Aowow()->query('UPDATE ?_comments SET flags = flags & ~0x4 WHERE id = ?d', $this->_post['id'][0]); - } - else if (DB::Aowow()->selectCell('SELECT 1 FROM ?_reports WHERE `mode` = ?d AND `reason`= ?d AND `subject` = ?d AND `userId` = ?d', 1, 17, $this->_post['id'][0], User::$id)) - return Lang::main('alreadyReport'); - else if (User::$id && !$this->_post['reason'] || mb_strlen($this->_post['reason']) < self::REPLY_LENGTH_MIN) - return Lang::main('textTooShort'); - else if (User::$id) // only report as outdated - $ok = Util::createReport(1, 17, $this->_post['id'][0], '[Outdated Comment] '.$this->_post['reason']); - - if ($ok) // this one is very special; as in: completely retarded - return 'ok'; // the script expects the actual characters 'ok' not some string like "ok" - else - trigger_error('AjaxComment::handleCommentOutOfDate - failed to update comment in db', E_USER_ERROR); - - return Lang::main('intError'); - } - - protected function handleCommentShowReplies() : string - { - return Util::toJSON(!$this->_get['id'] ? [] : CommunityContent::getCommentReplies($this->_get['id'])); - } - - protected function handleReplyAdd() : string - { - $this->contentType = MIME_TYPE_TEXT; - - if (!User::canComment()) - return Lang::main('cannotComment'); - - if (!$this->_post['commentId'] || !DB::Aowow()->selectCell('SELECT 1 FROM ?_comments WHERE id = ?d', $this->_post['commentId'])) - { - trigger_error('AjaxComment::handleReplyAdd - comment #'.$this->_post['commentId'].' does not exist', E_USER_ERROR); - return Lang::main('intError'); - } - - if (!$this->_post['body'] || mb_strlen($this->_post['body']) < self::REPLY_LENGTH_MIN || mb_strlen($this->_post['body']) > self::REPLY_LENGTH_MAX) - return Lang::main('textLength', [mb_strlen($this->_post['body']), self::REPLY_LENGTH_MIN, self::REPLY_LENGTH_MAX]); - - if (DB::Aowow()->query('INSERT INTO ?_comments (`userId`, `roles`, `body`, `date`, `replyTo`) VALUES (?d, ?d, ?, UNIX_TIMESTAMP(), ?d)', User::$id, User::$groups, $this->_post['body'], $this->_post['commentId'])) - return Util::toJSON(CommunityContent::getCommentReplies($this->_post['commentId'])); - - trigger_error('AjaxComment::handleReplyAdd - write to db failed', E_USER_ERROR); - return Lang::main('intError'); - } - - protected function handleReplyEdit() : string - { - $this->contentType = MIME_TYPE_TEXT; - - if (!User::canComment()) - return Lang::main('cannotComment'); - - if ((!$this->_post['replyId'] || !$this->_post['commentId']) && DB::Aowow()->selectCell('SELECT COUNT(1) FROM ?_comments WHERE id IN (?a)', [$this->_post['replyId'], $this->_post['commentId']])) - { - trigger_error('AjaxComment::handleReplyEdit - comment #'.$this->_post['commentId'].' or reply #'.$this->_post['replyId'].' does not exist', E_USER_ERROR); - return Lang::main('intError'); - } - - if (!$this->_post['body'] || mb_strlen($this->_post['body']) < self::REPLY_LENGTH_MIN || mb_strlen($this->_post['body']) > self::REPLY_LENGTH_MAX) - return Lang::main('textLength', [mb_strlen($this->_post['body']), self::REPLY_LENGTH_MIN, self::REPLY_LENGTH_MAX]); - - if (DB::Aowow()->query('UPDATE ?_comments SET body = ?, editUserId = ?d, editDate = UNIX_TIMESTAMP(), editCount = editCount + 1 WHERE id = ?d AND replyTo = ?d{ AND userId = ?d}', - $this->_post['body'], User::$id, $this->_post['replyId'], $this->_post['commentId'], User::isInGroup(U_GROUP_MODERATOR) ? DBSIMPLE_SKIP : User::$id)) - return Util::toJSON(CommunityContent::getCommentReplies($this->_post['commentId'])); - - trigger_error('AjaxComment::handleReplyEdit - write to db failed', E_USER_ERROR); - return Lang::main('intError'); - } - - protected function handleReplyDetach() : void - { - if (!$this->_post['id'] || !User::isInGroup(U_GROUP_MODERATOR)) - { - trigger_error('AjaxComment::handleReplyDetach - commentId empty or user #'.User::$id.' not moderator', E_USER_ERROR); - return; - } - - DB::Aowow()->query('UPDATE ?_comments c1, ?_comments c2 SET c1.replyTo = 0, c1.type = c2.type, c1.typeId = c2.typeId WHERE c1.replyTo = c2.id AND c1.id = ?d', $this->_post['id'][0]); - } - - protected function handleReplyDelete() : void - { - if (!User::$id || !$this->_post['id']) - { - trigger_error('AjaxComment::handleReplyDelete - commentId empty or user not logged in', E_USER_ERROR); - return; - } - - if (DB::Aowow()->query('DELETE FROM ?_comments WHERE id = ?d{ AND userId = ?d}', $this->_post['id'][0], User::isInGroup(U_GROUP_MODERATOR) ? DBSIMPLE_SKIP : User::$id)) - DB::Aowow()->query('DELETE FROM ?_user_ratings WHERE `type` = ?d AND `entry` = ?d', RATING_COMMENT, $this->_post['id'][0]); - else - trigger_error('AjaxComment::handleReplyDelete - deleting comment #'.$this->_post['id'][0].' by user #'.User::$id.' from db failed', E_USER_ERROR); - } - - protected function handleReplyFlag() : void - { - if (!User::$id || !$this->_post['id']) - { - trigger_error('AjaxComment::handleReplyFlag - commentId empty or user not logged in', E_USER_ERROR); - return; - } - - Util::createReport(1, 19, $this->_post['id'][0], '[General Reply Report]'); - } - - protected function handleReplyUpvote() : void - { - if (!$this->_post['id'] || !User::canUpvote()) - { - trigger_error('AjaxComment::handleReplyUpvote - commentId empty or user not allowed to vote', E_USER_ERROR); - return; - } - - $owner = DB::Aowow()->selectCell('SELECT userId FROM ?_comments WHERE id = ?d', $this->_post['id'][0]); - if (!$owner) - { - trigger_error('AjaxComment::handleReplyUpvote - comment #'.$this->_post['id'][0].' not found in db', E_USER_ERROR); - return; - } - - $ok = DB::Aowow()->query( - 'INSERT INTO ?_user_ratings (`type`, `entry`, `userId`, `value`) VALUES (?d, ?d, ?d, ?d)', - RATING_COMMENT, - $this->_post['id'][0], - User::$id, - User::canSupervote() ? 2 : 1 - ); - - if ($ok) - { - Util::gainSiteReputation($owner, SITEREP_ACTION_UPVOTED, ['id' => $this->_post['id'][0], 'voterId' => User::$id]); - User::decrementDailyVotes(); - } - else - trigger_error('AjaxComment::handleReplyUpvote - write to db failed', E_USER_ERROR); - } - - protected function handleReplyDownvote() : void - { - if (!$this->_post['id'] || !User::canDownvote()) - { - trigger_error('AjaxComment::handleReplyDownvote - commentId empty or user not allowed to vote', E_USER_ERROR); - return; - } - - $owner = DB::Aowow()->selectCell('SELECT userId FROM ?_comments WHERE id = ?d', $this->_post['id'][0]); - if (!$owner) - { - trigger_error('AjaxComment::handleReplyDownvote - comment #'.$this->_post['id'][0].' not found in db', E_USER_ERROR); - return; - } - - $ok = DB::Aowow()->query( - 'INSERT INTO ?_user_ratings (`type`, `entry`, `userId`, `value`) VALUES (?d, ?d, ?d, ?d)', - RATING_COMMENT, - $this->_post['id'][0], - User::$id, - User::canSupervote() ? -2 : -1 - ); - - if ($ok) - { - Util::gainSiteReputation($owner, SITEREP_ACTION_DOWNVOTED, ['id' => $this->_post['id'][0], 'voterId' => User::$id]); - User::decrementDailyVotes(); - } - else - trigger_error('AjaxComment::handleReplyDownvote - write to db failed', E_USER_ERROR); - } -} - -?> diff --git a/includes/ajaxHandler/contactus.class.php b/includes/ajaxHandler/contactus.class.php deleted file mode 100644 index d2a2517e..00000000 --- a/includes/ajaxHandler/contactus.class.php +++ /dev/null @@ -1,93 +0,0 @@ - ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'reason' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'ua' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'appname' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'page' => ['filter' => FILTER_SANITIZE_URL], - 'desc' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'id' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'relatedurl' => ['filter' => FILTER_SANITIZE_URL], - 'email' => ['filter' => FILTER_SANITIZE_EMAIL] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - // always this one - $this->handler = 'handleContactUs'; - } - - /* responses - 0: success - 1: captcha invalid - 2: description too long - 3: reason missing - 7: already reported - $: prints response - */ - protected function handleContactUs() : string - { - $mode = $this->_post['mode']; - $rsn = $this->_post['reason']; - $ua = $this->_post['ua']; - $app = $this->_post['appname']; - $url = $this->_post['page']; - $desc = $this->_post['desc']; - $subj = $this->_post['id']; - - $contexts = array( - [1, 2, 3, 4, 5, 6, 7, 8], - [15, 16, 17, 18, 19, 20], - [30, 31, 32, 33, 34, 35, 36, 37], - [45, 46, 47, 48], - [60, 61], - [45, 46, 47, 48], - [45, 46, 48] - ); - - if ($mode === null || $rsn === null || $ua === null || $app === null || $url === null) - { - trigger_error('AjaxContactus::handleContactUs - malformed contact request received', E_USER_ERROR); - return Lang::main('intError'); - } - - if (!isset($contexts[$mode]) || !in_array($rsn, $contexts[$mode])) - { - trigger_error('AjaxContactus::handleContactUs - report has invalid context (mode:'.$mode.' / reason:'.$rsn.')', E_USER_ERROR); - return Lang::main('intError'); - } - - if (!$desc) - return 3; - - if (mb_strlen($desc) > 500) - return 2; - - if (!User::$id && !User::$ip) - { - trigger_error('AjaxContactus::handleContactUs - could not determine IP for anonymous user', E_USER_ERROR); - return Lang::main('intError'); - } - - // check already reported - $field = User::$id ? 'userId' : 'ip'; - if (DB::Aowow()->selectCell('SELECT 1 FROM ?_reports WHERE `mode` = ?d AND `reason`= ?d AND `subject` = ?d AND ?# = ?', $mode, $rsn, $subj, $field, User::$id ?: User::$ip)) - return 7; - - if (Util::createReport($mode, $rsn, $subj, $desc, $ua, $app, $url, $this->_post['relatedurl'], $this->_post['email'])) - return 0; - - trigger_error('AjaxContactus::handleContactUs - write to db failed', E_USER_ERROR); - return Lang::main('intError'); - } -} - -?> diff --git a/includes/ajaxHandler/cookie.class.php b/includes/ajaxHandler/cookie.class.php deleted file mode 100644 index ccc36166..00000000 --- a/includes/ajaxHandler/cookie.class.php +++ /dev/null @@ -1,45 +0,0 @@ -_get = array( - $params[0] => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - ); - - // NOW we know, what to expect and sanitize - parent::__construct($params); - - // always this one - $this->handler = 'handleCookie'; - } - - /* responses - 0: success - $: silent error - */ - protected function handleCookie() : string - { - if (User::$id && $this->params && $this->_get[$this->params[0]]) - { - if (DB::Aowow()->query('REPLACE INTO ?_account_cookies VALUES (?d, ?, ?)', User::$id, $this->params[0], $this->_get[$this->params[0]])) - return '0'; - else - trigger_error('AjaxCookie::handleCookie - write to db failed', E_USER_ERROR); - } - else - trigger_error('AjaxCookie::handleCookie - malformed request received', E_USER_ERROR); - - return ''; - } -} - -?> diff --git a/includes/ajaxHandler/data.class.php b/includes/ajaxHandler/data.class.php deleted file mode 100644 index b114baa9..00000000 --- a/includes/ajaxHandler/data.class.php +++ /dev/null @@ -1,142 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkLocale'], - 't' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW ], - 'catg' => ['filter' => FILTER_SANITIZE_NUMBER_INT ], - 'skill' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxData::checkSkill' ], - 'class' => ['filter' => FILTER_SANITIZE_NUMBER_INT ], - 'callback' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxData::checkCallback' ] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (is_numeric($this->_get['locale'])) - User::useLocale($this->_get['locale']); - - // always this one - $this->handler = 'handleData'; - } - - /* responses - - */ - protected function handleData() : string - { - $result = ''; - - // different data can be strung together - foreach ($this->params as $set) - { - // requires valid token to hinder automated access - if ($set != 'item-scaling' && (!$this->_get['t'] || empty($_SESSION['dataKey']) || $this->_get['t'] != $_SESSION['dataKey'])) - { - trigger_error('AjaxData::handleData - session data key empty or expired', E_USER_ERROR); - continue; - } - - switch ($set) - { - /* issue on no initial data: - when we loadOnDemand, the jScript tries to generate the catg-tree before it is initialized - it cant be initialized, without loading the data as empty catg are omitted - loading the data triggers the generation of the catg-tree - */ - case 'factions': - $result .= $this->loadProfilerData($set); - break; - case 'companions': - $result .= $this->loadProfilerData($set, '778'); - break; - case 'mounts': - $result .= $this->loadProfilerData($set, '777'); - break; - case 'quests': - // &partial: im not doing this right - // it expects a full quest dump on first lookup but will query subCats again if clicked..? - // for now omiting the detail clicks with empty results and just set catg update - $catg = isset($this->_get['catg']) ? $this->_get['catg'] : 'null'; - if ($catg == 'null') - $result .= $this->loadProfilerData($set); - else if ($this->_get['callback']) - $result .= "\n\$WowheadProfiler.loadOnDemand('quests', ".$catg.");\n"; - - break; - case 'recipes': - if (!$this->_get['callback'] || !$this->_get['skill']) - break; - - foreach ($this->_get['skill'] as $s) - Util::loadStaticFile('p-recipes-'.$s, $result, true); - - Util::loadStaticFile('p-recipes-sec', $result, true); - $result .= "\n\$WowheadProfiler.loadOnDemand('recipes', null);\n"; - - break; - // locale independant - case 'quick-excludes': - case 'zones': - case 'weight-presets': - case 'item-scaling': - case 'realms': - case 'statistics': - if (!Util::loadStaticFile($set, $result) && CFG_DEBUG) - $result .= "alert('could not fetch static data: ".$set."');"; - - $result .= "\n\n"; - break; - // localized - case 'talents': - if ($_ = $this->_get['class']) - $set .= "-".$_; - case 'achievements': - case 'pet-talents': - case 'glyphs': - case 'gems': - case 'enchants': - case 'itemsets': - case 'pets': - if (!Util::loadStaticFile($set, $result, true) && CFG_DEBUG) - $result .= "alert('could not fetch static data: ".$set." for locale: ".User::$localeString."');"; - - $result .= "\n\n"; - break; - default: - trigger_error('AjaxData::handleData - invalid file "'.$set.'" in request', E_USER_ERROR); - break; - } - } - - return $result; - } - - protected static function checkSkill(string $val) : array - { - return array_intersect([171, 164, 333, 202, 182, 773, 755, 165, 186, 393, 197, 185, 129, 356], explode(',', $val)); - } - - protected static function checkCallback(string $val) : bool - { - return substr($val, 0, 29) === '$WowheadProfiler.loadOnDemand'; - } - - private function loadProfilerData(string $file, string $catg = 'null') : string - { - $result = ''; - if ($this->_get['callback']) - if (Util::loadStaticFile('p-'.$file, $result, true)) - $result .= "\n\$WowheadProfiler.loadOnDemand('".$file."', ".$catg.");\n"; - - return $result; - } - -} - -?> diff --git a/includes/ajaxHandler/edit.class.php b/includes/ajaxHandler/edit.class.php deleted file mode 100644 index f1b81400..00000000 --- a/includes/ajaxHandler/edit.class.php +++ /dev/null @@ -1,80 +0,0 @@ - ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'guide' => ['filter' => FILTER_SANITIZE_NUMBER_INT] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$params) - return; - - if ($params[0] == 'image') - $this->handler = 'handleUpload'; - else if ($params[0] == 'article') // has it's own editor page - $this->handler = null; - } - - /* - success: bool - id: image enumerator - type: 3 ? png : jpg - name: old filename - error: errString - */ - protected function handleUpload() : string - { - if (!User::$id || $this->_get['guide'] != 1) - return Util::toJSON(['success' => false, 'error' => '']); - - require_once('includes/libs/qqFileUploader.class.php'); - - $targetPath = 'static/uploads/guide/images/'; - $tmpPath = 'static/uploads/temp/'; - $tmpFile = User::$displayName.'-'.Type::GUIDE.'-0-'.Util::createHash(16); - - $uploader = new qqFileUploader(['jpg', 'jpeg', 'png'], 10 * 1024 * 1024); - $result = $uploader->handleUpload($tmpPath, $tmpFile, true); - - if (isset($result['success'])) - { - $finfo = new finfo(FILEINFO_MIME); - $mime = $finfo->file($tmpPath.$result['newFilename']); - if (preg_match('/^image\/(png|jpe?g)/i', $mime, $m)) - { - $i = 1; // image index - if ($files = scandir($targetPath, SCANDIR_SORT_DESCENDING)) - if (rsort($files, SORT_NATURAL) && $files[0] != '.' && $files[0] != '..') - $i = explode('.', $files[0])[0] + 1; - - $targetFile = $i . ($m[1] == 'png' ? '.png' : '.jpg'); - - // move to final location - if (!rename($tmpPath.$result['newFilename'], $targetPath.$targetFile)) - return Util::toJSON(['error' => Lang::main('intError')]); - - // send success - return Util::toJSON(array( - 'success' => true, - 'id' => $i, - 'type' => $m[1] == 'png' ? 3 : 2, - 'name' => $this->_get['qqfile'] - )); - } - - return Util::toJSON(['error' => Lang::screenshot('error', 'unkFormat')]); - } - - return Util::toJSON($result); - } -} - -?> diff --git a/includes/ajaxHandler/filter.class.php b/includes/ajaxHandler/filter.class.php deleted file mode 100644 index c02e80eb..00000000 --- a/includes/ajaxHandler/filter.class.php +++ /dev/null @@ -1,111 +0,0 @@ -page = $p[0]; - - if (isset($p[1])) - $this->cat[] = $p[1]; - - if (count($params) > 1) - for ($i = 1; $i < count($params); $i++) - $this->cat[] = $params[$i]; - - $opts = ['parentCats' => $this->cat]; - - switch ($p[0]) - { - case 'achievements': - $this->filter = (new AchievementListFilter(true, $opts)); - break; - case 'areatriggers': - $this->filter = (new AreaTriggerListFilter(true, $opts)); - break; - case 'enchantments': - $this->filter = (new EnchantmentListFilter(true, $opts)); - break; - case 'icons': - $this->filter = (new IconListFilter(true, $opts)); - break; - case 'items': - $this->filter = (new ItemListFilter(true, $opts)); - break; - case 'itemsets': - $this->filter = (new ItemsetListFilter(true, $opts)); - break; - case 'npcs': - $this->filter = (new CreatureListFilter(true, $opts)); - break; - case 'objects': - $this->filter = (new GameObjectListFilter(true, $opts)); - break; - case 'quests': - $this->filter = (new QuestListFilter(true, $opts)); - break; - case 'sounds': - $this->filter = (new SoundListFilter(true, $opts)); - break; - case 'spells': - $this->filter = (new SpellListFilter(true, $opts)); - break; - case 'profiles': - $this->filter = (new ProfileListFilter(true, $opts)); - break; - case 'guilds': - $this->filter = (new GuildListFilter(true, $opts)); - break; - case 'arena-teams': - $this->filter = (new ArenaTeamListFilter(true, $opts)); - break; - default: - return; - } - - parent::__construct($params); - - // always this one - $this->handler = 'handleFilter'; - } - - protected function handleFilter() : string - { - $url = '?'.$this->page; - - $this->filter->mergeCat($this->cat); - - if ($this->cat) - $url .= '='.implode('.', $this->cat); - - $fi = []; - if ($x = $this->filter->getFilterString()) - $url .= '&filter='.$x; - - if ($this->filter->error) - $_SESSION['fiError'] = get_class($this->filter); - - if ($fi) - $url .= '&filter='.implode(';', $fi); - - // do get request - return $url; - } -} - -?> diff --git a/includes/ajaxHandler/getdescription.class.php b/includes/ajaxHandler/getdescription.class.php deleted file mode 100644 index 3a88282c..00000000 --- a/includes/ajaxHandler/getdescription.class.php +++ /dev/null @@ -1,35 +0,0 @@ - [FILTER_CALLBACK, ['options' => 'AjaxHandler::checkFulltext']] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$params || $params[0]) // should be empty - return; - - $this->handler = 'handleDescription'; - } - - protected function handleDescription() : string - { - $this->contentType = MIME_TYPE_TEXT; - - if (!User::$id) - return ''; - - $desc = (new Markup($this->_post['description']))->stripTags(); - - return Lang::trimTextClean($desc, 120); - } -} - -?> diff --git a/includes/ajaxHandler/gotocomment.class.php b/includes/ajaxHandler/gotocomment.class.php deleted file mode 100644 index 54637470..00000000 --- a/includes/ajaxHandler/gotocomment.class.php +++ /dev/null @@ -1,38 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkInt'] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - // always this one - $this->handler = 'handleGoToComment'; - $this->doRedirect = true; - } - - /* responses - header() - */ - protected function handleGoToComment() : string - { - if (!$this->_get['id']) - return '.'; // go home - - if ($_ = DB::Aowow()->selectRow('SELECT IFNULL(c2.id, c1.id) AS id, IFNULL(c2.type, c1.type) AS type, IFNULL(c2.typeId, c1.typeId) AS typeId FROM ?_comments c1 LEFT JOIN ?_comments c2 ON c1.replyTo = c2.id WHERE c1.id = ?d', $this->_get['id'])) - return '?'.Type::getFileString(intVal($_['type'])).'='.$_['typeId'].'#comments:id='.$_['id'].($_['id'] != $this->_get['id'] ? ':reply='.$this->_get['id'] : null); - else - trigger_error('AjaxGotocomment::handleGoToComment - could not find comment #'.$this->get['id'], E_USER_ERROR); - - return '.'; - } -} - -?> diff --git a/includes/ajaxHandler/guide.class.php b/includes/ajaxHandler/guide.class.php deleted file mode 100644 index 5bbbd04c..00000000 --- a/includes/ajaxHandler/guide.class.php +++ /dev/null @@ -1,61 +0,0 @@ - [FILTER_SANITIZE_NUMBER_INT, null], - 'rating' => [FILTER_SANITIZE_NUMBER_INT, null] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$this->params || count($this->params) != 1) - return; - - $this->contentType = MIME_TYPE_TEXT; - - // select handler - if ($this->params[0] == 'vote') - $this->handler = 'voteGuide'; - } - - protected function voteGuide() : string - { - if (!$this->_post['id'] || $this->_post['rating'] < 0 || $this->_post['rating'] > 5) - { - header('HTTP/1.0 404 Not Found', true, 404); - return ''; - } - else if (!User::canUpvote() || !User::canDownvote()) // same logic as comments? - { - header('HTTP/1.0 403 Forbidden', true, 403); - return ''; - } - // by id, not own, published - if ($g = DB::Aowow()->selectRow('SELECT `userId`, `cuFlags` FROM ?_guides WHERE `id` = ?d AND (`status` = ?d OR `rev` > 0)', $this->_post['id'], GUIDE_STATUS_APPROVED)) - { - if ($g['cuFlags'] & GUIDE_CU_NO_RATING || $g['userId'] == User::$id) - { - header('HTTP/1.0 403 Forbidden', true, 403); - return ''; - } - - if (!$this->_post['rating']) - DB::Aowow()->query('DELETE FROM ?_user_ratings WHERE `type` = ?d AND `entry` = ?d AND `userId` = ?d', RATING_GUIDE, $this->_post['id'], User::$id); - else - DB::Aowow()->query('REPLACE INTO ?_user_ratings VALUES (?d, ?d, ?d, ?d)', RATING_GUIDE, $this->_post['id'], User::$id, $this->_post['rating']); - - $res = DB::Aowow()->selectRow('SELECT IFNULL(SUM(`value`), 0) AS `t`, IFNULL(COUNT(*), 0) AS `n` FROM ?_user_ratings WHERE `type` = ?d AND `entry` = ?d', RATING_GUIDE, $this->_post['id']); - return Util::toJSON($res['n'] ? ['rating' => $res['t'] / $res['n'], 'nvotes' => $res['n']] : ['rating' => 0, 'nvotes' => 0]); - } - - return Util::toJSON(['rating' => 0, 'nvotes' => 0]); - } -} - -?> diff --git a/includes/ajaxHandler/guild.class.php b/includes/ajaxHandler/guild.class.php deleted file mode 100644 index 01c6780e..00000000 --- a/includes/ajaxHandler/guild.class.php +++ /dev/null @@ -1,82 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdList' ], - 'profile' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkEmptySet'], - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$this->params) - return; - - switch ($this->params[0]) - { - case 'resync': - $this->handler = 'handleResync'; - break; - case 'status': - $this->handler = 'handleStatus'; - break; - } - } - - /* params - id: - user: [optional, not used] - profile: [optional, also get related chars] - return: 1 - */ - protected function handleResync() : string - { - if ($guilds = DB::Aowow()->select('SELECT realm, realmGUID FROM ?_profiler_guild WHERE id IN (?a)', $this->_get['id'])) - foreach ($guilds as $g) - Profiler::scheduleResync(Type::GUILD, $g['realm'], $g['realmGUID']); - - if ($this->_get['profile']) - if ($chars = DB::Aowow()->select('SELECT realm, realmGUID FROM ?_profiler_profiles WHERE guild IN (?a)', $this->_get['id'])) - foreach ($chars as $c) - Profiler::scheduleResync(Type::PROFILE, $c['realm'], $c['realmGUID']); - - return '1'; - } - - /* params - id: - return - - [ - nQueueProcesses, - [statusCode, timeToRefresh, curQueuePos, errorCode, nResyncTries], - [] - ... - ] - - not all fields are required, if zero they are omitted - statusCode: - 0: end the request - 1: waiting - 2: working... - 3: ready; click to view - 4: error / retry - errorCode: - 0: unk error - 1: char does not exist - 2: armory gone - */ - protected function handleStatus() : string - { - $response = Profiler::resyncStatus(Type::GUILD, $this->_get['id']); - return Util::toJSON($response); - } -} - -?> diff --git a/includes/ajaxHandler/locale.class.php b/includes/ajaxHandler/locale.class.php deleted file mode 100644 index b6c64d22..00000000 --- a/includes/ajaxHandler/locale.class.php +++ /dev/null @@ -1,33 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkLocale'] - ); - - public function __construct(array $params) - { - parent::__construct($params); - - // always this one - $this->handler = 'handleLocale'; - $this->doRedirect = true; - } - - /* responses - header() - */ - protected function handleLocale() : string - { - User::setLocale($this->_get['locale']); - User::save(); - - return isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '.'; - } -} - -?> diff --git a/includes/ajaxHandler/profile.class.php b/includes/ajaxHandler/profile.class.php deleted file mode 100644 index 64e3654a..00000000 --- a/includes/ajaxHandler/profile.class.php +++ /dev/null @@ -1,767 +0,0 @@ - ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdList' ], - 'items' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxProfile::checkItemList'], - 'size' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW ], - 'guild' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkEmptySet'], - 'arena-team' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkEmptySet'], - 'user' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxProfile::checkUser' ] - ); - - protected $_post = array( - 'name' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkFulltext'], - 'level' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'class' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'race' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'gender' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'nomodel' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'talenttree1' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'talenttree2' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'talenttree3' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'activespec' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'talentbuild1' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'glyphs1' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'talentbuild2' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'glyphs2' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'icon' => ['filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FLAG_STRIP_AOWOW], - 'description' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkFulltext'], - 'source' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'copy' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'public' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'gearscore' => ['filter' => FILTER_SANITIZE_NUMBER_INT], - 'inv' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdListUnsigned', 'flags' => FILTER_REQUIRE_ARRAY], - ); - - public function __construct(array $params) - { - parent::__construct($params); - - if (!$this->params) - return; - - if (!CFG_PROFILER_ENABLE) - return; - - switch ($this->params[0]) - { - case 'unlink': - $this->undo = true; - case 'link': - $this->handler = 'handleLink'; // always returns null - break; - case 'unpin': - $this->undo = true; - case 'pin': - $this->handler = 'handlePin'; // always returns null - break; - case 'private': - $this->undo = true; - case 'public': - $this->handler = 'handlePrivacy'; // always returns null - break; - case 'avatar': - $this->handler = 'handleAvatar'; // sets an image header - break; // so it has to die here or another header will be set - case 'resync': - $this->handler = 'handleResync'; // always returns "1" - break; - case 'status': - $this->handler = 'handleStatus'; // returns status object - break; - case 'save': - $this->handler = 'handleSave'; - break; - case 'delete': - $this->handler = 'handleDelete'; - break; - case 'purge': - $this->handler = 'handlePurge'; - break; - case 'summary': // page is generated by jScript - die(); // just be empty - case 'load': - $this->handler = 'handleLoad'; - break; - } - } - - /* params - id: - user: [optional] - return: null - */ - protected function handleLink() : void // links char with account - { - if (!User::$id || empty($this->_get['id'])) - { - trigger_error('AjaxProfile::handleLink - profileId empty or user not logged in', E_USER_ERROR); - return; - } - - $uid = User::$id; - if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) - { - if (!($uid = DB::Aowow()->selectCell('SELECT id FROM ?_account WHERE user = ?', $this->_get['user']))) - { - trigger_error('AjaxProfile::handleLink - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); - return; - } - } - - if ($this->undo) - DB::Aowow()->query('DELETE FROM ?_account_profiles WHERE accountId = ?d AND profileId IN (?a)', $uid, $this->_get['id']); - else - { - foreach ($this->_get['id'] as $prId) // only link characters, not custom profiles - { - if ($prId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_profiles WHERE id = ?d AND realm IS NOT NULL', $prId)) - DB::Aowow()->query('INSERT IGNORE INTO ?_account_profiles VALUES (?d, ?d, 0)', $uid, $prId); - else - { - trigger_error('AjaxProfile::handleLink - profile #'.$prId.' is custom or does not exist', E_USER_ERROR); - return; - } - } - } - } - - /* params - id: - user: [optional] - return: null - */ - protected function handlePin() : void // (un)favorite - { - if (!User::$id || empty($this->_get['id'][0])) - { - trigger_error('AjaxProfile::handlePin - profileId empty or user not logged in', E_USER_ERROR); - return; - } - - $uid = User::$id; - if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) - { - if (!($uid = DB::Aowow()->selectCell('SELECT id FROM ?_account WHERE user = ?', $this->_get['user']))) - { - trigger_error('AjaxProfile::handlePin - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); - return; - } - } - - // since only one character can be pinned at a time we can reset everything - DB::Aowow()->query('UPDATE ?_account_profiles SET extraFlags = extraFlags & ?d WHERE accountId = ?d', ~PROFILER_CU_PINNED, $uid); - // and set a single char if necessary - if (!$this->undo) - DB::Aowow()->query('UPDATE ?_account_profiles SET extraFlags = extraFlags | ?d WHERE profileId = ?d AND accountId = ?d', PROFILER_CU_PINNED, $this->_get['id'][0], $uid); - } - - /* params - id: - user: [optional] - return: null - */ - protected function handlePrivacy() : void // public visibility - { - if (!User::$id || empty($this->_get['id'][0])) - { - trigger_error('AjaxProfile::handlePrivacy - profileId empty or user not logged in', E_USER_ERROR); - return; - } - - $uid = User::$id; - if ($this->_get['user'] && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) - { - if (!($uid = DB::Aowow()->selectCell('SELECT id FROM ?_account WHERE user = ?', $this->_get['user']))) - { - trigger_error('AjaxProfile::handlePrivacy - user "'.$this->_get['user'].'" does not exist', E_USER_ERROR); - return; - } - } - - if ($this->undo) - { - DB::Aowow()->query('UPDATE ?_account_profiles SET extraFlags = extraFlags & ?d WHERE profileId IN (?a) AND accountId = ?d', ~PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); - DB::Aowow()->query('UPDATE ?_profiler_profiles SET cuFlags = cuFlags & ?d WHERE id IN (?a) AND user = ?d', ~PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); - } - else - { - DB::Aowow()->query('UPDATE ?_account_profiles SET extraFlags = extraFlags | ?d WHERE profileId IN (?a) AND accountId = ?d', PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); - DB::Aowow()->query('UPDATE ?_profiler_profiles SET cuFlags = cuFlags | ?d WHERE id IN (?a) AND user = ?d', PROFILER_CU_PUBLISHED, $this->_get['id'], $uid); - } - } - - /* params - id: - size: [optional] - return: image-header - */ - protected function handleAvatar() : void // image - { - // something happened in the last years: those textures do not include tiny icons - $sizes = [/* 'tiny' => 15, */'small' => 18, 'medium' => 36, 'large' => 56]; - $aPath = 'uploads/avatars/%d.jpg'; - $s = $this->_get['size'] ?: 'medium'; - - if (!$this->_get['id'] || !preg_match('/^([0-9]+)\.(jpg|gif)$/', $this->_get['id'][0], $matches) || !in_array($s, array_keys($sizes))) - { - trigger_error('AjaxProfile::handleAvatar - malformed request received', E_USER_ERROR); - return; - } - - $this->contentType = $matches[2] == 'png' ? MIME_TYPE_PNG : MIME_TYPE_JPEG; - - $id = $matches[1]; - $dest = imageCreateTruecolor($sizes[$s], $sizes[$s]); - - if (file_exists(sprintf($aPath, $id))) - { - $offsetX = $offsetY = 0; - - switch ($s) - { - case 'tiny': - $offsetX += $sizes['small']; - case 'small': - $offsetY += $sizes['medium']; - case 'medium': - $offsetX += $sizes['large']; - } - - $src = imageCreateFromJpeg(printf($aPath, $id)); - imagecopymerge($dest, $src, 0, 0, $offsetX, $offsetY, $sizes[$s], $sizes[$s], 100); - } - else - trigger_error('AjaxProfile::handleAvatar - avatar file #'.$id.' not found', E_USER_ERROR); - - if ($matches[2] == 'gif') - imageGif($dest); - else - imageJpeg($dest); - } - - /* params - id: - user: [optional, not used] - return: 1 - */ - protected function handleResync() : string - { - if ($chars = DB::Aowow()->select('SELECT realm, realmGUID FROM ?_profiler_profiles WHERE id IN (?a)', $this->_get['id'])) - { - foreach ($chars as $c) - Profiler::scheduleResync(Type::PROFILE, $c['realm'], $c['realmGUID']); - } - else - trigger_error('AjaxProfile::handleResync - profiles '.implode(', ', $this->_get['id']).' not found in db', E_USER_ERROR); - - return '1'; - } - - /* params - id: - return - - [ - nQueueProcesses, - [statusCode, timeToRefresh, curQueuePos, errorCode, nResyncTries], - [] - ... - ] - - not all fields are required, if zero they are omitted - statusCode: - 0: end the request - 1: waiting - 2: working... - 3: ready; click to view - 4: error / retry - errorCode: - 0: unk error - 1: char does not exist - 2: armory gone - */ - protected function handleStatus() : string - { - // roster resync for this guild was requested -> get char list - if ($this->_get['guild']) - $ids = DB::Aowow()->selectCol('SELECT id FROM ?_profiler_profiles WHERE guild IN (?a)', $this->_get['id']); - else if ($this->_get['arena-team']) - $ids = DB::Aowow()->selectCol('SELECT profileId FROM ?_profiler_arena_team_member WHERE arenaTeamId IN (?a)', $this->_get['id']); - else - $ids = $this->_get['id']; - - if (!$ids) - { - trigger_error('AjaxProfile::handleStatus - no profileIds to resync'.($this->_get['guild'] ? ' for guild #'.$this->_get['guild'] : ($this->_get['arena-team'] ? ' for areana team #'.$this->_get['arena-team'] : '')), E_USER_ERROR); - return Util::toJSON([1, [PR_QUEUE_STATUS_ERROR, 0, 0, PR_QUEUE_ERROR_CHAR]]); - } - - $response = Profiler::resyncStatus(Type::PROFILE, $ids); - return Util::toJSON($response); - } - - /* params (get)) - id: [0: new profile] - params (post) - [see below] - return: - proileId [onSuccess] - -1 [onError] - */ - protected function handleSave() : string // unKill a profile - { - // todo (med): detail check this post-data - $cuProfile = array( - 'user' => User::$id, - // 'userName' => User::$displayName, - 'name' => $this->_post['name'], - 'level' => $this->_post['level'], - 'class' => $this->_post['class'], - 'race' => $this->_post['race'], - 'gender' => $this->_post['gender'], - 'nomodelMask' => $this->_post['nomodel'], - 'talenttree1' => $this->_post['talenttree1'], - 'talenttree2' => $this->_post['talenttree2'], - 'talenttree3' => $this->_post['talenttree3'], - 'talentbuild1' => $this->_post['talentbuild1'], - 'talentbuild2' => $this->_post['talentbuild2'], - 'activespec' => $this->_post['activespec'], - 'glyphs1' => $this->_post['glyphs1'], - 'glyphs2' => $this->_post['glyphs2'], - 'gearscore' => $this->_post['gearscore'], - 'icon' => $this->_post['icon'], - 'cuFlags' => PROFILER_CU_PROFILE | ($this->_post['public'] ? PROFILER_CU_PUBLISHED : 0) - ); - - if (strstr($cuProfile['icon'], 'profile=avatar')) // how the profiler is supposed to handle icons is beyond me - $cuProfile['icon'] = ''; - - if ($_ = $this->_post['description']) - $cuProfile['description'] = $_; - - if ($_ = $this->_post['source']) // should i also set sourcename? - $cuProfile['sourceId'] = $_; - - if ($_ = $this->_post['copy']) // gets set to source profileId when "save as" is clicked. Whats the difference to 'source' though? - { - // get character origin info if possible - if ($r = DB::Aowow()->selectCell('SELECT realm FROM ?_profiler_profiles WHERE id = ?d AND realm IS NOT NULL', $_)) - $cuProfile['realm'] = $r; - - $cuProfile['sourceId'] = $_; - } - - if ($cuProfile['sourceId']) - $cuProfile['sourceName'] = DB::Aowow()->selectCell('SELECT name FROM ?_profiler_profiles WHERE id = ?d', $cuProfile['sourceId']); - - $charId = -1; - if ($id = $this->_get['id'][0]) // update - { - if ($charId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_profiles WHERE id = ?d', $id)) - DB::Aowow()->query('UPDATE ?_profiler_profiles SET ?a WHERE id = ?d', $cuProfile, $id); - } - else // new - { - $nProfiles = DB::Aowow()->selectCell('SELECT COUNT(*) FROM ?_profiler_profiles WHERE user = ?d AND (cuFlags & ?d) = 0 AND realmGUID IS NULL', User::$id, PROFILER_CU_DELETED); - if ($nProfiles < 10 || User::isPremium()) - if ($newId = DB::Aowow()->query('INSERT INTO ?_profiler_profiles (?#) VALUES (?a)', array_keys($cuProfile), array_values($cuProfile))) - $charId = $newId; - } - - // update items - if ($charId != -1) - { - // ok, 'funny' thing: wether an item has en extra prismatic sockel is determined contextual - // either the socket is -1 or it has an itemId in a socket where there shouldn't be one - $keys = ['id', 'slot', 'item', 'subitem', 'permEnchant', 'tempEnchant', 'gem1', 'gem2', 'gem3', 'gem4']; - - // validate Enchantments - $enchIds = array_merge( - array_column($this->_post['inv'], 3), // perm enchantments - array_column($this->_post['inv'], 4) // temp enchantments (not used..?) - ); - $enchs = new EnchantmentList(array(['id', $enchIds])); - - // validate items - $itemIds = array_merge( - array_column($this->_post['inv'], 1), // base item - array_column($this->_post['inv'], 5), // gem slot 1 - array_column($this->_post['inv'], 6), // gem slot 2 - array_column($this->_post['inv'], 7), // gem slot 3 - array_column($this->_post['inv'], 8) // gem slot 4 - ); - - $items = new ItemList(array(['id', $itemIds])); - if (!$items->error) - { - foreach ($this->_post['inv'] as $slot => $itemData) - { - if ($slot + 1 == array_sum($itemData)) // only slot definition set => empty slot - { - DB::Aowow()->query('DELETE FROM ?_profiler_items WHERE id = ?d AND slot = ?d', $charId, $itemData[0]); - continue; - } - - // item does not exist - if (!$items->getEntry($itemData[1])) - continue; - - // sub-item check - if (!$items->getRandEnchantForItem($itemData[1])) - $itemData[2] = 0; - - // item sockets are fubar - $nSockets = $items->json[$itemData[1]]['nsockets']; - $nSockets += in_array($slot, [SLOT_WAIST, SLOT_WRISTS, SLOT_HANDS]) ? 1 : 0; - for ($i = 5; $i < 9; $i++) - if ($itemData[$i] > 0 && (!$items->getEntry($itemData[$i]) || $i >= (5 + $nSockets))) - $itemData[$i] = 0; - - // item enchantments are borked - if ($itemData[3] && !$enchs->getEntry($itemData[3])) - $itemData[3] = 0; - - if ($itemData[4] && !$enchs->getEntry($itemData[4])) - $itemData[4] = 0; - - // looks good - array_unshift($itemData, $charId); - DB::Aowow()->query('REPLACE INTO ?_profiler_items (?#) VALUES (?a)', $keys, $itemData); - } - } - } - - return (string)$charId; - } - - /* params - id: - return - null - */ - protected function handleDelete() : void // kill a profile - { - if (!User::$id || !$this->_get['id']) - { - trigger_error('AjaxProfile::handleDelete - profileId empty or user not logged in', E_USER_ERROR); - return; - } - - // only flag as deleted; only custom profiles - DB::Aowow()->query( - 'UPDATE ?_profiler_profiles SET cuFlags = cuFlags | ?d WHERE id IN (?a) AND cuFlags & ?d {AND user = ?d}', - PROFILER_CU_DELETED, - $this->_get['id'], - PROFILER_CU_PROFILE, - User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU) ? DBSIMPLE_SKIP : User::$id - ); - } - - /* params - id: profileId - items: string [itemIds.join(':')] - unnamed: unixtime [only to force the browser to reload instead of cache] - return - lots... - */ - protected function handleLoad() : string - { - // titles, achievements, characterData, talents, pets - // and some onLoad-hook to .. load it registerProfile($data) - // everything else goes through data.php .. strangely enough - - if (!$this->_get['id']) - { - trigger_error('AjaxProfile::handleLoad - profileId empty', E_USER_ERROR); - return ''; - } - - $pBase = DB::Aowow()->selectRow('SELECT pg.name AS guildname, p.* FROM ?_profiler_profiles p LEFT JOIN ?_profiler_guild pg ON pg.id = p.guild WHERE p.id = ?d', $this->_get['id'][0]); - if (!$pBase) - { - trigger_error('Profiler::handleLoad - called with invalid profileId #'.$this->_get['id'][0], E_USER_WARNING); - return ''; - } - - if (($pBase['cuFlags'] & PROFILER_CU_DELETED) && !User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) - return ''; - - - $rData = []; - foreach (Profiler::getRealms() as $rId => $rData) - if ($rId == $pBase['realm']) - break; - - $profile = array( - 'id' => $pBase['id'], - 'source' => $pBase['id'], - 'level' => $pBase['level'], - 'classs' => $pBase['class'], - 'race' => $pBase['race'], - 'faction' => Game::sideByRaceMask(1 << ($pBase['race'] - 1)) - 1, - 'gender' => $pBase['gender'], - 'skincolor' => $pBase['skincolor'], - 'hairstyle' => $pBase['hairstyle'], - 'haircolor' => $pBase['haircolor'], - 'facetype' => $pBase['facetype'], - 'features' => $pBase['features'], - 'title' => $pBase['title'], - 'name' => $pBase['name'], - 'guild' => "$'".$pBase['guildname']."'", - 'published' => !!($pBase['cuFlags'] & PROFILER_CU_PUBLISHED), - 'pinned' => !!($pBase['cuFlags'] & PROFILER_CU_PINNED), - 'nomodel' => $pBase['nomodelMask'], - 'playedtime' => $pBase['playedtime'], - 'lastupdated' => $pBase['lastupdated'] * 1000, - 'talents' => array( - 'builds' => array( // notice the bullshit to prevent the talent-string from becoming a float! NOTICE IT!! - ['talents' => '$"'.$pBase['talentbuild1'].'"', 'glyphs' => $pBase['glyphs1']], - ['talents' => '$"'.$pBase['talentbuild2'].'"', 'glyphs' => $pBase['glyphs2']] - ), - 'active' => $pBase['activespec'] - ), - // set later - 'inventory' => [], - 'bookmarks' => [], // list of userIds who claimed this profile (claiming and owning are two different things) - - // completion lists: [subjectId => amount/timestamp/1] - 'skills' => [], // skillId => [curVal, maxVal] - 'reputation' => [], // factionId => curVal - 'titles' => [], // titleId => 1 - 'spells' => [], // spellId => 1; recipes, vanity pets, mounts - 'achievements' => [], // achievementId => timestamp - 'quests' => [], // questId => 1 - 'achievementpoints' => 0, // max you have - 'statistics' => [], // all raid activity [achievementId => killCount] - 'activity' => [], // recent raid activity [achievementId => 1] (is a subset of statistics) - ); - - if ($pBase['cuFlags'] & PROFILER_CU_PROFILE) - { - // this parameter is _really_ strange .. probably still not doing this right - $profile['source'] = $pBase['realm'] ? $pBase['sourceId'] : 0; - - $profile['sourcename'] = $pBase['sourceName']; - $profile['description'] = $pBase['description']; - $profile['user'] = $pBase['user']; - $profile['username'] = DB::Aowow()->selectCell('SELECT displayName FROM ?_account WHERE id = ?d', $pBase['user']); - } - - // custom profiles inherit this when copied from real char :( - if ($pBase['realm']) - { - $profile['region'] = [$rData['region'], Lang::profiler('regions', $rData['region'])]; - $profile['battlegroup'] = [Profiler::urlize(CFG_BATTLEGROUP), CFG_BATTLEGROUP]; - $profile['realm'] = [Profiler::urlize($rData['name'], true), $rData['name']]; - } - - // bookmarks - if ($_ = DB::Aowow()->selectCol('SELECT accountId FROM ?_account_profiles WHERE profileId = ?d', $pBase['id'])) - $profile['bookmarks'] = $_; - - // arena teams - [size(2|3|5) => DisplayName]; DisplayName gets urlized to use as link - if ($at = DB::Aowow()->selectCol('SELECT type AS ARRAY_KEY, name FROM ?_profiler_arena_team at JOIN ?_profiler_arena_team_member atm ON atm.arenaTeamId = at.id WHERE atm.profileId = ?d', $pBase['id'])) - $profile['arenateams'] = $at; - - // pets if hunter fields: [name:name, family:petFamily, npc:npcId, displayId:modelId, talents:talentString] - if ($pets = DB::Aowow()->select('SELECT name, family, npc, displayId, talents FROM ?_profiler_pets WHERE owner = ?d', $pBase['id'])) - $profile['pets'] = $pets; - - // source for custom profiles; profileId => [name, ownerId, iconString(optional)] - if ($customs = DB::Aowow()->select('SELECT id AS ARRAY_KEY, name, user, icon FROM ?_profiler_profiles WHERE sourceId = ?d AND sourceId <> id {AND (cuFlags & ?d) = 0}', $pBase['id'], User::isInGroup(U_GROUP_STAFF) ? DBSIMPLE_SKIP : PROFILER_CU_DELETED)) - { - foreach ($customs as $id => $cu) - { - if (!$cu['icon']) - unset($cu['icon']); - - $profile['customs'][$id] = array_values($cu); - } - } - - - /* $profile[] - // CUSTOM - 'auras' => [], // custom list of buffs, debuffs [spellId] - - // UNUSED - 'glyphs' => [], // provided list of already known glyphs (post cataclysm feature) - */ - - - $completion = DB::Aowow()->select('SELECT type AS ARRAY_KEY, typeId AS ARRAY_KEY2, cur, max FROM ?_profiler_completion WHERE id = ?d', $pBase['id']); - foreach ($completion as $type => $data) - { - switch ($type) - { - case Type::FACTION: // factionId => amount - $profile['reputation'] = array_combine(array_keys($data), array_column($data, 'cur')); - break; - case Type::TITLE: - foreach ($data as &$d) - $d = 1; - - $profile['titles'] = $data; - break; - case Type::QUEST: - foreach ($data as &$d) - $d = 1; - - $profile['quests'] = $data; - break; - case Type::SPELL: - foreach ($data as &$d) - $d = 1; - - $profile['spells'] = $data; - break; - case Type::ACHIEVEMENT: - $achievements = array_filter($data, function ($x) { return $x['max'] === null; }); - $statistics = array_filter($data, function ($x) { return $x['max'] !== null; }); - - // achievements - $profile['achievements'] = array_combine(array_keys($achievements), array_column($achievements, 'cur')); - $profile['achievementpoints'] = DB::Aowow()->selectCell('SELECT SUM(points) FROM ?_achievement WHERE id IN (?a)', array_keys($achievements)); - - // raid progression - $activity = array_filter($statistics, function ($x) { return $x['cur'] > (time() - MONTH); }); - foreach ($activity as &$r) - $r = 1; - - // ony .. subtract 10-man from 25-man - - $profile['statistics'] = array_combine(array_keys($statistics), array_column($statistics, 'max')); - $profile['activity'] = $activity; - break; - case Type::SKILL: - foreach ($data as &$d) - $d = [$d['cur'], $d['max']]; - - $profile['skills'] = $data; - break; - } - } - - $buff = ''; - - $usedSlots = []; - if ($this->_get['items']) - { - $phItems = new ItemList(array(['id', $this->_get['items']], ['slot', INVTYPE_NON_EQUIP, '!'])); - if (!$phItems->error) - { - $data = $phItems->getListviewData(ITEMINFO_JSON | ITEMINFO_SUBITEMS); - foreach ($phItems->iterate() as $iId => $__) - { - $sl = $phItems->getField('slot'); - foreach (Profiler::$slot2InvType as $slot => $invTypes) - { - if (in_array($sl, $invTypes) && !in_array($slot, $usedSlots)) - { - // get and apply inventory - $buff .= 'g_items.add('.$iId.', {name_'.User::$localeString.":'".Util::jsEscape($phItems->getField('name', true))."', quality:".$phItems->getField('quality').", icon:'".$phItems->getField('iconString')."', jsonequip:".Util::toJSON($data[$iId])."});\n"; - $profile['inventory'][$slot] = [$iId, 0, 0, 0, 0, 0, 0, 0]; - - $usedSlots[] = $slot; - break; - } - } - } - } - } - - if ($items = DB::Aowow()->select('SELECT * FROM ?_profiler_items WHERE id = ?d', $pBase['id'])) - { - $itemz = new ItemList(array(['id', array_column($items, 'item')], CFG_SQL_LIMIT_NONE)); - if (!$itemz->error) - { - $data = $itemz->getListviewData(ITEMINFO_JSON | ITEMINFO_SUBITEMS); - - foreach ($items as $i) - { - if ($itemz->getEntry($i['item']) && !in_array($i['slot'], $usedSlots)) - { - // get and apply inventory - $buff .= 'g_items.add('.$i['item'].', {name_'.User::$localeString.":'".Util::jsEscape($itemz->getField('name', true))."', quality:".$itemz->getField('quality').", icon:'".$itemz->getField('iconString')."', jsonequip:".Util::toJSON($data[$i['item']])."});\n"; - $profile['inventory'][$i['slot']] = [$i['item'], $i['subItem'], $i['permEnchant'], $i['tempEnchant'], $i['gem1'], $i['gem2'], $i['gem3'], $i['gem4']]; - } - } - } - } - - if ($buff) - $buff .= "\n"; - - - // if ($au = $char->getField('auras')) - // { - // $auraz = new SpellList(array(['id', $char->getField('auras')], CFG_SQL_LIMIT_NONE)); - // $dataz = $auraz->getListviewData(); - // $modz = $auraz->getProfilerMods(); - - // // get and apply aura-mods - // foreach ($dataz as $id => $data) - // { - // $mods = []; - // if (!empty($modz[$id])) - // { - // foreach ($modz[$id] as $k => $v) - // { - // if (is_array($v)) - // $mods[] = $v; - // else if ($str = @Game::$itemMods[$k]) - // $mods[$str] = $v; - // } - // } - - // $buff .= 'g_spells.add('.$id.", {id:".$id.", name:'".Util::jsEscape(mb_substr($data['name'], 1))."', icon:'".$data['icon']."', modifier:".Util::toJSON($mods)."});\n"; - // } - // $buff .= "\n"; - // } - - - // load available titles - Util::loadStaticFile('p-titles-'.$pBase['gender'], $buff, true); - - // add profile to buffer - $buff .= "\n\n\$WowheadProfiler.registerProfile(".Util::toJSON($profile).");"; - - return $buff."\n"; - } - - /* params - id: - data: [string, tabName] - return - null - */ - protected function handlePurge() : void { } // removes completion data (as uploaded by the wowhead client) Just fail silently if someone triggers this manually - - protected static function checkItemList($val) : array - { - // expecting item-list - if (preg_match('/\d+(:\d+)*/', $val)) - return array_map('intVal', explode(':', $val)); - - return []; - } - - protected static function checkUser(string $val) : string - { - if (User::isValidName($val)) - return $val; - - return ''; - } -} - -?> diff --git a/includes/basetype.class.php b/includes/basetype.class.php deleted file mode 100644 index 4bb50195..00000000 --- a/includes/basetype.class.php +++ /dev/null @@ -1,1459 +0,0 @@ - - * int - operator defaults to: = - * array - operator defaults to: IN () - * null - operator defaults to: IS [NULL] - * operator: modifies/overrides default - * ! - negated default value (NOT LIKE; <>; NOT IN) - * condition as str - * defines linking (AND || OR) - * condition as int - * defines LIMIT - * - * example: - * array( - * ['id', 45], - * ['name', 'test%', '!'], - * [ - * 'AND', - * ['flags', 0xFF, '&'], - * ['flags2', 0xF, '&'], - * ] - * [['mask', 0x3, '&'], 0], - * ['joinedTbl.field', NULL] // NULL must be explicitly specified "['joinedTbl.field']" would be skipped as erronous definition (only really usefull when left-joining) - * 'OR', - * 5 - * ) - * results in - * WHERE ((`id` = 45) OR (`name` NOT LIKE "test%") OR ((`flags` & 255) AND (`flags2` & 15)) OR ((`mask` & 3) = 0)) OR (`joinedTbl`.`field` IS NULL) LIMIT 5 - */ - public function __construct($conditions = [], $miscData = null) - { - $where = []; - $linking = ' AND '; - $limit = CFG_SQL_LIMIT_DEFAULT; - - if (!$this->queryBase || $conditions === null) - return; - - $prefixes = []; - if (preg_match('/FROM \??[\w\_]+( AS)?\s?`?(\w+)`?$/i', $this->queryBase, $match)) - $prefixes['base'] = $match[2]; - else - $prefixes['base'] = ''; - - if ($miscData && !empty($miscData['extraOpts'])) - $this->extendQueryOpts($miscData['extraOpts']); - - $resolveCondition = function ($c, $supLink) use (&$resolveCondition, &$prefixes, $miscData) - { - $subLink = ''; - - if (!$c) - return null; - - foreach ($c as $foo) - { - if ($foo === 'AND') - $subLink = ' AND '; - else if ($foo === 'OR') // nessi-bug: if (0 == 'OR') was true once... w/e - $subLink = ' OR '; - } - - // need to manually set link for subgroups to be recognized as condition set - if ($subLink) - { - $sql = []; - - foreach ($c as $foo) - if (is_array($foo)) - if ($x = $resolveCondition($foo, $supLink)) - $sql[] = $x; - - return $sql ? '('.implode($subLink, $sql).')' : null; - } - else - { - if ($c[0] == '1') - return '1'; - else if ($c[0] == '0') - return '(0)'; // trick if ($x = 0) into true... - else if (is_array($c[0]) && isset($c[1])) - $field = $resolveCondition($c[0], $supLink); - else if ($c[0]) - { - $setPrefix = function($f) use(&$prefixes) - { - if (is_array($f)) - $f = $f[0]; - - // numeric allows for formulas e.g. (1 < 3) - if (Util::checkNumeric($f)) - return $f; - - // skip condition if fieldName contains illegal chars - if (preg_match('/[^\d\w\.\_]/i', $f)) - return null; - - $f = explode('.', $f); - - switch (count($f)) - { - case 2: - if (!in_array($f[0], $prefixes)) - { - // choose table to join or return null if prefix does not exist - if (!in_array($f[0], array_keys($this->queryOpts))) - return null; - - $prefixes[] = $f[0]; - } - - return '`'.$f[0].'`.`'.$f[1].'`'; - case 1: - return '`'.$prefixes['base'].'`.`'.$f[0].'`'; - default: - return null; - } - }; - - // basic formulas - if (preg_match('/^\([\s\+\-\*\/\w\(\)\.]+\)$/i', strtr($c[0], ['`' => '', '´' => '', '--' => '']))) - $field = preg_replace_callback('/[\w\]*\.?[\w]+/i', $setPrefix, $c[0]); - else - $field = $setPrefix($c[0]); - - if (!$field) - return null; - } - else - return null; - - if (is_array($c[1]) && !empty($c[1])) - { - array_walk($c[1], function(&$item, $key) { - $item = Util::checkNumeric($item) ? $item : DB::Aowow()->escape($item); - }); - - $op = (isset($c[2]) && $c[2] == '!') ? 'NOT IN' : 'IN'; - $val = '('.implode(', ', $c[1]).')'; - } - else if (Util::checkNumeric($c[1])) - { - $op = (isset($c[2]) && $c[2] == '!') ? '<>' : '='; - $val = $c[1]; - } - else if (is_string($c[1])) - { - $op = (isset($c[2]) && $c[2] == '!') ? 'NOT LIKE' : 'LIKE'; - $val = DB::Aowow()->escape($c[1]); - } - else if (count($c) > 1 && $c[1] === null) // specifficly check for NULL - { - $op = (isset($c[2]) && $c[2] == '!') ? 'IS NOT' : 'IS'; - $val = 'NULL'; - } - else // null for example - return null; - - if (isset($c[2]) && $c[2] != '!') - $op = $c[2]; - - return '('.$field.' '.$op.' '.$val.')'; - } - }; - - foreach ($conditions as $i => $c) - { - switch (getType($c)) - { - case 'array': - break; - case 'string': - case 'integer': - if (is_string($c)) - $linking = $c == 'AND' ? ' AND ' : ' OR '; - else - $limit = $c > 0 ? $c : 0; - default: - unset($conditions[$i]); - } - } - - foreach ($conditions as $c) - if ($x = $resolveCondition($c, $linking)) - $where[] = $x; - - // optional query parts may require other optional parts to work - foreach ($prefixes as $pre) - if (isset($this->queryOpts[$pre][0])) - foreach ($this->queryOpts[$pre][0] as $req) - if (!in_array($req, $prefixes)) - $prefixes[] = $req; - - // remove optional query parts, that are not required - foreach ($this->queryOpts as $k => $arr) - if (!in_array($k, $prefixes)) - unset($this->queryOpts[$k]); - - // prepare usage of guids if using multiple realms (which have non-zoro indizes) - if (key($this->dbNames) != 0) - $this->queryBase = preg_replace('/\s([^\s]+)\sAS\sARRAY_KEY/i', ' CONCAT("DB_IDX", ":", \1) AS ARRAY_KEY', $this->queryBase); - - // insert additional selected fields - if ($s = array_column($this->queryOpts, 's')) - $this->queryBase = str_replace('ARRAY_KEY', 'ARRAY_KEY '.implode('', $s), $this->queryBase); - - // append joins - if ($j = array_column($this->queryOpts, 'j')) - foreach ($j as $_) - $this->queryBase .= is_array($_) ? (empty($_[1]) ? ' JOIN ' : ' LEFT JOIN ').$_[0] : ' JOIN '.$_; - - // append conditions - if ($where) - $this->queryBase .= ' WHERE ('.implode($linking, $where).')'; - - // append grouping - if ($g = array_filter(array_column($this->queryOpts, 'g'))) - $this->queryBase .= ' GROUP BY '.implode(', ', $g); - - // append post filtering - if ($h = array_filter(array_column($this->queryOpts, 'h'))) - $this->queryBase .= ' HAVING '.implode(' AND ', $h); - - // append ordering - if ($o = array_filter(array_column($this->queryOpts, 'o'))) - $this->queryBase .= ' ORDER BY '.implode(', ', $o); - - // apply limit - if ($limit) - $this->queryBase .= ' LIMIT '.$limit; - - // execute query (finally) - $mtch = 0; - $rows = []; - // this is purely because of multiple realms per server - foreach ($this->dbNames as $dbIdx => $n) - { - $query = str_replace('DB_IDX', $dbIdx, $this->queryBase); - if ($rows = DB::{$n}($dbIdx)->SelectPage($mtch, $query)) - { - $this->matches += $mtch; - foreach ($rows as $id => $row) - { - if (isset($this->templates[$id])) - trigger_error('GUID for List already in use #'.$id.'. Additional occurrence omitted!', E_USER_ERROR); - else - $this->templates[$id] = $row; - } - } - } - - if (!$this->templates) - return; - - // push first element for instant use - $this->reset(); - - // all clear - $this->error = false; - } - - public function &iterate() - { - $this->itrStack[] = $this->id; - - // reset on __construct - $this->reset(); - - foreach ($this->templates as $id => $__) - { - $this->id = $id; - $this->curTpl = &$this->templates[$id]; // do not use $tpl from each(), as we want to be referenceable - - yield $id => $this->curTpl; - - unset($this->curTpl); // kill reference or it will 'bleed' into the next iteration - } - - // fforward to old index - $this->reset(); - $oldIdx = array_pop($this->itrStack); - do - { - if (key($this->templates) != $oldIdx) - continue; - - $this->curTpl = current($this->templates); - $this->id = key($this->templates); - next($this->templates); - break; - } - while (next($this->templates)); - } - - protected function reset() - { - unset($this->curTpl); // kill reference or strange stuff will happen - $this->curTpl = reset($this->templates); - $this->id = key($this->templates); - } - - // read-access to templates - public function getEntry($id) - { - if (isset($this->templates[$id])) - { - unset($this->curTpl); // kill reference or strange stuff will happen - $this->curTpl = $this->templates[$id]; - $this->id = $id; - return $this->templates[$id]; - } - - return null; - } - - public function getField($field, $localized = false, $silent = false) - { - if (!$this->curTpl || (!$localized && !isset($this->curTpl[$field]))) - return ''; - - if ($localized) - return Util::localizedString($this->curTpl, $field, $silent); - - $value = $this->curTpl[$field]; - Util::checkNumeric($value); - - return $value; - } - - public function getAllFields($field, $localized = false, $silent = false) - { - $data = []; - - foreach ($this->iterate() as $__) - $data[$this->id] = $this->getField($field, $localized, $silent); - - return $data; - } - - public function getRandomId() - { - // ORDER BY RAND() is not optimal, so if anyone has an alternative idea.. - $where = User::isInGroup(U_GROUP_EMPLOYEE) ? 'WHERE (cuFlags & '.CUSTOM_EXCLUDE_FOR_LISTVIEW.') = 0' : null; - $pattern = '/SELECT .* (-?`?[\w_]*\`?.?`?(id|entry)`?) AS ARRAY_KEY,?.* FROM (\?[\w_-]+) (`?\w*`?)/i'; - $replace = 'SELECT $1 FROM $3 $4 '.$where.' ORDER BY RAND() ASC LIMIT 1'; - - $query = preg_replace($pattern, $replace, $this->queryBase); - - return DB::Aowow()->selectCell($query); - } - - public function getFoundIDs() - { - return array_keys($this->templates); - } - - public function getMatches() - { - return $this->matches; - } - - protected function extendQueryOpts($extra) // needs to be called from __construct - { - foreach ($extra as $tbl => $sets) - { - foreach ($sets as $module => $value) - { - if (!$value || !is_array($value)) - continue; - - switch ($module) - { - // additional (str) - case 'g': // group by - case 's': // select - if (!empty($this->queryOpts[$tbl][$module])) - $this->queryOpts[$tbl][$module] .= implode(' ', $value); - else - $this->queryOpts[$tbl][$module] = implode(' ', $value); - - break; - case 'h': // having - if (!empty($this->queryOpts[$tbl][$module])) - $this->queryOpts[$tbl][$module] .= implode(' AND ', $value); - else - $this->queryOpts[$tbl][$module] = implode(' AND ', $value); - - break; - // additional (arr) - case 'j': // join - if (!empty($this->queryOpts[$tbl][$module]) && is_array($this->queryOpts[$tbl][$module])) - $this->queryOpts[$tbl][$module][0][] = $value; - else - $this->queryOpts[$tbl][$module] = $value; - - break; - // replacement (str) - case 'l': // limit - case 'o': // order by - $this->queryOpts[$tbl][$module] = $value[0]; - break; - } - } - } - } - - /* source More .. keys seen used - 'n': name [always set] - 't': type [always set] - 'ti': typeId [always set] - 'bd': BossDrop [0; 1] [Creature / GO] - 'dd': DungeonDifficulty [-2: DungeonHC; -1: DungeonNM; 1: Raid10NM; 2:Raid25NM; 3:Raid10HM; 4: Raid25HM] [Creature / GO] - 'q': cssQuality [Items] - 'z': zone [set when all happens in here] - 'p': PvP [pvpSourceId] - 's': Type::TITLE: side; Type::SPELL: skillId (yeah, double use. Ain't life just grand) - 'c': category [Spells / Quests] - 'c2': subCat [Quests] - 'icon': iconString - */ - public function getSourceData() {} - - // should return data required to display a listview of any kind - // this is a rudimentary example, that will not suffice for most Types - abstract public function getListviewData(); - - // should return data to extend global js variables for a certain type (e.g. g_items) - abstract public function getJSGlobals($addMask = GLOBALINFO_ANY); - - // NPC, GO, Item, Quest, Spell, Achievement, Profile would require this - abstract public function renderTooltip(); -} - -trait listviewHelper -{ - public function hasSetFields($fields) - { - if (!is_array($fields)) - return 0x0; - - $result = 0x0; - - foreach ($this->iterate() as $__) - { - foreach ($fields as $k => $str) - { - if ($this->getField($str)) - { - $result |= 1 << $k; - unset($fields[$k]); - } - } - - if (empty($fields)) // all set .. return early - { - $this->reset(); // Generators have no __destruct, reset manually, when not doing a full iteration - return $result; - } - } - - return $result; - } - - public function hasDiffFields($fields) - { - if (!is_array($fields)) - return 0x0; - - $base = []; - $result = 0x0; - - foreach ($fields as $k => $str) - $base[$str] = $this->getField($str); - - foreach ($this->iterate() as $__) - { - foreach ($fields as $k => $str) - { - if ($base[$str] != $this->getField($str)) - { - $result |= 1 << $k; - unset($fields[$k]); - } - } - - if (empty($fields)) // all fields diff .. return early - { - $this->reset(); // Generators have no __destruct, reset manually, when not doing a full iteration - return $result; - } - } - - return $result; - } - - public function hasAnySource() - { - if (!isset($this->sources)) - return false; - - foreach ($this->sources as $src) - { - if (!is_array($src)) - continue; - - if (!empty($src)) - return true; - } - - return false; - } -} - -/* - !IMPORTANT! - It is flat out impossible to distinguish between floors for multi-level areas, if the floors overlap each other! - The coordinates generated by the script WILL be on every level and will have to be removed MANUALLY! - - impossible := you are not keen on reading wmo-data; -*/ -trait spawnHelper -{ - private $spawnResult = array( - SPAWNINFO_FULL => null, - SPAWNINFO_SHORT => null, - SPAWNINFO_ZONES => null, - SPAWNINFO_QUEST => null - ); - - private function createShortSpawns() // [zoneId, floor, [[x1, y1], [x2, y2], ..]] as tooltip2 if enabled by or anchor #map (one area, one floor, one creature, no survivors) - { - $this->spawnResult[SPAWNINFO_SHORT] = new StdClass; - - // first get zone/floor with the most spawns - if ($res = DB::Aowow()->selectRow('SELECT areaId, floor FROM ?_spawns WHERE type = ?d AND typeId = ?d AND posX > 0 AND posY > 0 GROUP BY areaId, floor ORDER BY count(1) DESC LIMIT 1', self::$type, $this->id)) - { - // get relevant spawn points - $points = DB::Aowow()->select('SELECT posX, posY FROM ?_spawns WHERE type = ?d AND typeId = ?d AND areaId = ?d AND floor = ?d AND posX > 0 AND posY > 0', self::$type, $this->id, $res['areaId'], $res['floor']); - $spawns = []; - foreach ($points as $p) - $spawns[] = [$p['posX'], $p['posY']]; - - $this->spawnResult[SPAWNINFO_SHORT]->zone = $res['areaId']; - $this->spawnResult[SPAWNINFO_SHORT]->coords = [$res['floor'] => $spawns]; - } - } - - private function createFullSpawns() // for display on map (object/npc detail page) - { - $data = []; - $wpSum = []; - $wpIdx = 0; - $worldPos = []; - $spawns = DB::Aowow()->select("SELECT * FROM ?_spawns WHERE type = ?d AND typeId = ?d AND posX > 0 AND posY > 0", self::$type, $this->id); - - if (!$spawns) - return; - - if (User::isInGroup(U_GROUP_MODERATOR)) - $worldPos = Game::getWorldPosForGUID(self::$type, ...array_column($spawns, 'guid')); - - foreach ($spawns as $s) - { - // check, if we can attach waypoints to creature - // we will get a nice clusterfuck of dots if we do this for more GUIDs, than we have colors though - if (count($spawns) < 6 && self::$type == Type::NPC) - { - if ($wPoints = DB::Aowow()->select('SELECT * FROM ?_creature_waypoints WHERE creatureOrPath = ?d AND floor = ?d', $s['pathId'] ? -$s['pathId'] : $this->id, $s['floor'])) - { - foreach ($wPoints as $i => $p) - { - $label = [Lang::npc('waypoint').Lang::main('colon').$p['point']]; - - if ($p['wait']) - $label[] = Lang::npc('wait').Lang::main('colon').Util::formatTime($p['wait'], false); - - $opts = array( // \0 doesn't get printed and tricks Util::toJSON() into handling this as a string .. i feel slightly dirty now - 'label' => "\0$
".implode('
', $label).'
', - 'type' => $wpIdx - ); - - // connective line - if ($i > 0) - $opts['lines'] = [[$wPoints[$i - 1]['posX'], $wPoints[$i - 1]['posY']]]; - - $data[$s['areaId']][$s['floor']]['coords'][] = [$p['posX'], $p['posY'], $opts]; - if (empty($wpSum[$s['areaId']][$s['floor']])) - $wpSum[$s['areaId']][$s['floor']] = 1; - else - $wpSum[$s['areaId']][$s['floor']]++; - } - $wpIdx++; - } - } - - $opts = $menu = $tt = $info = []; - $footer = ''; - - if ($s['respawn']) - $info[1] = ''.Lang::npc('respawnIn').Lang::main('colon').Util::formatTime($s['respawn'] * 1000, false).''; - - if (User::isInGroup(U_GROUP_STAFF)) - { - $info[0] = $s['guid'] < 0 ? 'Vehicle Accessory' : 'GUID'.Lang::main('colon').$s['guid']; - - if ($s['phaseMask'] > 1 && ($s['phaseMask'] & 0xFFFF) != 0xFFFF) - $info[2] = Lang::game('phases').Lang::main('colon').Util::asHex($s['phaseMask']); - - if ($s['spawnMask'] == 15) - $info[3] = Lang::game('mode').Lang::main('colon').Lang::game('modes', -1); - else if ($s['spawnMask']) - { - $_ = []; - for ($i = 0; $i < 4; $i++) - if ($s['spawnMask'] & 1 << $i) - $_[] = Lang::game('modes', $i); - - $info[4] = Lang::game('mode').Lang::main('colon').implode(', ', $_); - } - - if (self::$type == Type::AREATRIGGER) - { - $o = Util::O2Deg($this->getField('orientation')); - $info[5] = 'Orientation'.Lang::main('colon').$o[0].'° ('.$o[1].')'; - } - - // guid < 0 are vehicle accessories. those are moved by moving the vehicle - if (User::isInGroup(U_GROUP_MODERATOR) && $worldPos && $s['guid'] > 0) - { - if ($points = Game::worldPosToZonePos($worldPos[$s['guid']]['mapId'], $worldPos[$s['guid']]['posX'], $worldPos[$s['guid']]['posY'])) - { - $floors = []; - foreach ($points as $p) - { - if (isset(Game::$areaFloors[$p['areaId']])) - $floors[$p['areaId']][] = $p['floor']; - - if (isset($menu[$p['areaId']])) - continue; - else if ($p['areaId'] == $s['areaId']) - $menu[$p['areaId']] = [$p['areaId'], '$g_zones['.$p['areaId'].']', '', null, ['class' => 'checked q0']]; - else - $menu[$p['areaId']] = [$p['areaId'], '$g_zones['.$p['areaId'].']', '$spawnposfix.bind(null, '.self::$type.', '.$s['guid'].', '.$p['areaId'].', -1)', null, null]; - } - - foreach ($floors as $area => $f) - { - $menu[$area][2] = ''; - $menu[$area][3] = []; - if ($menu[$area][4]) - $menu[$area][4]['class'] = 'checked'; - - foreach ($f as $n) - { - $jsRef = $n; - if ($area != 4273) // Ulduar is weird maaaan..... - $jsRef--; - - // todo: 3959 (BT) and 4075 (Sunwell) start at level 0 or something - - if ($n == $s['floor']) - $menu[$area][3][] = [$jsRef, '$g_zone_areas['.$area.']['.$jsRef.']', '', null, ['class' => 'checked q0']]; - else - $menu[$area][3][] = [$jsRef, '$g_zone_areas['.$area.']['.$jsRef.']', '$spawnposfix.bind(null, '.self::$type.', '.$s['guid'].', '.$area.', '.$n.')']; - } - } - - $menu = array_values($menu); - } - - if ($menu) - { - $footer = '
Click to move displayed spawn point'; - array_unshift($menu, [null, "Move to..."]); - } - } - } - - if ($info) - $tt['info'] = $info; - - if ($footer) - $tt['footer'] = $footer; - - if ($tt) - $opts['tooltip'] = [$this->getField('name', true) => $tt]; - - if ($menu) - $opts['menu'] = $menu; - - $data[$s['areaId']] [$s['floor']] ['coords'] [] = [$s['posX'], $s['posY'], $opts]; - } - foreach ($data as $a => &$areas) - foreach ($areas as $f => &$floor) - $floor['count'] = count($floor['coords']) - (!empty($wpSum[$a][$f]) ? $wpSum[$a][$f] : 0); - - uasort($data, array($this, 'sortBySpawnCount')); - $this->spawnResult[SPAWNINFO_FULL] = $data; - } - - private function sortBySpawnCount($a, $b) - { - $aCount = current($a)['count']; - $bCount = current($b)['count']; - - if ($aCount == $bCount) { - return 0; - } - - return ($aCount < $bCount) ? 1 : -1; - } - - private function createZoneSpawns() // [zoneId1, zoneId2, ..] for locations-column in listview - { - $res = DB::Aowow()->selectCol("SELECT typeId AS ARRAY_KEY, GROUP_CONCAT(DISTINCT areaId) FROM ?_spawns WHERE type = ?d AND typeId IN (?a) GROUP BY typeId", self::$type, $this->getfoundIDs()); - foreach ($res as &$r) - { - $r = explode(',', $r); - if (count($r) > 3) - array_splice($r, 3, count($r), -1); - } - - $this->spawnResult[SPAWNINFO_ZONES] = $res; - } - - private function createQuestSpawns() // [zoneId => [floor => [[x1, y1], [x2, y2], ..]]] mapper on quest detail page - { - if (self::$type == Type::SOUND) - return; - - $res = DB::Aowow()->select('SELECT areaId, floor, typeId, posX, posY FROM ?_spawns WHERE type = ?d AND typeId IN (?a) AND posX > 0 AND posY > 0', self::$type, $this->getFoundIDs()); - $spawns = []; - foreach ($res as $data) - { - // zone => floor => spawnData - // todo (low): why is there a single set of coordinates; which one should be picked, instead of the first? gets used in ShowOnMap.buildTooltip i think - if (!isset($spawns[$data['areaId']][$data['floor']][$data['typeId']])) - { - $spawns[$data['areaId']][$data['floor']][$data['typeId']] = array( - 'type' => self::$type, - 'id' => $data['typeId'], - 'point' => '', // tbd later (start, end, requirement, sourcestart, sourceend, sourcerequirement) - 'name' => Util::localizedString($this->templates[$data['typeId']], 'name'), - 'coord' => [$data['posX'], $data['posY']], - 'coords' => [[$data['posX'], $data['posY']]], - 'objective' => 0, // tbd later (1-4 set a color; id of creature this entry gives credit for) - 'reactalliance' => $this->templates[$data['typeId']]['A'] ?: 0, - 'reacthorde' => $this->templates[$data['typeId']]['H'] ?: 0 - ); - } - else - $spawns[$data['areaId']][$data['floor']][$data['typeId']]['coords'][] = [$data['posX'], $data['posY']]; - } - - $this->spawnResult[SPAWNINFO_QUEST] = $spawns; - } - - public function getSpawns($mode) - { - // only Creatures, GOs and SoundEmitters can be spawned - if (!self::$type || !$this->getfoundIDs() || (self::$type != Type::NPC && self::$type != Type::OBJECT && self::$type != Type::SOUND && self::$type != Type::AREATRIGGER)) - return []; - - switch ($mode) - { - case SPAWNINFO_SHORT: - if ($this->spawnResult[SPAWNINFO_SHORT] === null) - $this->createShortSpawns(); - - return $this->spawnResult[SPAWNINFO_SHORT]; - case SPAWNINFO_FULL: - if (empty($this->spawnResult[SPAWNINFO_FULL])) - $this->createFullSpawns(); - - return $this->spawnResult[SPAWNINFO_FULL]; - case SPAWNINFO_ZONES: - if (empty($this->spawnResult[SPAWNINFO_ZONES])) - $this->createZoneSpawns(); - - return !empty($this->spawnResult[SPAWNINFO_ZONES][$this->id]) ? $this->spawnResult[SPAWNINFO_ZONES][$this->id] : []; - case SPAWNINFO_QUEST: - if (empty($this->spawnResult[SPAWNINFO_QUEST])) - $this->createQuestSpawns(); - - return $this->spawnResult[SPAWNINFO_QUEST]; - } - - return []; - } -} - -trait profilerHelper -{ - public static $type = 0; // arena teams dont actually have one - public static $brickFile = 'profile'; // profile is multipurpose - - private static $subjectGUID = 0; - - public function selectRealms($fi) - { - $this->dbNames = []; - - foreach(Profiler::getRealms() as $idx => $r) - { - if (!empty($fi['sv']) && Profiler::urlize($r['name']) != Profiler::urlize($fi['sv']) && intVal($fi['sv']) != $idx) - continue; - - if (!empty($fi['rg']) && Profiler::urlize($r['region']) != Profiler::urlize($fi['rg'])) - continue; - - $this->dbNames[$idx] = 'Characters'; - } - - return !!$this->dbNames; - } -} - -abstract class Filter -{ - private static $wCards = ['*' => '%', '?' => '_']; - - public $error = false; // erronous search fields - - private $cndSet = []; - - protected $parentCats = []; // used to validate ty-filter - protected $inputFields = []; // list of input fields defined per page - protected $fiData = ['c' => [], 'v' =>[]]; - protected $formData = array( // data to fill form fields - 'form' => [], // base form - unsanitized - 'setCriteria' => [], // dynamic criteria list - index checked - 'setWeights' => [], // dynamic weights list - index checked - 'extraCols' => [], // extra columns for LV - added as required - 'reputationCols' => [] // simlar and exclusive to extraCols - added as required - ); - - // parse the provided request into a usable format - public function __construct($fromPOST = false, $opts = []) - { - if (!empty($opts['parentCats'])) - $this->parentCats = $opts['parentCats']; - - if ($fromPOST) - $this->evaluatePOST(); - else - { - // an error occured, while processing POST - if (isset($_SESSION['fiError'])) - { - $this->error = $_SESSION['fiError'] == get_class($this); - unset($_SESSION['fiError']); - } - - $this->evaluateGET(); - } - } - - // use to generate cacheKey for filterable pages - public function __sleep() - { - return ['formData']; - } - - public function mergeCat(&$cats) - { - foreach ($this->parentCats as $idx => $cat) - $cats[$idx] = $cat; - } - - private function &criteriaIterator() - { - if (!$this->fiData['c']) - return; - - for ($i = 0; $i < count($this->fiData['c']['cr']); $i++) - { - // throws a notice if yielded directly "Only variable references should be yielded by reference" - $v = [&$this->fiData['c']['cr'][$i], &$this->fiData['c']['crs'][$i], &$this->fiData['c']['crv'][$i]]; - yield $i => $v; - } - } - - - /***********************/ - /* get prepared values */ - /***********************/ - - public function getFilterString(array $override = [], array $addCr = []) - { - $_ = []; - foreach (array_merge($this->fiData['c'], $this->fiData['v'], $override) as $k => $v) - { - if (isset($addCr[$k])) - { - $v = $v ? array_merge((array)$v, (array)$addCr[$k]) : $addCr[$k]; - unset($addCr[$k]); - } - - if (is_array($v) && !empty($v)) - $_[$k] = $k.'='.implode(':', $v); - else if ($v !== '') - $_[$k] = $k.'='.$v; - } - - // no criteria were set, so no merge occured .. append - if ($addCr) - { - $_['cr'] = 'cr='.$addCr['cr']; - $_['crs'] = 'crs='.$addCr['crs']; - $_['crv'] = 'crv='.$addCr['crv']; - } - - return implode(';', $_); - } - - public function getExtraCols() - { - return $this->formData['extraCols']; - } - - public function getSetCriteria() - { - return $this->formData['setCriteria']; - } - - public function getSetWeights() - { - return $this->formData['setWeights']; - } - - public function getReputationCols() - { - return $this->formData['reputationCols']; - } - - public function getForm() - { - return $this->formData['form']; - } - - public function getConditions() - { - if (!$this->cndSet) - { - // values - $this->cndSet = $this->createSQLForValues(); - - // criteria - foreach ($this->criteriaIterator() as &$_cr) - $this->cndSet[] = $this->createSQLForCriterium($_cr); - - if ($this->cndSet) - array_unshift($this->cndSet, empty($this->fiData['v']['ma']) ? 'AND' : 'OR'); - } - - return $this->cndSet; - } - - - /**********************/ - /* input sanitization */ - /**********************/ - - private function evaluatePOST() - { - // doesn't need to set formData['form']; this happens in GET-step - - foreach ($this->inputFields as $inp => [$type, $valid, $asArray]) - { - if (!isset($_POST[$inp]) || $_POST[$inp] === '') - continue; - - $val = $_POST[$inp]; - $k = in_array($inp, ['cr', 'crs', 'crv']) ? 'c' : 'v'; - - if ($asArray) - { - $buff = []; - foreach ((array)$val as $v) - if ($v !== '' && $this->checkInput($type, $valid, $v) && $v !== '') - $buff[] = $v; - - if ($buff) - $this->fiData[$k][$inp] = $buff; - } - else if ($val !== '' && $this->checkInput($type, $valid, $val) && $val !== '') - $this->fiData[$k][$inp] = $val; - } - - $this->setWeights(); - $this->setCriteria(); - } - - private function evaluateGET() - { - if (empty($_GET['filter'])) - return; - - // squash into usable format - $post = []; - foreach (explode(';', $_GET['filter']) as $f) - { - if (!strstr($f, '=')) - { - $this->error = true; - continue; - } - - $_ = explode('=', $f); - $post[$_[0]] = $_[1]; - } - - $cr = $crs = $crv = []; - foreach ($this->inputFields as $inp => [$type, $valid, $asArray]) - { - if (!isset($post[$inp]) || $post[$inp] === '') - continue; - - $val = $post[$inp]; - $k = in_array($inp, ['cr', 'crs', 'crv']) ? 'c' : 'v'; - - if ($asArray) - { - $buff = []; - foreach (explode(':', $val) as $v) - if ($v !== '' && $this->checkInput($type, $valid, $v) && $v !== '') - $buff[] = $v; - - if ($buff) - { - if ($k == 'v') - $this->formData['form'][$inp] = $buff; - - $this->fiData[$k][$inp] = array_map(function ($x) { return strtr($x, Filter::$wCards); }, $buff); - } - } - else if ($val !== '' && $this->checkInput($type, $valid, $val) && $val !== '') - { - if ($k == 'v') - $this->formData['form'][$inp] = $val; - - $this->fiData[$k][$inp] = strtr($val, Filter::$wCards); - } - } - - $this->setWeights(); - $this->setCriteria(); - } - - private function setCriteria() // [cr]iterium, [cr].[s]ign, [cr].[v]alue - { - if (empty($this->fiData['c']['cr']) && empty($this->fiData['c']['crs']) && empty($this->fiData['c']['crv'])) - return; - else if (empty($this->fiData['c']['cr']) || empty($this->fiData['c']['crs']) || empty($this->fiData['c']['crv'])) - { - unset($this->fiData['c']['cr']); - unset($this->fiData['c']['crs']); - unset($this->fiData['c']['crv']); - - $this->error = true; - - return; - } - - $_cr = &$this->fiData['c']['cr']; - $_crs = &$this->fiData['c']['crs']; - $_crv = &$this->fiData['c']['crv']; - - if (count($_cr) != count($_crv) || count($_cr) != count($_crs) || count($_cr) > 5 || count($_crs) > 5 /*|| count($_crv) > 5*/) - { - // use min provided criterion as basis; 5 criteria at most - $min = max(5, min(count($_cr), count($_crv), count($_crs))); - if (count($_cr) > $min) - array_splice($_cr, $min); - - if (count($_crv) > $min) - array_splice($_crv, $min); - - if (count($_crs) > $min) - array_splice($_crs, $min); - - $this->error = true; - } - - for ($i = 0; $i < count($_cr); $i++) - { - // conduct filter specific checks & casts here - $unsetme = false; - if (isset($this->genericFilter[$_cr[$i]])) - { - $gf = $this->genericFilter[$_cr[$i]]; - switch ($gf[0]) - { - case FILTER_CR_NUMERIC: - $_ = $_crs[$i]; - if (!Util::checkNumeric($_crv[$i], $gf[2]) || !$this->int2Op($_)) - $unsetme = true; - break; - case FILTER_CR_BOOLEAN: - case FILTER_CR_FLAG: - case FILTER_CR_STAFFFLAG: - $_ = $_crs[$i]; - if (!$this->int2Bool($_)) - $unsetme = true; - break; - case FILTER_CR_ENUM: - if (!Util::checkNumeric($_crs[$i], NUM_REQ_INT)) - $unsetme = true; - break; - } - } - - if (!$unsetme && intval($_cr[$i]) && $_crs[$i] !== '' && $_crv[$i] !== '') - continue; - - unset($_cr[$i]); - unset($_crs[$i]); - unset($_crv[$i]); - - $this->error = true; - } - - $this->formData['setCriteria'] = array( - 'cr' => $_cr, - 'crs' => $_crs, - 'crv' => $_crv - ); - } - - private function setWeights() - { - if (empty($this->fiData['v']['wt']) && empty($this->fiData['v']['wtv'])) - return; - - $_wt = &$this->fiData['v']['wt']; - $_wtv = &$this->fiData['v']['wtv']; - - if (empty($_wt) && !empty($_wtv)) - { - unset($_wtv); - $this->error = true; - return; - } - - if (empty($_wtv) && !empty($_wt)) - { - unset($_wt); - $this->error = true; - return; - } - - $nwt = count($_wt); - $nwtv = count($_wtv); - - if ($nwt > $nwtv) - { - array_splice($_wt, $nwtv); - $this->error = true; - } - else if ($nwtv > $nwt) - { - array_splice($_wtv, $nwt); - $this->error = true; - } - - $this->formData['setWeights'] = [$_wt, $_wtv]; - } - - protected function checkInput($type, $valid, &$val, $recursive = false) - { - switch ($type) - { - case FILTER_V_EQUAL: - if (gettype($valid) == 'integer') - $val = intval($val); - else if (gettype($valid) == 'double') - $val = floatval($val); - else /* if (gettype($valid) == 'string') */ - $val = strval($val); - - if ($valid == $val) - return true; - - break; - case FILTER_V_LIST: - if (!Util::checkNumeric($val, NUM_CAST_INT)) - return false; - - foreach ($valid as $k => $v) - { - if (gettype($v) != 'array') - continue; - - if ($this->checkInput(FILTER_V_RANGE, $v, $val, true)) - return true; - - unset($valid[$k]); - } - - if (in_array($val, $valid)) - return true; - - break; - case FILTER_V_RANGE: - if (Util::checkNumeric($val, NUM_CAST_INT) && $val >= $valid[0] && $val <= $valid[1]) - return true; - - break; - case FILTER_V_CALLBACK: - if ($this->$valid($val)) - return true; - - break; - case FILTER_V_REGEX: - if (!preg_match($valid, $val)) - return true; - - break; - } - - if (!$recursive) - $this->error = true; - - return false; - } - - protected function modularizeString(array $fields, $string = '', $exact = false, $shortStr = false) - { - if (!$string && !empty($this->fiData['v']['na'])) - $string = $this->fiData['v']['na']; - - $qry = []; - $exPH = $exact ? '%s' : '%%%s%%'; - foreach ($fields as $n => $f) - { - $sub = []; - $parts = array_filter(explode(' ', $string)); - foreach ($parts as $p) - { - if ($p[0] == '-' && (mb_strlen($p) > 3 || $shortStr)) - $sub[] = [$f, sprintf($exPH, str_replace('_', '\\_', mb_substr($p, 1))), '!']; - else if ($p[0] != '-' && (mb_strlen($p) > 2 || $shortStr)) - $sub[] = [$f, sprintf($exPH, str_replace('_', '\\_', $p))]; - } - - // single cnd? - if (!$sub) - continue; - else if (count($sub) > 1) - array_unshift($sub, 'AND'); - else - $sub = $sub[0]; - - $qry[] = $sub; - } - - // single cnd? - if (!$qry) - $this->error = true; - else if (count($qry) > 1) - array_unshift($qry, 'OR'); - else - $qry = $qry[0]; - - return $qry; - } - - protected function int2Op(&$op) - { - switch ($op) - { - case 1: $op = '>'; return true; - case 2: $op = '>='; return true; - case 3: $op = '='; return true; - case 4: $op = '<='; return true; - case 5: $op = '<'; return true; - case 6: $op = '!='; return true; - default: return false; - } - } - - protected function int2Bool(&$op) - { - switch ($op) - { - case 1: $op = true; return true; - case 2: $op = false; return true; - default: return false; - } - } - - protected function list2Mask(array $list, $noOffset = false) - { - $mask = 0x0; - $o = $noOffset ? 0 : 1; // schoolMask requires this..? - - foreach ($list as $itm) - $mask += (1 << (intval($itm) - $o)); - - return $mask; - } - - - /**************************/ - /* create conditions from */ - /* generic criteria */ - /**************************/ - - private function genericBoolean($field, $op, $isString) - { - if ($this->int2Bool($op)) - { - $value = $isString ? '' : 0; - $operator = $op ? '!' : null; - - return [$field, $value, $operator]; - } - - return null; - } - - private function genericBooleanFlags($field, $value, $op, $matchAny = false) - { - if (!$this->int2Bool($op)) - return null; - - if (!$op) - return [[$field, $value, '&'], 0]; - else if ($matchAny) - return [[$field, $value, '&'], 0, '!']; - else - return [[$field, $value, '&'], $value]; - } - - private function genericString($field, $value, $strFlags) - { - if ($strFlags & STR_LOCALIZED) - $field .= '_loc'.User::$localeId; - - return $this->modularizeString([$field], (string)$value, $strFlags & STR_MATCH_EXACT, $strFlags & STR_ALLOW_SHORT); - } - - private function genericNumeric($field, &$value, $op, $castInt) - { - if (!Util::checkNumeric($value, $castInt)) - return null; - - if ($this->int2Op($op)) - return [$field, $value, $op]; - - return null; - } - - private function genericEnum($field, $value) - { - if (is_bool($value)) - return [$field, 0, ($value ? '>' : '<=')]; - else if ($value == FILTER_ENUM_ANY) // any - return [$field, 0, '!']; - else if ($value == FILTER_ENUM_NONE) // none - return [$field, 0]; - else if ($value !== null) - return [$field, $value]; - - return null; - } - - protected function genericCriterion(&$cr) - { - $gen = array_pad($this->genericFilter[$cr[0]], 4, null); - $result = null; - - switch ($gen[0]) - { - case FILTER_CR_NUMERIC: - $result = $this->genericNumeric($gen[1], $cr[2], $cr[1], $gen[2]); - break; - case FILTER_CR_FLAG: - $result = $this->genericBooleanFlags($gen[1], $gen[2], $cr[1], $gen[3]); - break; - case FILTER_CR_STAFFFLAG: - if (User::isInGroup(U_GROUP_EMPLOYEE) && $cr[1] >= 0) - $result = $this->genericBooleanFlags($gen[1], (1 << $cr[1]), true); - break; - case FILTER_CR_BOOLEAN: - $result = $this->genericBoolean($gen[1], $cr[1], !empty($gen[2])); - break; - case FILTER_CR_STRING: - $result = $this->genericString($gen[1], $cr[2], $gen[2]); - break; - case FILTER_CR_ENUM: - if (isset($this->enums[$cr[0]][$cr[1]])) - $result = $this->genericEnum($gen[1], $this->enums[$cr[0]][$cr[1]]); - else if (intval($cr[1]) != 0) - $result = $this->genericEnum($gen[1], intval($cr[1])); - break; - case FILTER_CR_CALLBACK: - $result = $this->{$gen[1]}($cr, $gen[2], $gen[3]); - break; - case FILTER_CR_NYI_PH: // do not limit with not implemented filters - if (is_int($gen[2])) - return [$gen[2]]; - - // for nonsensical values; compare against 0 - if ($this->int2Op($cr[1]) && Util::checkNumeric($cr[2])) - { - if ($cr[1] == '=') - $cr[1] = '=='; - - return eval('return ('.$cr[2].' '.$cr[1].' 0);') ? [1] : [0]; - } - else - return [0]; - } - - if ($result && $gen[0] == FILTER_CR_NUMERIC && !empty($gen[3])) - $this->formData['extraCols'][] = $cr[0]; - - return $result; - } - - - /***********************************/ - /* create conditions from */ - /* non-generic values and criteria */ - /***********************************/ - - abstract protected function createSQLForCriterium(&$cr); - abstract protected function createSQLForValues(); -} - -?> diff --git a/includes/cfg.class.php b/includes/cfg.class.php new file mode 100644 index 00000000..56b287c8 --- /dev/null +++ b/includes/cfg.class.php @@ -0,0 +1,499 @@ + 'Site', 'Caching', 'Account', 'Session', 'Site Reputation', 'Google Analytics', 'Profiler', 0 => 'Other' + ); + + private const IDX_VALUE = 0; + private const IDX_FLAGS = 1; + private const IDX_CATEGORY = 2; + private const IDX_DEFAULT = 3; + private const IDX_COMMENT = 4; + + private static $store = []; // name => [value, flags, cat, default, comment] + private static $isLoaded = false; + + private static $rebuildScripts = array( + 'rep_req_border_uncommon' => ['globaljs'], + 'rep_req_border_rare' => ['globaljs'], + 'rep_req_border_epic' => ['globaljs'], + 'rep_req_border_legendary' => ['globaljs'], + 'profiler_enable' => ['realms', 'realmMenu'], + 'battlegroup' => ['realms', 'realmMenu'], + 'name_short' => ['searchplugin', 'searchboxBody', 'searchboxScript', 'demo'], + 'site_host' => ['searchplugin', 'searchboxBody', 'searchboxScript', 'demo', 'power', 'robots'], + 'static_host' => ['searchplugin', 'searchboxBody', 'searchboxScript', 'power'], + 'contact_email' => ['globaljs'], + 'locales' => ['globaljs'] + ); + + public static function load() : void + { + if (!DB::isConnected(DB_AOWOW)) + return; + + $sets = DB::Aowow()->selectAssoc('SELECT `key` AS ARRAY_KEY, `value` AS "0", `flags` AS "1", `cat` AS "2", `default` AS "3", `comment` AS "4" FROM ::config ORDER BY `key` ASC'); + foreach ($sets as $key => [$value, $flags, $catg, $default, $comment]) + { + $php = $flags & self::FLAG_PHP; + + if ($err = self::validate($value, $flags, $comment)) + { + self::throwError('Aowow config '.strtoupper($key).' failed validation and was skipped: '.$err); + continue; + } + + if ($flags & self::FLAG_INTERNAL) + { + self::throwError('Aowow config '.strtoupper($key).' is flagged as internaly generated and should not have been set in DB.'); + continue; + } + + if ($flags & self::FLAG_ON_LOAD_FN) + { + if (!method_exists(__CLASS__, $key)) + self::throwError('Aowow config '.strtoupper($key).' flagged for onLoadFN handling, but no handler was set'); + else + self::{$key}($value); + } + + if ($php) + ini_set(strtolower($key), $value); + + self::$store[strtolower($key)] = [$value, $flags, $catg, $default, $comment]; + } + + if (CLI && !count(self::$store)) + { + CLI::write('Cfg::load - aowow_config unexpectedly empty.', CLI::LOG_WARN); + return; + } + + self::$isLoaded = true; + } + + public static function add(string $key, /*int|string*/ $value) : string + { + if (!self::$isLoaded) + return 'used add() on uninitialized config'; + + if (!$key) + return 'empty option name given'; + + $key = strtolower($key); + + if (!preg_match(self::PATTERN_CONF_KEY_FULL, $key)) + return 'invalid chars in option name: [a-z 0-9 _ . -] are allowed'; + + if (isset(self::$store[$key])) + return 'this configuration option is already in use'; + + if ($errStr = self::validate($value)) + return $errStr; + + if (ini_get($key) === false || ini_set($key, $value) === false) + return 'this configuration option cannot be set'; + + $flags = self::FLAG_TYPE_STRING | self::FLAG_PHP; + if (!is_int(DB::Aowow()->qry('INSERT IGNORE INTO ::config (`key`, `value`, `cat`, `flags`) VALUES (%s, %s, %i, %i)', $key, $value, self::CAT_MISCELLANEOUS, $flags))) + return 'internal error'; + + self::$store[$key] = [$value, $flags, self::CAT_MISCELLANEOUS, null, null]; + return ''; + } + + public static function delete(string $key) : string + { + if (!self::$isLoaded) + return 'used delete() on uninitialized config'; + + $key = strtolower($key); + + if (!isset(self::$store[$key])) + return 'configuration option not found'; + + if (self::$store[$key][self::IDX_FLAGS] & self::FLAG_PERSISTENT) + return 'can\'t delete persistent option'; + + if (!(self::$store[$key][self::IDX_FLAGS] & self::FLAG_PHP)) + return 'can\'t delete non-php option'; + + if (self::$store[$key][self::IDX_FLAGS] & self::FLAG_INTERNAL) + return 'can\'t delete internal option'; + + if (!DB::Aowow()->qry('DELETE FROM ::config WHERE `key` = %s AND (`flags` & %i) = 0 AND (`flags` & %i) > 0', $key, self::FLAG_PERSISTENT, self::FLAG_PHP)) + return 'internal error'; + + unset(self::$store[$key]); + return ''; + } + + public static function get(string $key, bool $fromDB = false, bool $fullInfo = false) // : int|float|string + { + $key = strtolower($key); + + if (!isset(self::$store[$key])) + { + if (self::$isLoaded) + self::throwError('cfg not defined: '.strtoupper($key)); + + return null; + } + + if ($fromDB && $fullInfo) + return array_values(DB::Aowow()->selectRow('SELECT `value`, `flags`, `cat`, `default`, `comment` FROM ::config WHERE `key` = %s', $key)); + if ($fromDB) + return DB::Aowow()->selectCell('SELECT `value` FROM ::config WHERE `key` = %s', $key); + if ($fullInfo) + return self::$store[$key]; + + return self::$store[$key][self::IDX_VALUE]; + } + + public static function set(string $key, /*int|string*/ $value, ?array &$rebuildFiles = []) : string + { + if (!self::$isLoaded) + return 'used set() on uninitialized config'; + + $key = strtolower($key); + + if (!isset(self::$store[$key])) + return 'configuration option not found'; + + [$oldValue, $flags, , , $comment] = self::$store[$key]; + + if ($flags & self::FLAG_INTERNAL) + return 'can\'t set an internal option directly'; + + if ($err = self::validate($value, $flags, $comment)) + return $err; + + if ($flags & self::FLAG_REQUIRED && !strlen($value)) + return 'empty value given for required config'; + + DB::Aowow()->qry('UPDATE ::config SET `value` = %s WHERE `key` = %s', $value, $key); + self::$store[$key][self::IDX_VALUE] = $value; + + // validate change + if ($flags & self::FLAG_ON_SET_FN) + { + $errMsg = ''; + if (!method_exists(__CLASS__, $key)) + $errMsg = 'Aowow config '.strtoupper($key).' flagged for onSetFN validation, but no handler was set'; + else + self::{$key}($value, $errMsg); + + if ($errMsg) + { + // rollback change + DB::Aowow()->qry('UPDATE ::config SET `value` = %s WHERE `key` = %s', $oldValue, $key); + self::$store[$key][self::IDX_VALUE] = $oldValue; + + return $errMsg; + } + } + + if ($flags & self::FLAG_ON_LOAD_FN) + { + if (!method_exists(__CLASS__, $key)) + return 'Aowow config '.strtoupper($key).' flagged for onLoadFN handling, but no handler was set'; + else + self::{$key}($value); + } + + // trigger setup build + return self::handleFileBuild($key, $rebuildFiles); + } + + public static function reset(string $key, ?array &$rebuildFiles = []) : string + { + if (!self::$isLoaded) + return 'used reset() on uninitialized config'; + + $key = strtolower($key); + + if (!isset(self::$store[$key])) + return 'configuration option not found'; + + [$oldValue, $flags, , $default, ] = self::$store[$key]; + + if ($flags & self::FLAG_INTERNAL) + return 'can\'t set an internal option directly'; + + if (!$default) + return 'config option has no default value'; + + // @eval .. some dafault values are supplied as bitmask or the likes + if (!($flags & Cfg::FLAG_TYPE_STRING)) + $default = @eval('return ('.$default.');'); + + DB::Aowow()->qry('UPDATE ::config SET `value` = %s WHERE `key` = %s', $default, $key); + self::$store[$key][self::IDX_VALUE] = $default; + + // validate change + if ($flags & self::FLAG_ON_SET_FN) + { + $errMsg = ''; + if (!method_exists(__CLASS__, $key)) + $errMsg = 'required onSetFN validator not set'; + else + self::{$key}($default, $errMsg); + + if ($errMsg) + { + // rollback change + DB::Aowow()->qry('UPDATE ::config SET `value` = %s WHERE `key` = %s', $oldValue, $key); + self::$store[$key][self::IDX_VALUE] = $oldValue; + + return $errMsg; + } + } + + // trigger setup build + return self::handleFileBuild($key, $rebuildFiles); + } + + public static function forCategory(int $category) : \Generator + { + foreach (self::$store as $k => [, $flags, $catg, , ]) + if ($catg == $category && !($flags & self::FLAG_INTERNAL)) + yield $k => self::$store[$k]; + } + + public static function applyToString(string $string, bool $nf = true) : string + { + return preg_replace_callback( + ['/CFG_([A-Z_]+)/', '/((HOST|STATIC)_URL)/'], + function ($m) use ($nf) { + if (!isset(self::$store[strtolower($m[1])])) + return $m[1]; + + [$val, $flags, , , ] = self::$store[strtolower($m[1])]; + return ($flags & (self::FLAG_TYPE_FLOAT | self::FLAG_TYPE_INT)) && $nf ? Lang::nf($val) : $val; + }, + $string + ); + } + + + /************/ + /* internal */ + /************/ + + private static function validate(&$value, int $flags = self::FLAG_TYPE_STRING | self::FLAG_PHP, string $comment = ' - ') : string + { + $value = preg_replace(self::PATTERN_INVALID_CHARS, '', $value); + + if (!($flags & (self::FLAG_TYPE_BOOL | self::FLAG_TYPE_FLOAT | self::FLAG_TYPE_INT | self::FLAG_TYPE_STRING))) + return 'no type set for value'; + + if ($flags & self::FLAG_TYPE_INT && !Util::checkNumeric($value, NUM_CAST_INT)) + return 'value must be integer'; + + if ($flags & self::FLAG_TYPE_FLOAT && !Util::checkNumeric($value, NUM_CAST_FLOAT)) + return 'value must be float'; + + if ($flags & self::FLAG_OPT_LIST) + { + $info = explode(' - ', $comment)[1]; + foreach (explode(', ', $info) as $option) + if (explode(':', $option)[0] == $value) + return ''; + + return 'value not in range'; + } + + if ($flags & self::FLAG_BITMASK) + { + $mask = 0x0; + $info = explode(' - ', $comment)[1]; + foreach (explode(', ', $info) as $option) + $mask |= (1 << explode(':', $option)[0]); + + if (!($value &= $mask) && ($flags & self::FLAG_REQUIRED)) + return 'value not in range'; + } + + if ($flags & self::FLAG_TYPE_BOOL) + $value = $value ? 1 : 0; + + return ''; + } + + private static function handleFileBuild(string $key, array &$rebuildFiles) : string + { + if (!isset(self::$rebuildScripts[$key])) + return ''; + + $msg = ''; + + if (CLI) + { + $rebuildFiles = array_merge($rebuildFiles, self::$rebuildScripts[$key]); + return ''; + } + + // not in CLI mode and build() can only be run from CLI. .. todo: other options..? + exec('php aowow --build='.implode(',', self::$rebuildScripts[$key]), $out); + foreach ($out as $o) + if (strstr($o, 'ERR')) + $msg .= explode('0m]', $o)[1]."
\n"; + + return $msg; + } + + private static function throwError($msg) : void + { + if (CLI) + CLI::write($msg, CLI::LOG_ERROR); + else + trigger_error($msg, E_USER_ERROR); + } + + private static function useSSL() : bool + { + return (($_SERVER['HTTPS'] ?? 'off') != 'off') || (self::$store['force_ssl'][self::IDX_VALUE] ?? 0); + } + + + /***************************/ + /* onSet/onLoad validators */ + /***************************/ + + private static function locales(int|string $value, ?string &$msg = '') : bool + { + if (!CLI) + return true; + + // note: Change is written to db and storage at this point, but can be rolled back. + if (CLISetup::setLocales()) + return true; + + $msg .= 'no valid locales set'; + return false; + } + + private static function acc_auth_mode(int|string $value, ?string &$msg = '') : bool + { + if ($value == 1 && !extension_loaded('gmp')) + { + $msg .= 'PHP extension GMP is required to use TrinityCore as auth source, but is not currently enabled.'; + return false; + } + + return true; + } + + private static function profiler_enable(int|string $value, ?string &$msg = '') : bool + { + if ($value != 1) + return true; + + return Profiler::queueStart($msg); + } + + private static function static_host(int|string $value, ?string &$msg = '') : bool + { + self::$store['static_url'] = array( // points js to images & scripts + (self::useSSL() ? 'https://' : 'http://').$value, + self::FLAG_PERSISTENT | self::FLAG_TYPE_STRING | self::FLAG_INTERNAL, + self::CAT_SITE, + null, // no default value + null, // no comment/info + ); + + return true; + } + + private static function site_host(int|string $value, ?string &$msg = '') : bool + { + self::$store['host_url'] = array( // points js to executable files + (self::useSSL() ? 'https://' : 'http://').$value, + self::FLAG_PERSISTENT | self::FLAG_TYPE_STRING | self::FLAG_INTERNAL, + self::CAT_SITE, + null, // no default value + null, // no comment/info + ); + + return true; + } + + private static function cache_mode(int|string $value, ?string &$msg = '') : bool + { + if ($value & 0x2 && !class_exists('\Memcached')) + { + $msg .= 'PHP extension Memcached is not enabled.'; + return false; + } + + return true; + } + + private static function screenshot_min_size(int|string $value, ?string &$msg = '') : bool + { + if ($value < 200) + { + $msg .= 'Value must be at least 200 (px).'; + return false; + } + + return true; + } + + private static function logographic_ft_search(int|string $value, ?string &$msg = '') : bool + { + if (!$value) + return true; + + $ok = true; + foreach (['::spell', '::items', '::objects', '::creature', '::quests'] as $tbl) + { + if (DB::Aowow()->selectRow('SHOW INDEX FROM %n WHERE `column_name` = %s AND `index_type` = "FULLTEXT"', $tbl, 'name_loc4')) + continue; + + $ok = false; + $msg .= "\nNo fulltext index found on col: 'name_loc4'; tbl: '".$tbl."'."; + } + + if (!$ok) + $msg .= "\nCannot enable option.\n"; + + return $ok; + } +} + +?> diff --git a/includes/community.class.php b/includes/community.class.php deleted file mode 100644 index f4359a08..00000000 --- a/includes/community.class.php +++ /dev/null @@ -1,506 +0,0 @@ - 0 AND ur.userId = ?d, ur.value, 0)) AS userRating, - SUM(IF( r.userId > 0 AND r.userId = ?d, 1, 0)) AS userReported - FROM - ?_comments c - JOIN - ?_account a1 ON c.userId = a1.id - LEFT JOIN - ?_account a2 ON c.editUserId = a2.id - LEFT JOIN - ?_account a3 ON c.deleteUserId = a3.id - LEFT JOIN - ?_account a4 ON c.responseUserId = a4.id - LEFT JOIN - ?_user_ratings ur ON c.id = ur.entry AND ur.type = ?d - LEFT JOIN - ?_reports r ON r.subject = c.id AND r.mode = 1 AND r.reason = 19 - WHERE - c.replyTo = ?d AND c.type = ?d AND c.typeId = ?d AND - ((c.flags & ?d) = 0 OR c.userId = ?d OR ?d) - GROUP BY - c.id - ORDER BY - rating ASC - '; - - private static string $ssQuery = ' - SELECT s.id AS ARRAY_KEY, s.id, a.displayName AS user, s.date, s.width, s.height, s.caption, IF(s.status & ?d, 1, 0) AS "sticky", s.type, s.typeId - FROM ?_screenshots s - LEFT JOIN ?_account a ON s.userIdOwner = a.id - WHERE {s.userIdOwner = ?d AND }{s.type = ? AND }{s.typeId = ? AND }s.status & ?d AND (s.status & ?d) = 0 - {ORDER BY ?# DESC} - {LIMIT ?d} - '; - - private static string $viQuery = ' - SELECT v.id AS ARRAY_KEY, v.id, a.displayName AS user, v.date, v.videoId, v.caption, IF(v.status & ?d, 1, 0) AS "sticky", v.type, v.typeId - FROM ?_videos v - LEFT JOIN ?_account a ON v.userIdOwner = a.id - WHERE {v.userIdOwner = ?d AND }{v.type = ? AND }{v.typeId = ? AND }v.status & ?d AND (v.status & ?d) = 0 - {ORDER BY ?# DESC} - {LIMIT ?d} - '; - - private static string $previewQuery = ' - SELECT - c.id, - c.body AS preview, - c.date, - c.replyTo AS commentid, - IF(c.flags & ?d, 1, 0) AS deleted, - IF(c.type <> 0, c.type, c2.type) AS type, - IF(c.typeId <> 0, c.typeId, c2.typeId) AS typeId, - IFNULL(SUM(ur.value), 0) AS rating, - a.displayName AS user - FROM - ?_comments c - JOIN - ?_account a ON c.userId = a.id - LEFT JOIN - ?_user_ratings ur ON ur.entry = c.id AND ur.userId <> 0 AND ur.`type` = 1 - LEFT JOIN - ?_comments c2 ON c.replyTo = c2.id - WHERE - {c.userId = ?d AND} - {c.replyTo <> ?d AND} - {c.replyTo = ?d AND} - ((c.flags & ?d) = 0 OR c.userId = ?d OR ?d) - GROUP BY - c.id - ORDER BY - date DESC - LIMIT - ?d - '; - - private static function addSubject(int $type, int $typeId) : void - { - if (!isset(self::$subjCache[$type][$typeId])) - self::$subjCache[$type][$typeId] = 0; - } - - private static function getSubjects() : void - { - foreach (self::$subjCache as $type => $ids) - { - $_ = array_filter(array_keys($ids), 'is_numeric'); - if (!$_) - continue; - - $obj = Type::newList($type, [CFG_SQL_LIMIT_NONE, ['id', $_]]); - if (!$obj) - continue; - - foreach ($obj->iterate() as $id => $__) - self::$subjCache[$type][$id] = $obj->getField('name', true); - } - } - - public static function getCommentPreviews(array $params = [], ?int &$nFound = 0, bool $dateFmt = true) : array - { - /* - purged:0, <- doesnt seem to be used anymore - domain:'live' <- irrelevant for our case - */ - - $comments = DB::Aowow()->selectPage( - $nFound, - self::$previewQuery, - CC_FLAG_DELETED, - empty($params['user']) ? DBSIMPLE_SKIP : $params['user'], - empty($params['replies']) ? DBSIMPLE_SKIP : 0, // i dont know, how to switch the sign around - !empty($params['replies']) ? DBSIMPLE_SKIP : 0, - CC_FLAG_DELETED, - User::$id, - User::isInGroup(U_GROUP_COMMENTS_MODERATOR), - CFG_SQL_LIMIT_DEFAULT - ); - - foreach ($comments as $c) - self::addSubject($c['type'], $c['typeId']); - - self::getSubjects(); - - foreach ($comments as $idx => &$c) - { - if (!empty(self::$subjCache[$c['type']][$c['typeId']])) - { - // apply subject - $c['subject'] = self::$subjCache[$c['type']][$c['typeId']]; - - // format date - $c['date'] = $dateFmt ? date(Util::$dateFormatInternal, $c['date']) : intVal($c['date']); - - // remove commentid if not looking for replies - if (empty($params['replies'])) - unset($c['commentid']); - - // format text for listview - $c['preview'] = Lang::trimTextClean($c['preview']); - } - else - { - trigger_error('Comment '.$c['id'].' belongs to nonexistant subject.', E_USER_NOTICE); - unset($comments[$idx]); - } - } - - return $comments; - } - - public static function getCommentReplies(int $commentId, int $limit = 0, ?int &$nFound = 0) : array - { - $replies = []; - $query = $limit > 0 ? self::$coQuery.' LIMIT '.$limit : self::$coQuery; - - // get replies - $results = DB::Aowow()->selectPage($nFound, $query, User::$id, User::$id, RATING_COMMENT, $commentId, 0, 0, CC_FLAG_DELETED, User::$id, User::isInGroup(U_GROUP_COMMENTS_MODERATOR)); - foreach ($results as $r) - { - (new Markup($r['body']))->parseGlobalsFromText(self::$jsGlobals); - - $reply = array( - 'commentid' => $commentId, - 'id' => $r['id'], - 'body' => $r['body'], - 'username' => $r['user'], - 'roles' => $r['roles'], - 'creationdate' => date(Util::$dateFormatInternal, $r['date']), - 'lasteditdate' => date(Util::$dateFormatInternal, $r['editDate']), - 'rating' => (string)$r['rating'] - ); - - if ($r['userReported']) - $reply['reportedByUser'] = true; - - if ($r['userRating'] > 0) - $reply['votedByUser'] = true; - else if ($r['userRating'] < 0) - $reply['downvotedByUser'] = true; - - $replies[] = $reply; - } - - return $replies; - } - - public static function getScreenshotsForManager($type, $typeId, $userId = 0) - { - $screenshots = DB::Aowow()->select(' - SELECT s.id, a.displayName AS user, s.date, s.width, s.height, s.type, s.typeId, s.caption, s.status, s.status AS "flags" - FROM ?_screenshots s - LEFT JOIN ?_account a ON s.userIdOwner = a.id - WHERE - { s.type = ?d} - { AND s.typeId = ?d} - { s.userIdOwner = ?d} - LIMIT 100', - $userId ? DBSIMPLE_SKIP : $type, - $userId ? DBSIMPLE_SKIP : $typeId, - $userId ? $userId : DBSIMPLE_SKIP - ); - - $num = []; - foreach ($screenshots as $s) - { - if (empty($num[$s['type']][$s['typeId']])) - $num[$s['type']][$s['typeId']] = 1; - else - $num[$s['type']][$s['typeId']]++; - } - - // format data to meet requirements of the js - foreach ($screenshots as $idx => &$s) - { - $s['date'] = date(Util::$dateFormatInternal, $s['date']); - - $s['name'] = "Screenshot #".$s['id']; // what should we REALLY name it? - - if (isset($screenshots[$idx - 1])) - $s['prev'] = $idx - 1; - - if (isset($screenshots[$idx + 1])) - $s['next'] = $idx + 1; - - // order gives priority for 'status' - if (!($s['flags'] & CC_FLAG_APPROVED)) - { - $s['pending'] = 1; - $s['status'] = 0; - } - else - $s['status'] = 100; - - if ($s['flags'] & CC_FLAG_STICKY) - { - $s['sticky'] = 1; - $s['status'] = 105; - } - - if ($s['flags'] & CC_FLAG_DELETED) - { - $s['deleted'] = 1; - $s['status'] = 999; - } - - // something todo with massSelect .. am i doing this right? - if ($num[$s['type']][$s['typeId']] == 1) - $s['unique'] = 1; - - if (!$s['user']) - unset($s['user']); - } - - return $screenshots; - } - - public static function getScreenshotPagesForManager($all, &$nFound) - { - // i GUESS .. ss_getALL ? everything : pending - $nFound = 0; - $pages = DB::Aowow()->select(' - SELECT s.`type`, s.`typeId`, count(1) AS "count", MIN(s.`date`) AS "date" - FROM ?_screenshots s - {WHERE (s.status & ?d) = 0} - GROUP BY s.`type`, s.`typeId`', - $all ? DBSIMPLE_SKIP : CC_FLAG_APPROVED | CC_FLAG_DELETED - ); - - if ($pages) - { - // limit to one actually existing type each - foreach (array_unique(array_column($pages, 'type')) as $t) - { - $ids = []; - foreach ($pages as $row) - if ($row['type'] == $t) - $ids[] = $row['typeId']; - - if (!$ids) - continue; - - $obj = Type::newList($t, [CFG_SQL_LIMIT_NONE, ['id', $ids]]); - if (!$obj || $obj->error) - continue; - - foreach ($pages as &$p) - if ($p['type'] == $t) - if ($obj->getEntry($p['typeId'])) - $p['name'] = $obj->getField('name', true); - } - - foreach ($pages as &$p) - { - if (empty($p['name'])) - { - trigger_error('Screenshot linked to nonexistant type/typeId combination: '.$p['type'].'/'.$p['typeId'], E_USER_NOTICE); - unset($p); - } - else - { - $nFound += $p['count']; - $p['date'] = date(Util::$dateFormatInternal, $p['date']); - } - } - } - - return $pages; - } - - public static function getComments(int $type, int $typeId) : array - { - - $results = DB::Aowow()->query(self::$coQuery, User::$id, User::$id, RATING_COMMENT, 0, $type, $typeId, CC_FLAG_DELETED, User::$id, (int)User::isInGroup(U_GROUP_COMMENTS_MODERATOR)); - $comments = []; - - // additional informations - $i = 0; - foreach ($results as $r) - { - (new Markup($r['body']))->parseGlobalsFromText(self::$jsGlobals); - - self::$jsGlobals[Type::USER][$r['userId']] = $r['userId']; - - $c = array( - 'commentv2' => 1, // always 1.. enables some features i guess..? - 'number' => $i++, // some iterator .. unsued? - 'id' => $r['id'], - 'date' => date(Util::$dateFormatInternal, $r['date']), - 'roles' => $r['roles'], - 'body' => $r['body'], - 'rating' => $r['rating'], - 'userRating' => $r['userRating'], - 'user' => $r['user'], - ); - - $c['replies'] = self::getCommentReplies($r['id'], 5, $c['nreplies']); - - if ($r['responseBody']) // adminResponse - { - $c['response'] = $r['responseBody']; - $c['responseroles'] = $r['responseRoles']; - $c['responseuser'] = $r['responseUser']; - - (new Markup($r['responseBody']))->parseGlobalsFromText(self::$jsGlobals); - } - - if ($r['editCount']) // lastEdit - $c['lastEdit'] = [date(Util::$dateFormatInternal, $r['editDate']), $r['editCount'], $r['editUser']]; - - if ($r['flags'] & CC_FLAG_STICKY) - $c['sticky'] = true; - - if ($r['flags'] & CC_FLAG_DELETED) - { - $c['deleted'] = true; - $c['deletedInfo'] = [date(Util::$dateFormatInternal, $r['deleteDate']), $r['deleteUser']]; - } - - if ($r['flags'] & CC_FLAG_OUTDATED) - $c['outofdate'] = true; - - $comments[] = $c; - } - - return $comments; - } - - public static function getVideos(int $typeOrUser = 0, int $typeId = 0, int &$nFound = 0, bool $dateFmt = true) : array - { - $videos = DB::Aowow()->selectPage($nFound, self::$viQuery, - CC_FLAG_STICKY, - $typeOrUser < 0 ? -$typeOrUser : DBSIMPLE_SKIP, - $typeOrUser > 0 ? $typeOrUser : DBSIMPLE_SKIP, - $typeOrUser > 0 ? $typeId : DBSIMPLE_SKIP, - CC_FLAG_APPROVED, - CC_FLAG_DELETED, - !$typeOrUser ? 'date' : DBSIMPLE_SKIP, - !$typeOrUser ? CFG_SQL_LIMIT_SEARCH : DBSIMPLE_SKIP - ); - - if ($typeOrUser <= 0) // not for search by type/typeId - { - foreach ($videos as $v) - self::addSubject($v['type'], $v['typeId']); - - self::getSubjects(); - } - - // format data to meet requirements of the js - foreach ($videos as &$v) - { - if ($typeOrUser <= 0) // not for search by type/typeId - { - if (!empty(self::$subjCache[$v['type']][$v['typeId']]) && !is_numeric(self::$subjCache[$v['type']][$v['typeId']])) - $v['subject'] = self::$subjCache[$v['type']][$v['typeId']]; - else - $v['subject'] = Lang::user('removed'); - } - - $v['date'] = $dateFmt ? date(Util::$dateFormatInternal, $v['date']) : intVal($v['date']); - $v['videoType'] = 1; // always youtube - - if (!$v['sticky']) - unset($v['sticky']); - - if (!$v['user']) - unset($v['user']); - } - - return $videos; - } - - public static function getScreenshots(int $typeOrUser = 0, int $typeId = 0, int &$nFound = 0, bool $dateFmt = true) : array - { - $screenshots = DB::Aowow()->selectPage($nFound, self::$ssQuery, - CC_FLAG_STICKY, - $typeOrUser < 0 ? -$typeOrUser : DBSIMPLE_SKIP, - $typeOrUser > 0 ? $typeOrUser : DBSIMPLE_SKIP, - $typeOrUser > 0 ? $typeId : DBSIMPLE_SKIP, - CC_FLAG_APPROVED, - CC_FLAG_DELETED, - !$typeOrUser ? 'date' : DBSIMPLE_SKIP, - !$typeOrUser ? CFG_SQL_LIMIT_SEARCH : DBSIMPLE_SKIP - ); - - if ($typeOrUser <= 0) // not for search by type/typeId - { - foreach ($screenshots as $s) - self::addSubject($s['type'], $s['typeId']); - - self::getSubjects(); - } - - // format data to meet requirements of the js - foreach ($screenshots as &$s) - { - if ($typeOrUser <= 0) // not for search by type/typeId - { - if (!empty(self::$subjCache[$s['type']][$s['typeId']]) && !is_numeric(self::$subjCache[$s['type']][$s['typeId']])) - $s['subject'] = self::$subjCache[$s['type']][$s['typeId']]; - else - $s['subject'] = Lang::user('removed'); - } - - $s['date'] = $dateFmt ? date(Util::$dateFormatInternal, $s['date']) : intVal($s['date']); - - if (!$s['sticky']) - unset($s['sticky']); - - if (!$s['user']) - unset($s['user']); - } - - return $screenshots; - } - - public static function getAll(int $type, int $typeId, array &$jsg) : array - { - $result = array( - 'vi' => self::getVideos($type, $typeId), - 'ss' => self::getScreenshots($type, $typeId), - 'co' => self::getComments($type, $typeId) - ); - - Util::mergeJsGlobals($jsg, self::$jsGlobals); - - return $result; - } - - public static function getJSGlobals() : array - { - return self::$jsGlobals; - } -} -?> diff --git a/includes/components/Conditions/Conditions.class.php b/includes/components/Conditions/Conditions.class.php new file mode 100644 index 00000000..9512d766 --- /dev/null +++ b/includes/components/Conditions/Conditions.class.php @@ -0,0 +1,770 @@ + + public const OP_LT = 2; // < + public const OP_GT_E = 3; // >= + public const OP_LT_E = 4; // <= + // Group, Entry, Id + public const SRC_NONE = 0; // null, null, null - use when adding external conditions + public const SRC_CREATURE_LOOT_TEMPLATE = 1; // tplEntry, itemId, null + public const SRC_DISENCHANT_LOOT_TEMPLATE = 2; // tplEntry, itemId, null + public const SRC_FISHING_LOOT_TEMPLATE = 3; // tplEntry, itemId, null + public const SRC_GAMEOBJECT_LOOT_TEMPLATE = 4; // tplEntry, itemId, null + public const SRC_ITEM_LOOT_TEMPLATE = 5; // tplEntry, itemId, null + public const SRC_MAIL_LOOT_TEMPLATE = 6; // tplEntry, itemId, null + public const SRC_MILLING_LOOT_TEMPLATE = 7; // tplEntry, itemId, null + public const SRC_PICKPOCKETING_LOOT_TEMPLATE = 8; // tplEntry, itemId, null + public const SRC_PROSPECTING_LOOT_TEMPLATE = 9; // tplEntry, itemId, null + public const SRC_REFERENCE_LOOT_TEMPLATE = 10; // tplEntry, itemId, null + public const SRC_SKINNING_LOOT_TEMPLATE = 11; // tplEntry, itemId, null + public const SRC_SPELL_LOOT_TEMPLATE = 12; // tplEntry, itemId, null + public const SRC_SPELL_IMPLICIT_TARGET = 13; // effectMask, spellId, null + public const SRC_GOSSIP_MENU = 14; // menuId, textId, null + public const SRC_GOSSIP_MENU_OPTION = 15; // menuId, optionId, null + public const SRC_CREATURE_TEMPLATE_VEHICLE = 16; // npcId, null, null + public const SRC_SPELL = 17; // null, spellId, null + public const SRC_SPELL_CLICK_EVENT = 18; // npcId, spellId, null + public const SRC_QUEST_AVAILABLE = 19; // null, questId, null + public const SRC_QUEST_SHOW_MARK = 20; // null, questId, null - ⚠️ unused as of 01.05.2024 + public const SRC_VEHICLE_SPELL = 21; // npcId, spellId, null + public const SRC_SMART_EVENT = 22; // id, entryGuid, srcType + public const SRC_NPC_VENDOR = 23; // npcId, itemId, null + public const SRC_SPELL_PROC = 24; // null, spellId, null +// public const SRC_SPELL_TERRAIN_SWAP = 25; // - ❌ reserved for TC master +// public const SRC_SPELL_PHASE = 26; // - ❌ reserved for TC master +// public const SRC_SPELL_GRAVEYARD = 27; // - ❌ reserved for TC master +// public const SRC_SPELL_AREATRIGGER = 28; // - ❌ reserved for TC master +// public const SRC_SPELL_CONVERSATION_LINE = 29; // - ❌ reserved for TC master + public const SRC_AREATRIGGER_CLIENT = 30; // null, atId, null +// public const SRC_SPELL_TRAINER_SPELL = 31; // - ❌ reserved for TC master +// public const SRC_SPELL_OBJECT_VISIBILITY = 32; // - ❌ reserved for TC master +// public const SRC_SPELL_SPAWN_GROUP = 33; // - ❌ reserved for TC master + + public const NONE = 0; // always true: NULL, NULL, NULL + public const AURA = 1; // aura is applied: spellId, effIdx, NULL + public const ITEM = 2; // owns item: itemId, count, includeBank? + public const ITEM_EQUIPPED = 3; // has item equipped: itemId, NULL, NULL + public const ZONEID = 4; // is in zone: areaId, NULL, NULL + public const REPUTATION_RANK = 5; // reputation status: factionId, rankMask, NULL + public const TEAM = 6; // is on team: teamId, NULL, NULL + public const SKILL = 7; // has skill: skillId, value, NULL + public const QUESTREWARDED = 8; // has finished quest: questId, NULL, NULL + public const QUESTTAKEN = 9; // has accepted quest: questId, NULL, NULL + public const DRUNKENSTATE = 10; // has drunken status: stateId, NULL, NULL + public const WORLD_STATE = 11; // world var == value: worldStateId, value, NULL + public const ACTIVE_EVENT = 12; // world event is active: eventId, NULL, NULL + public const INSTANCE_INFO = 13; // instance var == data: entry data, type + public const QUEST_NONE = 14; // never seen quest: questId, NULL, NULL + public const CHR_CLASS = 15; // belongs to classes: classMask, NULL, NULL + public const CHR_RACE = 16; // belongs to races: raceMask, NULL, NULL + public const ACHIEVEMENT = 17; // obtained achievement: achievementId, NULL, NULL + public const TITLE = 18; // obtained title: titleId, NULL, NULL + public const SPAWNMASK = 19; // spawnMask, NULL, NULL + public const GENDER = 20; // has gender: genderId, NULL, NULL + public const UNIT_STATE = 21; // unit has state: unitState, NULL, NULL + public const MAPID = 22; // is on map: mapId, NULL, NULL + public const AREAID = 23; // is in area: areaId, NULL, NULL + public const CREATURE_TYPE = 24; // creature is of type: creaturetypeId, NULL, NULL + public const SPELL = 25; // knows spell: spellId, NULL, NULL + public const PHASEMASK = 26; // is in phase: phaseMask, NULL, NULL + public const LEVEL = 27; // player level is..: level, comparator, NULL + public const QUEST_COMPLETE = 28; // has completed quest: questId, NULL, NULL + public const NEAR_CREATURE = 29; // is near creature: creatureId, dist, includeCorpse? + public const NEAR_GAMEOBJECT = 30; // is near gameObject: gameObjectId, dist, NULL + public const OBJECT_ENTRY_GUID = 31; // target is ???: objectType, id, guid + public const TYPE_MASK = 32; // target matches type: typeMask, NULL, NULL + public const RELATION_TO = 33; // Cond.Target, relation, NULL + public const REACTION_TO = 34; // Cond.Target, rankMask, NULL + public const DISTANCE_TO = 35; // distance to target Cond.Target, dist, comparator + public const ALIVE = 36; // target is alive: NULL, NULL, NULL + public const HP_VAL = 37; // targets absolute health: amount, comparator, NULL + public const HP_PCT = 38; // targets relative health: amount, comparator, NULL + public const REALM_ACHIEVEMENT = 39; // realmfirst was achieved: achievementId, NULL, NULL + public const IN_WATER = 40; // unit is swimming: NULL, NULL, NULL +// public const TERRAIN_SWAP = 41; // ❌ reserved for TC master + public const STAND_STATE = 42; // stateType, state, NULL + public const DAILY_QUEST_DONE = 43; // repeatable quest done: questId, NULL, NULL + public const CHARMED = 44; // unit is charmed: NULL, NULL, NULL + public const PET_TYPE = 45; // player has pet of type: petType, NULL, NULL + public const TAXI = 46; // player is on taxi: NULL, NULL, NULL + public const QUESTSTATE = 47; // questId, stateMask, NULL + public const QUEST_OBJECTIVE_PROGRESS = 48; // questId, objectiveIdx, count + public const DIFFICULTY_ID = 49; // map has difficulty id: difficulty, NULL, NULL + public const GAMEMASTER = 50; // player is GM: canBeGM?, NULL, NULL +// public const OBJECT_ENTRY_GUID_MASTER = 51; // ❌ reserved for TC master +// public const TYPE_MASK_MASTER = 52; // ❌ reserved for TC master +// public const BATTLE_PET_COUNT = 53; // ❌ reserved for TC master +// public const SCENARIO_STEP = 54; // ❌ reserved for TC master +// public const SCENE_IN_PROGRESS = 55; // ❌ reserved for TC master +// public const PLAYER_CONDITION = 56; // ❌ reserved for TC master +// public const PRIVATE_OBJECT = 57; // ❌ reserved for TC master + public const STRING_ID = 58; // go or npc has StringId NULL, NULL, NULL +// public const LABEL = 59; // ❌ reserved for TC master + + private const IDX_SRC_GROUP = 0; + private const IDX_SRC_ENTRY = 1; + private const IDX_SRC_ID = 2; + private const IDX_SRC_FN = 3; + + private static $source = array( // [Group, Entry, Id, typeResolverFN] + self::SRC_NONE => [null, null, null, null], + self::SRC_CREATURE_LOOT_TEMPLATE => [Type::NPC, Type::ITEM, null, 'lootIdToNpc'], + self::SRC_DISENCHANT_LOOT_TEMPLATE => [Type::ITEM, Type::ITEM, null, 'disenchantIdToItem'], + self::SRC_FISHING_LOOT_TEMPLATE => [Type::ZONE, Type::ITEM, null, null], + self::SRC_GAMEOBJECT_LOOT_TEMPLATE => [Type::OBJECT, Type::ITEM, null, 'lootIdToGObject'], + self::SRC_ITEM_LOOT_TEMPLATE => [Type::ITEM, Type::ITEM, null, null], + self::SRC_MAIL_LOOT_TEMPLATE => [Type::QUEST, Type::ITEM, null, 'RewardTemplateToQuest'], + self::SRC_MILLING_LOOT_TEMPLATE => [Type::ITEM, Type::ITEM, null, null], + self::SRC_PICKPOCKETING_LOOT_TEMPLATE => [Type::NPC, Type::ITEM, null, 'PickpocketLootToNpc'], + self::SRC_PROSPECTING_LOOT_TEMPLATE => [Type::ITEM, Type::ITEM, null, null], + self::SRC_REFERENCE_LOOT_TEMPLATE => [null, Type::ITEM, null, null], + self::SRC_SKINNING_LOOT_TEMPLATE => [Type::NPC, Type::ITEM, null, 'SkinLootToNpc'], + self::SRC_SPELL_LOOT_TEMPLATE => [Type::SPELL, Type::ITEM, null, null], + self::SRC_SPELL_IMPLICIT_TARGET => [true, Type::SPELL, null, null], + self::SRC_GOSSIP_MENU => [true, true, null, null], + self::SRC_GOSSIP_MENU_OPTION => [true, true, null, null], + self::SRC_CREATURE_TEMPLATE_VEHICLE => [null, Type::NPC, null, null], + self::SRC_SPELL => [null, Type::SPELL, null, null], + self::SRC_SPELL_CLICK_EVENT => [Type::NPC, Type::SPELL, null, null], + self::SRC_QUEST_AVAILABLE => [null, Type::QUEST, null, null], + self::SRC_QUEST_SHOW_MARK => [null, Type::QUEST, null, null], + self::SRC_VEHICLE_SPELL => [Type::NPC, Type::SPELL, null, null], + self::SRC_SMART_EVENT => [true, true, true, null], + self::SRC_NPC_VENDOR => [Type::NPC, Type::ITEM, null, null], + self::SRC_SPELL_PROC => [null, Type::SPELL, null, null], + self::SRC_AREATRIGGER_CLIENT => [null, Type::AREATRIGGER, null, null] + ); + + private const IDX_CND_VAL1 = 0; + private const IDX_CND_VAL2 = 1; + private const IDX_CND_VAL3 = 2; + private const IDX_CND_FN = 3; + + private static $conditions = array(// [Value1, Value2, Value3, handlerFn] + self::NONE => [null, null, null, null], + self::AURA => [Type::SPELL, null, null, null], + self::ITEM => [Type::ITEM, true, true, null], + self::ITEM_EQUIPPED => [Type::ITEM, null, null, null], + self::ZONEID => [Type::ZONE, null, null, null], + self::REPUTATION_RANK => [Type::FACTION, true, null, null], + self::TEAM => [true, null, null, 'factionToSide'], + self::SKILL => [Type::SKILL, true, null, null], + self::QUESTREWARDED => [Type::QUEST, null, null, null], + self::QUESTTAKEN => [Type::QUEST, null, null, null], + self::DRUNKENSTATE => [true, null, null, null], + self::WORLD_STATE => [true, true, null, null], + self::ACTIVE_EVENT => [Type::WORLDEVENT, null, null, null], + self::INSTANCE_INFO => [true, true, true, null], + self::QUEST_NONE => [Type::QUEST, null, null, null], + self::CHR_CLASS => [Type::CHR_CLASS, null, null, 'maskToBits'], + self::CHR_RACE => [Type::CHR_RACE, null, null, 'maskToBits'], + self::ACHIEVEMENT => [Type::ACHIEVEMENT, null, null, null], + self::TITLE => [Type::TITLE, null, null, null], + self::SPAWNMASK => [true, null, null, null], + self::GENDER => [true, null, null, null], + self::UNIT_STATE => [true, null, null, null], + self::MAPID => [true, true, null, 'mapToZone'], + self::AREAID => [Type::ZONE, null, null, null], + self::CREATURE_TYPE => [true, null, null, null], + self::SPELL => [Type::SPELL, null, null, null], + self::PHASEMASK => [true, null, null, null], + self::LEVEL => [true, true, null, null], + self::QUEST_COMPLETE => [Type::QUEST, null, null, null], + self::NEAR_CREATURE => [Type::NPC, true, true, null], + self::NEAR_GAMEOBJECT => [Type::OBJECT, true, true, null], + self::OBJECT_ENTRY_GUID => [true, true, true, 'typeidToId'], + self::TYPE_MASK => [true, null, null, null], + self::RELATION_TO => [true, true, null, null], + self::REACTION_TO => [true, true, null, null], + self::DISTANCE_TO => [true, true, true, null], + self::ALIVE => [null, null, null, null], + self::HP_VAL => [true, true, null, null], + self::HP_PCT => [true, true, null, null], + self::REALM_ACHIEVEMENT => [Type::ACHIEVEMENT, null, null, null], + self::IN_WATER => [null, null, null, null], + self::STAND_STATE => [true, true, null, null], + self::DAILY_QUEST_DONE => [Type::QUEST, null, null, null], + self::CHARMED => [null, null, null, null], + self::PET_TYPE => [true, null, null, null], + self::TAXI => [null, null, null, null], + self::QUESTSTATE => [Type::QUEST, true, null, null], + self::QUEST_OBJECTIVE_PROGRESS => [Type::QUEST, true, true, null], + self::DIFFICULTY_ID => [true, null, null, null], + self::GAMEMASTER => [true, null, null, null], + self::STRING_ID => [true, null, null, null] + ); + + private $jsGlobals = []; + private $rows = []; + private $result = []; + private $resultExtra = []; + + + /******/ + /* IN */ + /******/ + + public function getBySource(int|array $type, int|array $group = 0, int|array $entry = 0, int|array $id = 0) : self + { + if ($group) + $group = is_int($group) ? [$group] : array_map('intVal', $group); + if ($entry) + $entry = is_int($entry) ? [$entry] : array_map('intVal', $entry); + if ($id) + $id = is_int($id) ? [$id] : array_map('intVal', $id); + if ($type) + $type = is_int($type) ? [$type] : array_map('intVal', $type); + else + return $this; + + $where = [['`SourceTypeOrReferenceId` IN %in', $type]]; + if ($group) + $where[] = ['`SourceGroup` IN %in', $group]; + if ($entry) + $where[] = ['`SourceEntry` IN %in', $entry]; + if ($id) + $where[] = ['`SourceId` IN %in', $id]; + + $this->rows = array_merge($this->rows, DB::World()->selectAssoc( + 'SELECT `SourceTypeOrReferenceId`, `SourceEntry`, `SourceGroup`, `SourceId`, `ElseGroup`, + `ConditionTypeOrReference`, `ConditionTarget`, `ConditionValue1`, `ConditionValue2`, `ConditionValue3`, `ConditionStringValue1`, `NegativeCondition` + FROM conditions + WHERE %and + ORDER BY `SourceTypeOrReferenceId`, `SourceEntry`, `SourceGroup`, `ElseGroup` ASC', + $where + )); + + return $this; + } + + public function getByCondition(int $type, int $typeId/* , int ...$conditionIds */) : self + { + $lookups = []; // can only be in val1 for now + foreach (self::$conditions as $cId => [$cVal1, , , ]) + if ($type === $cVal1 /* && (!$conditionIds || in_array($cId, $conditionIds)) */ ) + { + if ($cId == self::CHR_CLASS || $cId == self::CHR_RACE) + $lookups[] = [DB::AND, [['c2.`ConditionTypeOrReference` = %i', $cId], ['(c2.`ConditionValue1` & %i) > 0', 1 << ($typeId - 1)]]]; + else + $lookups[] = [DB::AND, [['c2.`ConditionTypeOrReference` = %i', $cId], ['c2.`ConditionValue1` = %i', $typeId]]]; + } + + if (!$lookups) + return $this; + + $this->rows = array_merge($this->rows, DB::World()->selectAssoc( + 'SELECT c1.`SourceTypeOrReferenceId`, c1.`SourceEntry`, c1.`SourceGroup`, c1.`SourceId`, c1.`ElseGroup`, + c1.`ConditionTypeOrReference`, c1.`ConditionTarget`, c1.`ConditionValue1`, c1.`ConditionValue2`, c1.`ConditionValue3`, c1.`ConditionStringValue1`, c1.`NegativeCondition` + FROM conditions c1 + JOIN conditions c2 ON c1.SourceTypeOrReferenceId = c2.SourceTypeOrReferenceId AND c1.SourceEntry = c2.SourceEntry AND c1.SourceGroup = c2.SourceGroup AND c1.SourceId = c2.SourceId + WHERE %or + GROUP BY `SourceTypeOrReferenceId`,`SourceGroup`,`SourceEntry`,`SourceId`,`ElseGroup`,`ConditionTypeOrReference`,`ConditionTarget`,`ConditionValue1`,`ConditionValue2`,`ConditionValue3` + ORDER BY `SourceTypeOrReferenceId`, `SourceEntry`, `SourceGroup`, `ElseGroup` ASC', + $lookups + )); + + return $this; + } + + public function addExternalCondition(int $srcType, string $groupKey, array $condition, bool $orGroup = false) : void + { + if (!isset(self::$source[$srcType])) + return; + + [$cId, $cVal1, $cVal2, $cVal3, $cString] = array_pad(array_pad($condition, 5, 0), 6, ''); + if (!isset(self::$conditions[abs($cId)])) + return; + + while (substr_count($groupKey, ':') < 3) + $groupKey .= ':0'; // pad with missing srcEntry, SrcId, cndTarget to group key + + if (!$this->prepareSource($srcType, ...explode(':', $groupKey))) + return; + + if ($c = $this->prepareCondition($cId, $cVal1, $cVal2, $cVal3, $cString)) + { + if ($orGroup) + $this->result[$srcType][$groupKey][] = [$c]; + else if (!isset($this->result[$srcType][$groupKey][0])) + $this->result[$srcType][$groupKey][0] = [$c]; + else + $this->result[$srcType][$groupKey][0][] = $c; + } + } + + + /*******/ + /* OUT */ + /*******/ + + public function toListviewTab(string $id = 'conditions', string $name = '') : array + { + if (!$this->result) + return []; + + $out = []; + $nCnd = 0; + foreach ($this->result as $srcType => $srcData) + { + foreach ($srcData as $grpKey => $grpData) + { + if (!isset($this->resultExtra[$srcType][$grpKey])) + { + $nCnd++; + $out[$srcType][$grpKey] = $grpData; + } + else + { + $nCnd += count($this->resultExtra[$srcType][$grpKey]); + foreach ($this->resultExtra[$srcType][$grpKey] as $extraGrp) + $out[$srcType][$extraGrp] = $grpData; + } + } + } + + $data = ""; + + $tab = array( + 'data' => $data, + 'id' => $id, + 'name' => ($name ?: '$LANG.tab_conditions') . '+" ('.$nCnd.')"' + ); + + return $tab; + } + + // $keyX params are string(ref to lv column) or int(fixed value) + public function toListviewColumn(array &$lvRows, ?array &$extraCols = [], $keyGroup = 'id', $keyEntry = 0, $keyId = 0) : bool + { + if (!$this->result) + return false; + + $success = false; + foreach ($lvRows as &$row) + { + $srcKey = implode(':', array( + is_string($keyGroup) ? ($row[$keyGroup] ?? 0) : $keyGroup, + is_string($keyEntry) ? ($row[$keyEntry] ?? 0) : $keyEntry, + is_string($keyId) ? ($row[$keyId] ?? 0) : $keyId, + '' // cndTarget - 0 / 1 + )); + + foreach ($this->result as $cndData) + { + if (isset($cndData[$srcKey.'0'])) + { + $row['condition'][self::SRC_NONE][$srcKey.'0'] = $cndData[$srcKey.'0']; + $success = true; + } + + if (isset($cndData[$srcKey.'1'])) + { + $row['condition'][self::SRC_NONE][$srcKey.'1'] = $cndData[$srcKey.'1']; + $success = true; + } + } + } + + if ($success) + $extraCols[] = '$Listview.extraCols.condition'; + + return $success; + } + + public function toMarkupTag() : string + { + if (!$this->result) + return ''; + + return '[condition]' . json_encode($this->result, JSON_NUMERIC_CHECK) . '[/condition]'; + } + + public function getJsGlobals() : array + { + return $this->jsGlobals; + } + + + /*********/ + /* Other */ + /*********/ + + public static function lootTableToConditionSource(string $lootTable) : int + { + return match ($lootTable) + { + Loot::FISHING => self::SRC_FISHING_LOOT_TEMPLATE, + Loot::CREATURE => self::SRC_CREATURE_LOOT_TEMPLATE, + Loot::GAMEOBJECT => self::SRC_GAMEOBJECT_LOOT_TEMPLATE, + Loot::ITEM => self::SRC_ITEM_LOOT_TEMPLATE, + Loot::DISENCHANT => self::SRC_DISENCHANT_LOOT_TEMPLATE, + Loot::PROSPECTING => self::SRC_PROSPECTING_LOOT_TEMPLATE, + Loot::MILLING => self::SRC_MILLING_LOOT_TEMPLATE, + Loot::PICKPOCKET => self::SRC_PICKPOCKETING_LOOT_TEMPLATE, + Loot::SKINNING => self::SRC_SKINNING_LOOT_TEMPLATE, + Loot::MAIL => self::SRC_MAIL_LOOT_TEMPLATE, + Loot::SPELL => self::SRC_SPELL_LOOT_TEMPLATE, + Loot::REFERENCE => self::SRC_REFERENCE_LOOT_TEMPLATE, + default => self::SRC_NONE + }; + } + + public static function extendListviewRow(array &$lvRow, int $srcType, int $groupKey, array $condition) : bool + { + if (!isset(self::$source[$srcType])) + return false; + + [$cId, $cVal1, $cVal2, $cVal3, $cString1] = array_pad(array_pad($condition, 5, 0), 6, ''); + if (!isset(self::$conditions[abs($cId)])) + return false; + + while (substr_count($groupKey, ':') < 3) + $groupKey .= ':0'; // pad with missing srcEntry, SrcId, cndTarget to group key + + if ($c = (new self())->prepareCondition($cId, $cVal1, $cVal2, $cVal3, $cString1)) + $lvRow['condition'][$srcType][$groupKey][] = [$c]; + + return true; + } + + public function prepare() : bool + { + // itr over rows and prep data + if (!$this->rows) + return !empty($this->result); // respect previously added externalCnd + + foreach ($this->rows as $r) + { + if (!isset(self::$source[$r['SourceTypeOrReferenceId']])) + { + trigger_error('Conditions: skipping condition with unknown SourceTypeOrReferenceId #'.$r['SourceTypeOrReferenceId'], E_USER_WARNING); + continue; + } + + if (!isset(self::$conditions[$r['ConditionTypeOrReference']])) + { + trigger_error('Conditions: skipping condition with unknown ConditionTypeOrReference #'.$r['ConditionTypeOrReference'], E_USER_WARNING); + continue; + } + + [$sType, $sGroup, $sEntry, $sId, $cTarget] = $this->prepareSource($r['SourceTypeOrReferenceId'], $r['SourceGroup'], $r['SourceEntry'], $r['SourceId'], $r['ConditionTarget']); + if ($sType === null) + continue; + + $cnd = $this->prepareCondition( + $r['NegativeCondition'] ? -$r['ConditionTypeOrReference'] : $r['ConditionTypeOrReference'], + $r['ConditionValue1'], + $r['ConditionValue2'], + $r['ConditionValue3'], + $r['ConditionStringValue1'] + ); + if (!$cnd) + continue; + + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + $this->result[$r['SourceTypeOrReferenceId']] [$group] [$r['ElseGroup']] [] = $cnd; + } + + return true; + } + + private function prepareSource(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : array + { + // only one entry in array expected + if ($fn = self::$source[$sType][self::IDX_SRC_FN]) + if (!$this->$fn($sType, $sGroup, $sEntry, $sId, $cTarget)) + return [null, null, null, null, null]; + + [$grp, $entry, $id, $_] = self::$source[$sType]; + if (is_int($grp)) + $this->jsGlobals[$grp][$sGroup] = $sGroup; + if (is_int($entry)) + $this->jsGlobals[$entry][$sEntry] = $sEntry; + // Note: sourceId currently has no typed content + // if (is_int($id)) + // $this->jsGlobals[$id][$sId] = $sId; + + // more checks? not all sources can retarget + $cTarget = min(1, max(0, $cTarget)); + + return [$sType, $sGroup, $sEntry, $sId, $cTarget]; + } + + private function prepareCondition($cId, $cVal1, $cVal2, $cVal3, $cString1) : array + { + if ($fn = self::$conditions[abs($cId)][self::IDX_CND_FN]) + if (!$this->$fn(abs($cId), $cVal1, $cVal2, $cVal3, $cString1)) + return []; + + $result = [$cId]; + + for ($i = 0; $i < 3; $i++) + { + $field = self::$conditions[abs($cId)][$i]; + + if (is_int($field)) + $this->jsGlobals[$field][${'cVal'.($i+1)}] = ${'cVal'.($i+1)}; + if ($field) + $result[] = ${'cVal'.($i+1)}; // variable amount of condition values + } + + if ($cString1) + { + $result = array_pad($result, 4, null); + $result[4] = $cString1; + } + + return $result; + } + + private function factionToSide($cndId, &$cVal1, $cVal2, $cVal3, $cString1) : bool + { + if ($cVal1 == 469) + $cVal1 = SIDE_ALLIANCE; + else if ($cVal1 == 67) + $cVal1 = SIDE_HORDE; + else + $cVal1 = SIDE_BOTH; + + return true; + } + + private function mapToZone($cndId, &$cVal1, &$cVal2, $cVal3, $cString1) : bool + { + // use g_zone_categories id + if ($cVal1 == 530) // outland + $cVal1 = 8; + else if ($cVal1 == 571) // northrend + $cVal1 = 10; + else if ($cVal1 == 0 || $cVal1 == 1) // eastern kingdoms / kalimdor + ; // cVal alrady correct - NOP + else if ($id = DB::Aowow()->selectCell('SELECT `id` FROM ::zones WHERE `mapId` = %i AND `parentArea` = 0 AND (`cuFlags` & %i) = 0', $cVal1, CUSTOM_EXCLUDE_FOR_LISTVIEW)) + { + // remap for instanced area - do not use List (pointless overhead) + $this->jsGlobals[Type::ZONE][$id] = $id; + $cVal2 = $id; + $cVal1 = 0; + } + else + { + trigger_error('Conditions - CONDITION_MAPID has invalid mapId #'.$cVal1, E_USER_WARNING); + return false; + } + + return true; + } + + private function maskToBits($cndId, &$cVal1, $cVal2, $cVal3, $cString1) : bool + { + if ($cndId == self::CHR_CLASS) + { + $cVal1 &= ChrClass::MASK_ALL; + foreach (Util::mask2bits($cVal1, 1) as $cId) + $this->jsGlobals[Type::CHR_CLASS][$cId] = $cId; + } + + if ($cndId == self::CHR_RACE) + { + $cVal1 &= ChrRace::MASK_ALL; + foreach (Util::mask2bits($cVal1, 1) as $rId) + $this->jsGlobals[Type::CHR_RACE][$rId] = $rId; + } + + return true; + } + + private function typeidToId($cndId, $cVal1, &$cVal2, &$cVal3, $cString1) : bool + { + if ($cVal1 == self::TYPEID_UNIT) + { + if ($cVal3 && ($_ = DB::Aowow()->selectCell('SELECT `typeId` FROM ::spawns WHERE `type` = %i AND `guid` = %i', Type::NPC, $cVal3))) + $cVal2 = intVal($_); + + if ($cVal2) + $this->jsGlobals[Type::NPC][$cVal2] = $cVal2; + } + else if ($cVal1 == self::TYPEID_GAMEOBJECT) + { + if ($cVal3 && ($_ = DB::Aowow()->selectCell('SELECT `typeId` FROM ::spawns WHERE `type` = %i AND `guid` = %i', Type::OBJECT, $cVal3))) + $cVal2 = intVal($_); + + if ($cVal2) + $this->jsGlobals[Type::OBJECT][$cVal2] = $cVal2; + } + else // Player or Corpse .. no guid + $cVal2 = $cVal3 = 0; + + // maybe prepare other types? + return true; + } + + private function lootIdToNpc(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : bool + { + if (!$sGroup) + { + trigger_error('Conditions::lootToNpc - skipping reference to creature_loot_template entry 0', E_USER_WARNING); + return false; + } + + if ($npcs = DB::Aowow()->selectCol('SELECT `id` FROM ::creature WHERE `lootId` = %i', $sGroup)) + { + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + foreach ($npcs as $npcId) + { + $this->jsGlobals[Type::NPC][$npcId] = $npcId; + $this->resultExtra[$sType][$group][] = $npcId . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + } + + return true; + } + + trigger_error('Conditions::lootToNpc - creature_loot_template #'.$sGroup.' unreferenced?', E_USER_WARNING); + return false; + } + + private function disenchantIdToItem(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : bool + { + if (!$sGroup) + { + trigger_error('Conditions::disenchantIdToItem - skipping reference to disenchant_loot_template entry 0', E_USER_WARNING); + return false; + } + + if ($items = DB::Aowow()->selectCol('SELECT `id` FROM ::items WHERE `disenchantId` = %i', $sGroup)) + { + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + foreach ($items as $itemId) + { + $this->jsGlobals[Type::ITEM][$itemId] = $itemId; + $this->resultExtra[$sType][$group][] = $itemId . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + } + + return true; + } + + trigger_error('Conditions::disenchantIdToItem - disenchant_loot_template #'.$sGroup.' unreferenced?', E_USER_WARNING); + return false; + } + + private function lootIdToGObject(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : bool + { + if (!$sGroup) + { + trigger_error('Conditions::lootIdToGObject - skipping reference to gameobject_loot_template entry 0', E_USER_WARNING); + return false; + } + + if ($gos = DB::Aowow()->selectCol('SELECT `id` FROM ::objects WHERE `lootId` = %i', $sGroup)) + { + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + foreach ($gos as $goId) + { + $this->jsGlobals[Type::OBJECT][$goId] = $goId; + $this->resultExtra[$sType][$group][] = $goId . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + } + + return true; + } + + trigger_error('Conditions::lootIdToGObject - gameobject_loot_template #'.$sGroup.' unreferenced?', E_USER_WARNING); + return false; + } + + private function RewardTemplateToQuest(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : bool + { + if (!$sGroup) + { + trigger_error('Conditions::RewardTemplateToQuest - skipping reference to mail_loot_template entry 0', E_USER_WARNING); + return false; + } + + if ($quests = DB::Aowow()->selectCol('SELECT `id` FROM ::quests WHERE `rewardMailTemplateId` = %i', $sGroup)) + { + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + foreach ($quests as $questId) + { + $this->jsGlobals[Type::QUEST][$questId] = $questId; + $this->resultExtra[$sType][$group][] = $questId . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + } + + return true; + } + + trigger_error('Conditions::RewardTemplateToQuest - mail_loot_template #'.$sGroup.' unreferenced?', E_USER_WARNING); + return false; + } + + private function PickpocketLootToNpc(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : bool + { + if (!$sGroup) + { + trigger_error('Conditions::PickpocketLootToNpc - skipping reference to pickpocketing_loot_template entry 0', E_USER_WARNING); + return false; + } + + if ($npcs = DB::Aowow()->selectCol('SELECT `id` FROM ::creature WHERE `pickpocketLootId` = %i', $sGroup)) + { + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + foreach ($npcs as $npcId) + { + $this->jsGlobals[Type::NPC][$npcId] = $npcId; + $this->resultExtra[$sType][$group][] = $npcId . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + } + + return true; + } + + trigger_error('Conditions::PickpocketLootToNpc - pickpocketing_loot_template #'.$sGroup.' unreferenced?', E_USER_WARNING); + return false; + } + + private function SkinLootToNpc(int $sType, int $sGroup, int $sEntry, int $sId, int $cTarget) : bool + { + if (!$sGroup) + { + trigger_error('Conditions::SkinLootToNpc - skipping reference to skinning_loot_template entry 0', E_USER_WARNING); + return false; + } + + if ($npcs = DB::Aowow()->selectCol('SELECT `id` FROM ::creature WHERE `skinLootId` = %i', $sGroup)) + { + $group = $sGroup . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + foreach ($npcs as $npcId) + { + $this->jsGlobals[Type::NPC][$npcId] = $npcId; + $this->resultExtra[$sType][$group][] = $npcId . ':' . $sEntry . ':' . $sId . ':' . $cTarget; + } + + return true; + } + + trigger_error('Conditions::SkinLootToNpc - skinning_loot_template #'.$sGroup.' unreferenced?', E_USER_WARNING); + return false; + } +} + +?> diff --git a/includes/components/SmartAI/SmartAI.class.php b/includes/components/SmartAI/SmartAI.class.php new file mode 100644 index 00000000..3bf9b55e --- /dev/null +++ b/includes/components/SmartAI/SmartAI.class.php @@ -0,0 +1,837 @@ +selectCell('SELECT `typeId` FROM ::spawns WHERE `type` = %i AND `guid` = %i', $type, $guid)) + return $_; + + trigger_error('SmartAI::resolveGuid - failed to resolve guid '.$guid.' of type '.$type, E_USER_WARNING); + return null; + } + + private function numRange(int $min, int $max, bool $isTime) : string + { + if ($isTime) + return Util::createNumRange($min, $max, ' – ', fn($x) => DateTime::formatTimeElapsedFloat($x)); + + return Util::createNumRange($min, $max, ' – ') ?: 0; + } + + private function formatTime(int $time, int $_, bool $isMilliSec) : string + { + if (!$time) + return ''; + + return DateTime::formatTimeElapsedFloat($time * ($isMilliSec ? 1 : 1000)); + } + + private function castFlags(int $flags) : string + { + if ($x = ($flags & ~SmartAI::CAST_FLAG_VALIDATE)) + { + trigger_error('SmartAI::castFlags - unknown SmartCastFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= SmartAI::CAST_FLAG_VALIDATE; + } + + $cf = []; + for ($i = 1; $i <= SmartAI::CAST_FLAG_COMBAT_MOVE; $i <<= 1) + if (($flags & $i) && ($x = Lang::smartAI('castFlags', $i))) + $cf[] = $x; + + return Lang::concat($cf); + } + + private function npcFlags(int $flags) : string + { + if ($x = ($flags & ~NPC_FLAG_VALIDATE)) + { + trigger_error('SmartAI::npcFlags - unknown NpcFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= NPC_FLAG_VALIDATE; + } + + $nf = []; + for ($i = 1; $i <= NPC_FLAG_MAILBOX; $i <<= 1) + if (($flags & $i) && ($x = Lang::npc('npcFlags', $i))) + $nf[] = $x; + + return Lang::concat($nf ?: [Lang::smartAI('empty')]); + } + + private function dynFlags(int $flags) : string + { + if ($x = ($flags & ~UNIT_DYNFLAG_VALIDATE)) + { + trigger_error('SmartAI::dynFlags - unknown unit dynFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= UNIT_DYNFLAG_VALIDATE; + } + + $df = []; + for ($i = 1; $i <= UNIT_DYNFLAG_TAPPED_BY_ALL_THREAT_LIST; $i <<= 1) + if (($flags & $i) && ($x = Lang::unit('dynFlags', $i))) + $df[] = $x; + + return Lang::concat($df ?: [Lang::smartAI('empty')]); + } + + private function goFlags(int $flags) : string + { + if ($x = ($flags & ~GO_FLAG_VALIDATE)) + { + trigger_error('SmartAI::goFlags - unknown GameobjectFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= GO_FLAG_VALIDATE; + } + + $gf = []; + for ($i = 1; $i <= GO_FLAG_DESTROYED; $i <<= 1) + if (($flags & $i) && ($x = Lang::gameObject('goFlags', $i))) + $gf[] = $x; + + return Lang::concat($gf ?: [Lang::smartAI('empty')]); + } + + private function spawnFlags(int $flags) : string + { + if ($x = ($flags & ~SmartAI::SPAWN_FLAG_VALIDATE)) + { + trigger_error('SmartAI::spawnFlags - unknown SmartSpawnFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= SmartAI::SPAWN_FLAG_VALIDATE; + } + + $sf = []; + for ($i = 1; $i <= SmartAI::SPAWN_FLAG_NOSAVE_RESPAWN; $i <<= 1) + if (($flags & $i) && ($x = Lang::smartAI('spawnFlags', $i))) + $sf[] = $x; + + return Lang::concat($sf ?: [Lang::smartAI('empty')]); + } + + private function unitFlags(int $flags, int $flags2) : string + { + if ($x = ($flags & ~UNIT_FLAG_VALIDATE)) + { + trigger_error('SmartAI::unitFlags - unknown UnitFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= UNIT_FLAG_VALIDATE; + } + + if ($x = ($flags2 & ~UNIT_FLAG2_VALIDATE)) + { + trigger_error('SmartAI::unitFlags - unknown UnitFlags2 '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags2 &= UNIT_FLAG2_VALIDATE; + } + + $field = $flags2 ? 'flags2' : 'flags'; + $max = $flags2 ? UNIT_FLAG2_ALLOW_CHEAT_SPELLS : UNIT_FLAG_UNK_31; + $uf = []; + + for ($i = 1; $i <= $max; $i <<= 1) + if (($flags & $i) && ($x = Lang::unit($field, $i))) + $uf[] = $x; + + return Lang::concat($uf ?: [Lang::smartAI('empty')]); + } + + private function unitFieldBytes1(int $flags, int $idx) : string + { + switch ($idx) + { + case 0: + case 3: + return Lang::unit('bytes1', 'bytesIdx', $idx).Lang::main('colon').(Lang::unit('bytes1', $idx, $flags) ?? Lang::unit('bytes1', 'valueUNK', [$flags, $idx])); + case 2: + $buff = []; + for ($i = 1; $i <= 0x10; $i <<= 1) + if (($flags & $i) && ($x = Lang::unit('bytes1', $idx, $flags))) + $buff[] = $x; + + return Lang::unit('bytes1', 'bytesIdx', $idx).Lang::main('colon').($buff ? Lang::concat($buff) : Lang::unit('bytes1', 'valueUNK', [$flags, $idx])); + default: + return Lang::unit('bytes1', 'idxUNK', [$idx]); + } + } + + private function summonType(int $x) : string + { + return Lang::smartAI('summonTypes', $x) ?? Lang::smartAI('summonType', 'summonTypeUNK', [$x]); + } + + private function sheathState(int $x) : string + { + return Lang::smartAI('sheaths', $x) ?? Lang::smartAI('sheathUNK', [$x]); + } + + private function aiTemplate(int $x) : string + { + return Lang::smartAI('aiTpl', $x) ?? Lang::smartAI('aiTplUNK', [$x]); + } + + private function reactState(int $x) : string + { + return Lang::smartAI('reactStates', $x) ?? Lang::smartAI('reactStateUNK', [$x]); + } + + private function powerType(int $x) : string + { + return Lang::spell('powerTypes', $x) ?? Lang::smartAI('powerTypeUNK', [$x]); + } + + private function hostilityMode(int $x) : string + { + return Lang::smartAI('hostilityModes', $x) ?? Lang::smartAI('hostilityModeUNK', [$x]); + } + + private function motionType(int $x) : string + { + return Lang::smartAI('motionTypes', $x) ?? Lang::smartAI('motionTypeUNK', [$x]); + } + + private function lootState(int $x) : string + { + return Lang::smartAI('lootStates', $x) ?? Lang::smartAI('lootStateUNK', [$x]); + } + private function weatherState(int $x) : string + { + return Lang::smartAI('weatherStates', $x) ?? Lang::smartAI('weatherStateUNK', [$x]); + } + + private function magicSchool(int $x) : string + { + return Lang::getMagicSchools($x); + } +} + +class SmartAI +{ + public const SRC_TYPE_CREATURE = 0; + public const SRC_TYPE_OBJECT = 1; + public const SRC_TYPE_AREATRIGGER = 2; + public const SRC_TYPE_ACTIONLIST = 9; + + public const CAST_FLAG_INTERRUPT_PREV = 0x01; // Interrupt any spell casting + public const CAST_FLAG_TRIGGERED = 0x02; // Triggered (this makes spell cost zero mana and have no cast time) +// public const CAST_FORCE_CAST = 0x04; // Forces cast even if creature is out of mana or out of range +// public const CAST_NO_MELEE_IF_OOM = 0x08; // Prevents creature from entering melee if out of mana or out of range +// public const CAST_FORCE_TARGET_SELF = 0x10; // the target to cast this spell on itself + public const CAST_FLAG_AURA_MISSING = 0x20; // Only casts the spell if the target does not have an aura from the spell + public const CAST_FLAG_COMBAT_MOVE = 0x40; // Prevents combat movement if cast successful. Allows movement on range, OOM, LOS + public const CAST_FLAG_VALIDATE = self::CAST_FLAG_INTERRUPT_PREV | self::CAST_FLAG_TRIGGERED | self::CAST_FLAG_AURA_MISSING | self::CAST_FLAG_COMBAT_MOVE; + + public const REACT_PASSIVE = 0; + public const REACT_DEFENSIVE = 1; + public const REACT_AGGRESSIVE = 2; + public const REACT_ASSIST = 3; + + public const SUMMON_TIMED_OR_DEAD_DESPAWN = 1; + public const SUMMON_TIMED_OR_CORPSE_DESPAWN = 2; + public const SUMMON_TIMED_DESPAWN = 3; + public const SUMMON_TIMED_DESPAWN_OOC = 4; + public const SUMMON_CORPSE_DESPAWN = 5; + public const SUMMON_CORPSE_TIMED_DESPAWN = 6; + public const SUMMON_DEAD_DESPAWN = 7; + public const SUMMON_MANUAL_DESPAWN = 8; + + public const TEMPLATE_BASIC = 0; // + public const TEMPLATE_CASTER = 1; // +JOIN: target_param1 as castFlag + public const TEMPLATE_TURRET = 2; // +JOIN: target_param1 as castflag + public const TEMPLATE_PASSIVE = 3; // + public const TEMPLATE_CAGED_GO_PART = 4; // + public const TEMPLATE_CAGED_NPC_PART = 5; // + + public const SPAWN_FLAG_NONE = 0x00; + public const SPAWN_FLAG_IGNORE_RESPAWN = 0x01; // onSpawnIn - ignore & reset respawn timer + public const SPAWN_FLAG_FORCE_SPAWN = 0x02; // onSpawnIn - force additional spawn if already in world + public const SPAWN_FLAG_NOSAVE_RESPAWN = 0x04; // onDespawn - remove respawn time + public const SPAWN_FLAG_VALIDATE = self::SPAWN_FLAG_IGNORE_RESPAWN | self::SPAWN_FLAG_FORCE_SPAWN | self::SPAWN_FLAG_NOSAVE_RESPAWN; + + private array $jsGlobals = []; + private array $rawData = []; + private array $result = []; + private array $tabs = []; + private array $itr = []; + private array $quotes = []; + + public string $css = <<baseEntry = $miscData['baseEntry'] ?? 0; + $this->title = $miscData['title'] ?? ''; + $this->teleportTargetArea = $miscData['teleportTargetArea'] ?? 0; + + if ($this->baseEntry) // my parent handles base css + $this->css = ''; + + $raw = DB::World()->selectAssoc( + 'SELECT `id`, `link`, + `event_type`, `event_param1`, `event_param2`, `event_param3`, `event_param4`, `event_param5`, `event_phase_mask`, `event_chance`, `event_flags`, + `action_type`, `action_param1`, `action_param2`, `action_param3`, `action_param4`, `action_param5`, `action_param6`, + `target_type`, `target_param1`, `target_param2`, `target_param3`, `target_param4`, `target_x`, `target_y`, `target_z`, `target_o` + FROM smart_scripts + WHERE `entryorguid` = %i AND `source_type` = %i + ORDER BY `id` ASC', + $this->entry, $this->srcType); + + foreach ($raw as $r) + { + $this->rawData[$r['id']] = array( + 'id' => $r['id'], + 'link' => $r['link'], + 'event' => new SmartEvent($r['id'], $r['event_type'], $r['event_phase_mask'], $r['event_chance'], $r['event_flags'], [$r['event_param1'], $r['event_param2'], $r['event_param3'], $r['event_param4'], $r['event_param5']], $this), + 'action' => new SmartAction($r['id'], $r['action_type'], [$r['action_param1'], $r['action_param2'], $r['action_param3'], $r['action_param4'], $r['action_param5'], $r['action_param6']], $this), + 'target' => new SmartTarget($r['id'], $r['target_type'], [$r['target_param1'], $r['target_param2'], $r['target_param3'], $r['target_param4']], [$r['target_x'], $r['target_y'], $r['target_z'], $r['target_o']], $this), + 'condition' => (new Conditions())->getBySource(Conditions::SRC_SMART_EVENT, $r['id'] + 1, $entry, $srcType) + ); + } + } + + + /*********************/ + /* Lookups by action */ + /*********************/ + + public static function getOwnerOfNPCSummon(int $npcId, int $typeFilter = 0) : array + { + if ($npcId <= 0) + return []; + + $lookup = array( + SmartAction::ACTION_SUMMON_CREATURE => [1 => $npcId], + SmartAction::ACTION_MOUNT_TO_ENTRY_OR_MODEL => [1 => $npcId] + ); + + if ($npcGuids = DB::Aowow()->selectCol('SELECT `guid` FROM ::spawns WHERE `type` = %i AND `typeId` = %i', Type::NPC, $npcId)) + if ($groups = DB::World()->selectCol('SELECT `groupId` FROM spawn_group WHERE `spawnType` = 0 AND `spawnId` IN %in', $npcGuids)) + foreach ($groups as $g) + $lookup[SmartAction::ACTION_SPAWN_SPAWNGROUP][1] = $g; + + $result = self::getActionOwner($lookup, $typeFilter); + + // can skip lookups for SmartAction::ACTION_SUMMON_CREATURE_GROUP as creature_summon_groups already contains summoner info + if ($sgs = DB::World()->selectAssoc('SELECT `summonerType` AS "0", `summonerId` AS "1" FROM creature_summon_groups WHERE `entry` = %i', $npcId)) + foreach ($sgs as [$type, $typeId]) + $result[$type][] = $typeId; + + return $result; + } + + public static function getOwnerOfObjectSummon(int $objectId, int $typeFilter = 0) : array + { + if ($objectId <= 0) + return []; + + $lookup = array( + SmartAction::ACTION_SUMMON_GO => [1 => $objectId] + ); + + if ($objGuids = DB::Aowow()->selectCol('SELECT `guid` FROM ::spawns WHERE `type` = %i AND `typeId` = %i', Type::OBJECT, $objectId)) + if ($groups = DB::World()->selectCol('SELECT `groupId` FROM spawn_group WHERE `spawnType` = 1 AND `spawnId` IN %in', $objGuids)) + foreach ($groups as $g) + $lookup[SmartAction::ACTION_SPAWN_SPAWNGROUP][1] = $g; + + return self::getActionOwner($lookup, $typeFilter); + } + + public static function getOwnerOfSpellCast(int $spellId, int $typeFilter = 0) : array + { + if ($spellId <= 0) + return []; + + $lookup = array( + SmartAction::ACTION_CAST => [1 => $spellId], + SmartAction::ACTION_ADD_AURA => [1 => $spellId], + SmartAction::ACTION_SELF_CAST => [1 => $spellId], + SmartAction::ACTION_CROSS_CAST => [1 => $spellId], + SmartAction::ACTION_INVOKER_CAST => [1 => $spellId] + ); + + return self::getActionOwner($lookup, $typeFilter); + } + + public static function getOwnerOfSoundPlayed(int $soundId, int $typeFilter = 0) : array + { + if ($soundId <= 0) + return []; + + $lookup = array( + SmartAction::ACTION_SOUND => [1 => $soundId] + ); + + return self::getActionOwner($lookup, $typeFilter); + } + + // lookup: SmartActionId => [[paramIdx => value], ...] + private static function getActionOwner(array $lookup, int $typeFilter = 0) : array + { + $qParts = []; + $result = []; + $genFilter = $talFilter = []; + switch ($typeFilter) + { + case Type::NPC: + $genFilter = [self::SRC_TYPE_CREATURE, self::SRC_TYPE_ACTIONLIST]; + $talFilter = [self::SRC_TYPE_CREATURE]; + break; + case Type::OBJECT: + $genFilter = [self::SRC_TYPE_OBJECT, self::SRC_TYPE_ACTIONLIST]; + $talFilter = [self::SRC_TYPE_OBJECT]; + break; + case Type::AREATRIGGER: + $genFilter = [self::SRC_TYPE_AREATRIGGER, self::SRC_TYPE_ACTIONLIST]; + $talFilter = [self::SRC_TYPE_AREATRIGGER]; + break; + } + + $where = $qParts = []; + foreach ($lookup as $action => $params) + { + $pq = []; + $aq = [DB::AND, [['`action_type` = %i', $action], [DB::OR, &$pq]]]; + foreach ($params as $idx => $p) + $pq[] = ["`action_param$idx` = %i", $p]; + + $qParts[] = $aq; + unset($pq); + } + + if ($genFilter) + $where[] = ['`source_type` IN %in', $genFilter]; + if ($qParts) + $where[] = [DB::OR, $qParts]; + + $smartS = DB::World()->selectAssoc('SELECT `source_type` AS "0", `entryOrGUID` AS "1" FROM smart_scripts WHERE %and', $where ?: [0]); + + // filter for TAL shenanigans + if ($smartTAL = array_filter($smartS, fn($x) => $x[0] == self::SRC_TYPE_ACTIONLIST)) + { + $smartS = array_diff_key($smartS, $smartTAL); + + $q = $where = []; + foreach ($smartTAL as [, $eog]) + { + // SmartAction::ACTION_CALL_TIMED_ACTIONLIST + $q[] = [DB::AND, array( + ['`action_type` = %i', SmartAction::ACTION_CALL_TIMED_ACTIONLIST], + ['`action_param1` = %i', $eog] + )]; + + // SmartAction::ACTION_CALL_RANDOM_TIMED_ACTIONLIST + $q[] = [DB::AND, array( + ['`action_type` = %i', SmartAction::ACTION_CALL_RANDOM_TIMED_ACTIONLIST], + ['`action_param1` = %i', $eog], + ['`action_param2` = %i', $eog], + ['`action_param3` = %i', $eog], + ['`action_param4` = %i', $eog], + ['`action_param5` = %i', $eog] + )]; + + // SmartAction::ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST + $q[] = [DB::AND, array( + ['`action_type` = %i', SmartAction::ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST], + ['%i BETWEEN `action_param1` AND `action_param2`', $eog] + )]; + } + + if ($talFilter) + $where[] = ['`source_type` IN %in', $talFilter]; + if ($q) + $where[] = [DB::OR, $q]; + + if ($_ = DB::World()->selectAssoc('SELECT `source_type` AS "0", `entryOrGUID` AS "1" FROM smart_scripts WHERE %and', $where ?: [0])) + $smartS = array_merge($smartS, $_); + } + + // filter guids for entries + if ($smartG = array_filter($smartS, fn($x) => $x[1] < 0)) + { + $smartS = array_diff_key($smartS, $smartG); + + $where = []; + foreach ($smartG as [$st, $eog]) + { + if ($st == self::SRC_TYPE_CREATURE) + $where[] = [DB::AND, [['`type` = %i', Type::NPC], ['`guid` = %i', -$eog]]]; + else if ($st == self::SRC_TYPE_OBJECT) + $where[] = [DB::AND, [['`type` = %i', Type::OBJECT], ['`guid` = %i', -$eog]]]; + } + + if ($where) + { + $owner = DB::Aowow()->selectAssoc('SELECT `type`, `typeId` FROM ::spawns WHERE %or', $where); + foreach ($owner as $o) + $result[$o['type']][] = $o['typeId']; + } + } + + foreach ($smartS as [$st, $eog]) + { + if ($st == self::SRC_TYPE_CREATURE) + $result[Type::NPC][] = $eog; + else if ($st == self::SRC_TYPE_OBJECT) + $result[Type::OBJECT][] = $eog; + else if ($st == self::SRC_TYPE_AREATRIGGER) + $result[Type::AREATRIGGER][] = $eog; + } + + return $result; + } + + + /********************/ + /* Lookups by owner */ + /********************/ + + public static function getNPCSummonsForOwner(int $entry, int $srcType = self::SRC_TYPE_CREATURE) : array + { + // action => paramIdx with npcIds/spawnGoupIds + $lookup = array( + SmartAction::ACTION_SUMMON_CREATURE => [1], + SmartAction::ACTION_MOUNT_TO_ENTRY_OR_MODEL => [1], + SmartAction::ACTION_SPAWN_SPAWNGROUP => [1] + ); + + $result = self::getOwnerAction($srcType, $entry, $lookup, $moreInfo); + + // can skip lookups for SmartAction::ACTION_SUMMON_CREATURE_GROUP as creature_summon_groups already contains summoner info + if ($srcType == self::SRC_TYPE_CREATURE || $srcType == self::SRC_TYPE_OBJECT) + { + $st = $srcType == self::SRC_TYPE_CREATURE ? SUMMONER_TYPE_CREATURE : SUMMONER_TYPE_GAMEOBJECT; + if ($csg = DB::World()->selectCol('SELECT `entry` FROM creature_summon_groups WHERE `summonerType` = %i AND `summonerId` = %i', $st, $entry)) + $result = array_merge($result, $csg); + } + + if (!empty($moreInfo[SmartAction::ACTION_SPAWN_SPAWNGROUP])) + { + $grp = $moreInfo[SmartAction::ACTION_SPAWN_SPAWNGROUP]; + if ($sgs = DB::World()->selectCol('SELECT `spawnId` FROM spawn_group WHERE `spawnType` = %i AND `groupId` IN %in', SUMMONER_TYPE_CREATURE, $grp)) + if ($ids = DB::Aowow()->selectCol('SELECT DISTINCT `typeId` FROM ::spawns WHERE `type` = %i AND `guid` IN %in', Type::NPC, $sgs)) + $result = array_merge($result, $ids); + } + + return $result; + } + + public static function getObjectSummonsForOwner(int $entry, int $srcType = self::SRC_TYPE_CREATURE) : array + { + // action => paramIdx with gobIds/spawnGoupIds + $lookup = array( + SmartAction::ACTION_SUMMON_GO => [1], + SmartAction::ACTION_SPAWN_SPAWNGROUP => [1] + ); + + $result = self::getOwnerAction($srcType, $entry, $lookup, $moreInfo); + + if (!empty($moreInfo[SmartAction::ACTION_SPAWN_SPAWNGROUP])) + { + $grp = $moreInfo[SmartAction::ACTION_SPAWN_SPAWNGROUP]; + if ($sgs = DB::World()->selectCol('SELECT `spawnId` FROM spawn_group WHERE `spawnType` = %i AND `groupId` IN %in', SUMMONER_TYPE_GAMEOBJECT, $grp)) + if ($ids = DB::Aowow()->selectCol('SELECT DISTINCT `typeId` FROM ::spawns WHERE `type` = %i AND `guid` IN %in', Type::OBJECT, $sgs)) + $result = array_merge($result, $ids); + } + + return $result; + } + + public static function getSpellCastsForOwner(int $entry, int $srcType = self::SRC_TYPE_CREATURE) : array + { + // action => paramIdx with spellIds + $lookup = array( + SmartAction::ACTION_CAST => [1], + SmartAction::ACTION_ADD_AURA => [1], + SmartAction::ACTION_INVOKER_CAST => [1], + SmartAction::ACTION_CROSS_CAST => [1] + ); + + return self::getOwnerAction($srcType, $entry, $lookup); + } + + public static function getSoundsPlayedForOwner(int $entry, int $srcType = self::SRC_TYPE_CREATURE) : array + { + // action => paramIdx with soundIds + $lookup = array( + SmartAction::ACTION_SOUND => [1] + ); + + return self::getOwnerAction($srcType, $entry, $lookup); + } + + // lookup: [SmartActionId => [paramIdx, ...], ...] + private static function getOwnerAction(int $sourceType, int $entry, array $lookup, ?array &$moreInfo = []) : array + { + if ($entry < 0) // no lookup by GUID + return []; + + $actionQuery = 'SELECT `action_type`, `action_param1`, `action_param2`, `action_param3`, `action_param4`, `action_param5`, `action_param6` FROM smart_scripts WHERE `source_type` = %i AND `action_type` IN %in AND `entryOrGUID` IN %in'; + + $smartScripts = DB::World()->selectAssoc($actionQuery, $sourceType, array_merge(array_keys($lookup), SmartAction::ACTION_ALL_TIMED_ACTION_LISTS), [$entry]); + $smartResults = []; + $smartTALs = []; + foreach ($smartScripts as $s) + { + if ($s['action_type'] == SmartAction::ACTION_SPAWN_SPAWNGROUP) + $moreInfo[SmartAction::ACTION_SPAWN_SPAWNGROUP][] = $s['action_param1']; + else if (in_array($s['action_type'], array_keys($lookup))) + { + foreach ($lookup[$s['action_type']] as $p) + $smartResults[] = $s['action_param'.$p]; + } + else if ($s['action_type'] == SmartAction::ACTION_CALL_TIMED_ACTIONLIST) + $smartTALs[] = $s['action_param1']; + else if ($s['action_type'] == SmartAction::ACTION_CALL_RANDOM_TIMED_ACTIONLIST) + { + for ($i = 1; $i < 7; $i++) + if ($s['action_param'.$i]) + $smartTALs[] = $s['action_param'.$i]; + } + else if ($s['action_type'] == SmartAction::ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST) + { + for ($i = $s['action_param1']; $i <= $s['action_param2']; $i++) + $smartTALs[] = $i; + } + } + + if ($smartTALs) + { + if ($TALActList = DB::World()->selectAssoc($actionQuery, self::SRC_TYPE_ACTIONLIST, array_keys($lookup), $smartTALs)) + { + foreach ($TALActList as $e) + { + foreach ($lookup[$e['action_type']] as $i) + { + if ($e['action_type'] == SmartAction::ACTION_SPAWN_SPAWNGROUP) + $moreInfo[SmartAction::ACTION_SPAWN_SPAWNGROUP][] = $e['action_param'.$i]; + else + $smartResults[] = $e['action_param'.$i]; + } + } + } + } + + return $smartResults; + } + + + /******************************/ + /* Structured Lisview Display */ + /******************************/ + + private function &iterate() : \Generator + { + reset($this->rawData); + + foreach ($this->rawData as $k => $__) + { + $this->itr = &$this->rawData[$k]; + + yield $this->itr; + } + } + + public function prepare() : bool + { + if (!$this->rawData) + return false; + + if ($this->result) + return true; + + $visibleCols = (1 << 0) | (1 << 2) | (1 << 4); + + foreach ($this->iterate() as $__) + { + $rowIdx = Util::createHash(8); + + if ($this->itr['action']->type == SmartAction::ACTION_TALK || $this->itr['action']->type == SmartAction::ACTION_SIMPLE_TALK) + if ($ts = $this->itr['target']->getTalkSource()) + $this->initQuotes($ts); + + [$evtBody, $evtFooter] = $this->itr['event']->process(); + [$actBody, $actFooter] = $this->itr['action']->process(); + + $evtBody = str_replace(['#target#', '#rowIdx#'], [$this->itr['target']->process(), $rowIdx], $evtBody); + $actBody = str_replace(['#target#', '#rowIdx#'], [$this->itr['target']->process(), $rowIdx], $actBody); + + if ($this->itr['event']->hasPhases()) + $visibleCols |= (1 << 1); + + if ($this->itr['event']->chance != 100) + $visibleCols |= (1 << 3); + + if ($this->itr['condition']->prepare()) + { + $visibleCols |= (1 << 5); + Util::mergeJsGlobals($this->jsGlobals, $this->itr['condition']->getJsGlobals()); + } + + $this->result[] = array( + $this->itr['id'], + implode(', ', Util::mask2bits($this->itr['event']->phaseMask, 1)), + $evtBody.($evtFooter ? '[div float=right margin=0px clear=both width=100% align=right][i][small class=q0]'.$evtFooter.'[/small][/i][/div]' : ''), + $this->itr['event']->chance.'%', + $actBody.($actFooter ? '[div float=right margin=0px clear=both width=100% align=right][i][small class=q0]'.$actFooter.'[/small][/i][/div]' : ''), + $this->itr['condition']->toMarkupTag() + ); + } + + $th = array( + ['#' , '24px'], + ['Phase', '48px'], + ['Event', '30%%'], + ['Chance', '60px'], + ['Action', 'auto'], + ['Condition', 'auto'] + ); + + for ($i = 0, $j = count($th); $i < $j; $i++) + { + if ($visibleCols & (1 << $i)) + continue; + + unset($th[$i]); + foreach ($this->result as &$r) + unset($r[$i]); + + unset($r); + } + + $tblId = Util::createHash(12); + + $this->css .= "\n#tbl-".$tblId." { grid-template-columns: ".implode(' ', array_column($th, 1))."; }"; + + $tbl = '[tr]' . array_reduce(array_column($th, 0), fn($out, $n) => $out .= '[td header]'.$n.'[/td]', '') . '[/tr]'; + + foreach ($this->result as $r) + $tbl .= '[tr][td]'.implode('[/td][td]', $r).'[/td][/tr]'; + + $tbl = '[table id=tbl-'.$tblId.' class=grid]'.$tbl.'[/table]'; + + if ($this->srcType == self::SRC_TYPE_ACTIONLIST) + $this->tabs[$this->entry] = $tbl; + else + $this->tabs[0] = $tbl; + + return true; + } + + public function getMarkup() : ?Markup + { + # id | event (footer phase) | chance | action + target + + if (!$this->rawData) + return null; + + $wrapper = '%s'; + $return = '[style]'.strtr($this->css, "\n", ' ').'[/style][pad][h3][toggler id=sai]SmartAI'.$this->title.'[/toggler][/h3][div id=sai clear=left]%s[/div]'; + $tabs = ''; + if (count($this->tabs) > 1) + { + $wrapper = '[tabs name=sai]%s[/tabs]'; + $return = "[script]function TalTabClick(id) { $('#dsf67g4d-sai').find('[href=\'#sai-actionlist-' + id + '\']').click(); }[/script]" . $return; + foreach ($this->tabs as $guid => $data) + { + $buff = '[tab name="'.($guid ? 'ActionList #'.$guid : 'Main').'"]'.$data.'[/tab]'; + if ($guid) + $tabs .= $buff; + else + $tabs = $buff . $tabs; + } + } + + return new Markup(sprintf($return, sprintf($wrapper, $tabs ?: $this->tabs[0])), ['allow' => Markup::CLASS_ADMIN], 'smartai-generic'); + } + + public function addJsGlobals(array $jsg) : void + { + Util::mergeJsGlobals($this->jsGlobals, $jsg); + } + + public function getJSGlobals() : array + { + return $this->jsGlobals; + } + + public function getTabs() : array + { + return $this->tabs; + } + + public function addTab(int $guid, string $tt) : void + { + $this->tabs[$guid] = $tt; + } + + public function getTarget(int $id = -1) : ?SmartTarget + { + if ($id < 0) + return $this->itr['target']; + + return $this->rawData[$id]['target'] ?? null; + } + + public function getAction(int $id = -1) : ?SmartAction + { + if ($id < 0) + return $this->itr['action']; + + return $this->rawData[$id]['action'] ?? null; + } + + public function getEvent(int $id = -1) : ?SmartEvent + { + if ($id < 0) + return $this->itr['event']; + + return $this->rawData[$id]['event'] ?? null; + } + + public function getEntry() : int + { + return $this->baseEntry ?: $this->entry; + } + + private function initQuotes(int $creatureId) : void + { + if (isset($this->quotes[$creatureId])) + return; + + [$quotes, , ] = Game::getQuotesForCreature($creatureId); + + $this->quotes[$creatureId] = $quotes; + + if (!empty($this->quotes[$creatureId])) + $this->quotes[$creatureId]['src'] = CreatureList::getName($creatureId); + } + + public function getQuote(int $creatureId, int $group, ?string &$npcSrc) : array + { + if (isset($this->quotes[$creatureId][$group])) + { + $npcSrc = $this->quotes[$creatureId]['src']; + return $this->quotes[$creatureId][$group]; + } + + return []; + } +} + +?> diff --git a/includes/components/SmartAI/SmartAction.class.php b/includes/components/SmartAI/SmartAction.class.php new file mode 100644 index 00000000..05a354eb --- /dev/null +++ b/includes/components/SmartAI/SmartAction.class.php @@ -0,0 +1,760 @@ + [null, null, null, null, null, null, 0], // No action + self::ACTION_TALK => [null, ['formatTime', -1, true], null, null, null, null, 0], // groupID from creature_text, duration to wait before TEXT_OVER event is triggered, useTalkTarget (0/1) - use target as talk target + self::ACTION_SET_FACTION => [null, null, null, null, null, null, 0], // FactionId (or 0 for default) + self::ACTION_MORPH_TO_ENTRY_OR_MODEL => [Type::NPC, null, null, null, null, null, 0], // Creature_template entry(param1) OR ModelId (param2) (or 0 for both to demorph) + self::ACTION_SOUND => [Type::SOUND, null, null, null, null, null, 0], // SoundId, onlySelf + self::ACTION_PLAY_EMOTE => [null, null, null, null, null, null, 0], // EmoteId + self::ACTION_FAIL_QUEST => [Type::QUEST, null, null, null, null, null, 0], // QuestID + self::ACTION_OFFER_QUEST => [Type::QUEST, null, null, null, null, null, 0], // QuestID, directAdd + self::ACTION_SET_REACT_STATE => [['reactState', 10, false], null, null, null, null, null, 0], // state + self::ACTION_ACTIVATE_GOBJECT => [null, null, null, null, null, null, 0], // + self::ACTION_RANDOM_EMOTE => [null, null, null, null, null, null, 0], // EmoteId1, EmoteId2, EmoteId3... + self::ACTION_CAST => [Type::SPELL, ['castFlags', -1, false], null, null, null, null, 0], // SpellId, CastFlags, TriggeredFlags + self::ACTION_SUMMON_CREATURE => [Type::NPC, ['summonType', -1, false], ['formatTime', 10, true], null, null, null, 0], // CreatureID, summonType, duration in ms, attackInvoker, flags(SmartActionSummonCreatureFlags) + self::ACTION_THREAT_SINGLE_PCT => [null, null, null, null, null, null, 0], // Threat% + self::ACTION_THREAT_ALL_PCT => [null, null, null, null, null, null, 0], // Threat% + self::ACTION_CALL_AREAEXPLOREDOREVENTHAPPENS => [Type::QUEST, null, null, null, null, null, 0], // QuestID + self::ACTION_SET_INGAME_PHASE_ID => [null, null, null, null, null, null, 2], // used on 4.3.4 and higher scripts + self::ACTION_SET_EMOTE_STATE => [null, null, null, null, null, null, 0], // emoteID + self::ACTION_SET_UNIT_FLAG => [['unitFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_REMOVE_UNIT_FLAG => [['unitFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_AUTO_ATTACK => [null, null, null, null, null, null, 0], // AllowAttackState (0 = stop attack, anything else means continue attacking) + self::ACTION_ALLOW_COMBAT_MOVEMENT => [null, null, null, null, null, null, 0], // AllowCombatMovement (0 = stop combat based movement, anything else continue attacking) + self::ACTION_SET_EVENT_PHASE => [null, null, null, null, null, null, 0], // Phase + self::ACTION_INC_EVENT_PHASE => [null, null, null, null, null, null, 0], // Value (may be negative to decrement phase, should not be 0) + self::ACTION_EVADE => [null, null, null, null, null, null, 0], // toRespawnPosition (0 = Move to RespawnPosition, 1 = Move to last stored home position) + self::ACTION_FLEE_FOR_ASSIST => [null, null, null, null, null, null, 0], // With Emote + self::ACTION_CALL_GROUPEVENTHAPPENS => [Type::QUEST, null, null, null, null, null, 0], // QuestID + self::ACTION_COMBAT_STOP => [null, null, null, null, null, null, 0], // + self::ACTION_REMOVEAURASFROMSPELL => [Type::SPELL, null, null, null, null, null, 0], // Spellid (0 removes all auras), charges (0 removes aura) + self::ACTION_FOLLOW => [null, null, null, null, null, null, 0], // Distance (0 = default), Angle (0 = default), EndCreatureEntry, credit, creditType (0monsterkill, 1event) + self::ACTION_RANDOM_PHASE => [null, null, null, null, null, null, 0], // PhaseId1, PhaseId2, PhaseId3... + self::ACTION_RANDOM_PHASE_RANGE => [null, null, null, null, null, null, 0], // PhaseMin, PhaseMax + self::ACTION_RESET_GOBJECT => [null, null, null, null, null, null, 0], // + self::ACTION_CALL_KILLEDMONSTER => [Type::NPC, null, null, null, null, null, 0], // CreatureId, + self::ACTION_SET_INST_DATA => [null, null, null, null, null, null, 0], // Field, Data, Type (0 = SetData, 1 = SetBossState) + self::ACTION_SET_INST_DATA64 => [null, null, null, null, null, null, 0], // Field, + self::ACTION_UPDATE_TEMPLATE => [Type::NPC, null, null, null, null, null, 0], // Entry + self::ACTION_DIE => [null, null, null, null, null, null, 0], // No Params + self::ACTION_SET_IN_COMBAT_WITH_ZONE => [null, null, null, null, null, null, 0], // No Params + self::ACTION_CALL_FOR_HELP => [null, null, null, null, null, null, 0], // Radius, With Emote + self::ACTION_SET_SHEATH => [['sheathState', 10, false], null, null, null, null, null, 0], // Sheath (0-unarmed, 1-melee, 2-ranged) + self::ACTION_FORCE_DESPAWN => [['formatTime', 10, true], ['formatTime', 11, false], null, null, null, null, 0], // timer + self::ACTION_SET_INVINCIBILITY_HP_LEVEL => [null, null, null, null, null, null, 0], // MinHpValue(+pct, -flat) + self::ACTION_MOUNT_TO_ENTRY_OR_MODEL => [Type::NPC, null, null, null, null, null, 0], // Creature_template entry(param1) OR ModelId (param2) (or 0 for both to dismount) + self::ACTION_SET_INGAME_PHASE_MASK => [null, null, null, null, null, null, 0], // mask + self::ACTION_SET_DATA => [null, null, null, null, null, null, 0], // Field, Data (only creature @todo) + self::ACTION_ATTACK_STOP => [null, null, null, null, null, null, 0], // + self::ACTION_SET_VISIBILITY => [null, null, null, null, null, null, 0], // on/off + self::ACTION_SET_ACTIVE => [null, null, null, null, null, null, 0], // on/off + self::ACTION_ATTACK_START => [null, null, null, null, null, null, 0], // + self::ACTION_SUMMON_GO => [Type::OBJECT, ['formatTime', 10, false], null, null, null, null, 0], // GameObjectID, DespawnTime in s + self::ACTION_KILL_UNIT => [null, null, null, null, null, null, 0], // + self::ACTION_ACTIVATE_TAXI => [null, null, null, null, null, null, 0], // TaxiID + self::ACTION_WP_START => [null, null, null, Type::QUEST, ['formatTime', 10, true], ['reactState', 11, false], 0], // run/walk, pathID, canRepeat, quest, despawntime + self::ACTION_WP_PAUSE => [['formatTime', 10, true], null, null, null, null, null, 0], // time + self::ACTION_WP_STOP => [['formatTime', 10, true], Type::QUEST, null, null, null, null, 0], // despawnTime, quest, fail? + self::ACTION_ADD_ITEM => [Type::ITEM, null, null, null, null, null, 0], // itemID, count + self::ACTION_REMOVE_ITEM => [Type::ITEM, null, null, null, null, null, 0], // itemID, count + self::ACTION_INSTALL_AI_TEMPLATE => [['aiTemplate', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_SET_RUN => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_SET_DISABLE_GRAVITY => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_SET_SWIM => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_TELEPORT => [null, null, null, null, null, null, 0], // mapID, + self::ACTION_SET_COUNTER => [null, null, null, null, null, null, 0], // id, value, reset (0/1) + self::ACTION_STORE_TARGET_LIST => [null, null, null, null, null, null, 0], // varID, + self::ACTION_WP_RESUME => [null, null, null, null, null, null, 0], // none + self::ACTION_SET_ORIENTATION => [null, null, null, null, null, null, 0], // + self::ACTION_CREATE_TIMED_EVENT => [null, ['numRange', 10, true], null, ['numRange', -1, true], null, null, 0], // id, InitialMin, InitialMax, RepeatMin(only if it repeats), RepeatMax(only if it repeats), chance + self::ACTION_PLAYMOVIE => [null, null, null, null, null, null, 0], // entry + self::ACTION_MOVE_TO_POS => [null, null, null, null, null, null, 0], // PointId, transport, disablePathfinding, ContactDistance + self::ACTION_ENABLE_TEMP_GOBJ => [['formatTime', 10, false], null, null, null, null, null, 0], // despawnTimer (sec) + self::ACTION_EQUIP => [null, null, Type::ITEM, Type::ITEM, Type::ITEM, null, 0], // entry, slotmask slot1, slot2, slot3 , only slots with mask set will be sent to client, bits are 1, 2, 4, leaving mask 0 is defaulted to mask 7 (send all), slots1-3 are only used if no entry is set + self::ACTION_CLOSE_GOSSIP => [null, null, null, null, null, null, 0], // none + self::ACTION_TRIGGER_TIMED_EVENT => [null, null, null, null, null, null, 0], // id(>1) + self::ACTION_REMOVE_TIMED_EVENT => [null, null, null, null, null, null, 0], // id(>1) + self::ACTION_ADD_AURA => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_OVERRIDE_SCRIPT_BASE_OBJECT => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_RESET_SCRIPT_BASE_OBJECT => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_CALL_SCRIPT_RESET => [null, null, null, null, null, null, 0], // none + self::ACTION_SET_RANGED_MOVEMENT => [null, null, null, null, null, null, 0], // Distance, angle + self::ACTION_CALL_TIMED_ACTIONLIST => [null, null, null, null, null, null, 0], // ID (overwrites already running actionlist), stop after combat?(0/1), timer update type(0-OOC, 1-IC, 2-ALWAYS) + self::ACTION_SET_NPC_FLAG => [['npcFlags', 10, false], null, null, null, null, null, 0], // Flags + self::ACTION_ADD_NPC_FLAG => [['npcFlags', 10, false], null, null, null, null, null, 0], // Flags + self::ACTION_REMOVE_NPC_FLAG => [['npcFlags', 10, false], null, null, null, null, null, 0], // Flags + self::ACTION_SIMPLE_TALK => [null, null, null, null, null, null, 0], // groupID, can be used to make players say groupID, Text_over event is not triggered, whisper can not be used (Target units will say the text) + self::ACTION_SELF_CAST => [Type::SPELL, ['castFlags', -1, false], null, null, null, null, 0], // spellID, castFlags + self::ACTION_CROSS_CAST => [Type::SPELL, ['castFlags', -1, false], null, null, null, null, 0], // spellID, castFlags, CasterTargetType, CasterTarget param1, CasterTarget param2, CasterTarget param3, ( + the origonal target fields as Destination target), CasterTargets will cast spellID on all Targets (use with caution if targeting multiple * multiple units) + self::ACTION_CALL_RANDOM_TIMED_ACTIONLIST => [null, null, null, null, null, null, 0], // script9 ids 1-9 + self::ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST => [null, null, null, null, null, null, 0], // script9 id min, max + self::ACTION_RANDOM_MOVE => [null, null, null, null, null, null, 0], // maxDist + self::ACTION_SET_UNIT_FIELD_BYTES_1 => [['unitFieldBytes1', 10, false], null, null, null, null, null, 0], // bytes, target + self::ACTION_REMOVE_UNIT_FIELD_BYTES_1 => [['unitFieldBytes1', 10, false], null, null, null, null, null, 0], // bytes, target + self::ACTION_INTERRUPT_SPELL => [null, Type::SPELL, null, null, null, null, 0], // + self::ACTION_SEND_GO_CUSTOM_ANIM => [['dynFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_SET_DYNAMIC_FLAG => [['dynFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_ADD_DYNAMIC_FLAG => [['dynFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_REMOVE_DYNAMIC_FLAG => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_JUMP_TO_POS => [null, null, null, null, null, null, 0], // speedXY, speedZ, targetX, targetY, targetZ + self::ACTION_SEND_GOSSIP_MENU => [null, null, null, null, null, null, 0], // menuId, optionId + self::ACTION_GO_SET_LOOT_STATE => [['lootState', 10, false], null, null, null, null, null, 0], // state + self::ACTION_SEND_TARGET_TO_TARGET => [null, null, null, null, null, null, 0], // id + self::ACTION_SET_HOME_POS => [null, null, null, null, null, null, 0], // none + self::ACTION_SET_HEALTH_REGEN => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_SET_ROOT => [null, null, null, null, null, null, 0], // off/on + self::ACTION_SET_GO_FLAG => [['goFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_ADD_GO_FLAG => [['goFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_REMOVE_GO_FLAG => [['goFlags', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_SUMMON_CREATURE_GROUP => [null, null, null, null, null, null, 0], // Group, attackInvoker + self::ACTION_SET_POWER => [['powerType', 10, false], null, null, null, null, null, 0], // PowerType, newPower + self::ACTION_ADD_POWER => [['powerType', 10, false], null, null, null, null, null, 0], // PowerType, newPower + self::ACTION_REMOVE_POWER => [['powerType', 10, false], null, null, null, null, null, 0], // PowerType, newPower + self::ACTION_GAME_EVENT_STOP => [Type::WORLDEVENT, null, null, null, null, null, 0], // GameEventId + self::ACTION_GAME_EVENT_START => [Type::WORLDEVENT, null, null, null, null, null, 0], // GameEventId + self::ACTION_START_CLOSEST_WAYPOINT => [null, null, null, null, null, null, 0], // wp1, wp2, wp3, wp4, wp5, wp6, wp7 + self::ACTION_MOVE_OFFSET => [null, null, null, null, null, null, 0], // + self::ACTION_RANDOM_SOUND => [Type::SOUND, Type::SOUND, Type::SOUND, Type::SOUND, null, null, 0], // soundId1, soundId2, soundId3, soundId4, soundId5, onlySelf + self::ACTION_SET_CORPSE_DELAY => [['formatTime', 10, false], null, null, null, null, null, 0], // timer + self::ACTION_DISABLE_EVADE => [null, null, null, null, null, null, 0], // 0/1 (1 = disabled, 0 = enabled) + self::ACTION_GO_SET_GO_STATE => [null, null, null, null, null, null, 0], // state + self::ACTION_SET_CAN_FLY => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_REMOVE_AURAS_BY_TYPE => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_SET_SIGHT_DIST => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_FLEE => [['formatTime', 10, false], null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_ADD_THREAT => [null, null, null, null, null, null, 0], // +threat, -threat + self::ACTION_LOAD_EQUIPMENT => [null, null, null, null, null, null, 0], // id + self::ACTION_TRIGGER_RANDOM_TIMED_EVENT => [['numRange', 10, false], null, null, null, null, null, 0], // id min range, id max range + self::ACTION_REMOVE_ALL_GAMEOBJECTS => [null, null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::ACTION_PAUSE_MOVEMENT => [null, ['formatTime', 10, true], null, null, null, null, 0], // MovementSlot (default = 0, active = 1, controlled = 2), PauseTime (ms), Force + self::ACTION_PLAY_ANIMKIT => [null, null, null, null, null, null, 2], // don't use on 3.3.5a + self::ACTION_SCENE_PLAY => [null, null, null, null, null, null, 2], // don't use on 3.3.5a + self::ACTION_SCENE_CANCEL => [null, null, null, null, null, null, 2], // don't use on 3.3.5a + self::ACTION_SPAWN_SPAWNGROUP => [null, null, null, ['spawnFlags', 11, false], null, null, 0], // Group ID, min secs, max secs, spawnflags + self::ACTION_DESPAWN_SPAWNGROUP => [null, null, null, ['spawnFlags', 11, false], null, null, 0], // Group ID, min secs, max secs, spawnflags + self::ACTION_RESPAWN_BY_SPAWNID => [null, null, null, null, null, null, 0], // spawnType, spawnId + self::ACTION_INVOKER_CAST => [Type::SPELL, ['castFlags', -1, false], null, null, null, null, 0], // spellID, castFlags + self::ACTION_PLAY_CINEMATIC => [null, null, null, null, null, null, 0], // entry, cinematic + self::ACTION_SET_MOVEMENT_SPEED => [null, null, null, null, null, null, 0], // movementType, speedInteger, speedFraction + self::ACTION_PLAY_SPELL_VISUAL_KIT => [null, null, null, null, null, null, 2], // spellVisualKitId (RESERVED, PENDING CHERRYPICK) + self::ACTION_OVERRIDE_LIGHT => [Type::ZONE, null, null, ['formatTime', -1, true], null, null, 0], // zoneId, overrideLightID, transitionMilliseconds + self::ACTION_OVERRIDE_WEATHER => [Type::ZONE, ['weatherState', 10, false], null, null, null, null, 0], // zoneId, weatherId, intensity + self::ACTION_SET_AI_ANIM_KIT => [null, null, null, null, null, null, 2], // DEPRECATED, DO REUSE (it was never used in any branch, treat as free action id) + self::ACTION_SET_HOVER => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_SET_HEALTH_PCT => [null, null, null, null, null, null, 0], // percent + self::ACTION_CREATE_CONVERSATION => [null, null, null, null, null, null, 2], // don't use on 3.3.5a + self::ACTION_SET_IMMUNE_PC => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_SET_IMMUNE_NPC => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_SET_UNINTERACTIBLE => [null, null, null, null, null, null, 0], // 0/1 + self::ACTION_ACTIVATE_GAMEOBJECT => [null, null, null, null, null, null, 0], // GameObjectActions + self::ACTION_ADD_TO_STORED_TARGET_LIST => [null, null, null, null, null, null, 0], // varID + self::ACTION_BECOME_PERSONAL_CLONE_FOR_PLAYER => [null, null, null, null, null, null, 2], // don't use on 3.3.5a + self::ACTION_TRIGGER_GAME_EVENT => [null, null, null, null, null, null, 2], // eventId, useSaiTargetAsGameEventSource (RESERVED, PENDING CHERRYPICK) + self::ACTION_DO_ACTION => [null, null, null, null, null, null, 2] // actionId (RESERVED, PENDING CHERRYPICK) + ); + + private array $jsGlobals = []; + private ?array $summons = null; + + public function __construct( + private int $id, + public readonly int $type, + private array $param, + private SmartAI &$smartAI) + { + // init additional parameters + Util::checkNumeric($this->param, NUM_CAST_INT); + $this->param = array_pad($this->param, 15, ''); + } + + public function process() : array + { + $body = + $footer = ''; + + $actionTT = Lang::smartAI('actionTT', array_merge([$this->type], $this->param)); + + for ($i = 0; $i < 5; $i++) + { + $aParams = $this->data[$this->type]; + + if (is_array($aParams[$i])) + { + [$fn, $idx, $extraParam] = $aParams[$i]; + + if ($idx < 0) + $footer = $this->{$fn}($this->param[$i], $this->param[$i + 1], $extraParam); + else + $this->param[$idx] = $this->{$fn}($this->param[$i], $this->param[$i + 1], $extraParam); + } + else if (is_int($aParams[$i]) && $this->param[$i]) + $this->jsGlobals[$aParams[$i]][$this->param[$i]] = $this->param[$i]; + } + + // non-generic cases + switch ($this->type) + { + case self::ACTION_FLEE_FOR_ASSIST: // 25 -> none + case self::ACTION_CALL_FOR_HELP: // 39 -> self + if ($this->param[0]) + $footer = $this->param; + break; + case self::ACTION_INTERRUPT_SPELL: // 92 -> self + if (!$this->param[1]) + $footer = $this->param; + break; + case self::ACTION_UPDATE_TEMPLATE: // 36 + case self::ACTION_SET_CORPSE_DELAY: // 116 + if ($this->param[1]) + $footer = $this->param; + break; + case self::ACTION_PAUSE_MOVEMENT: // 127 -> any target [ye, not gonna resolve this nonsense] + case self::ACTION_REMOVEAURASFROMSPELL: // 28 -> any target + case self::ACTION_SOUND: // 4 -> self [param3 set in DB but not used in core?] + case self::ACTION_SUMMON_GO: // 50 -> self, world coords + case self::ACTION_MOVE_TO_POS: // 69 -> any target + if ($this->param[2]) + $footer = $this->param; + break; + case self::ACTION_WP_START: // 53 -> any .. why tho? + if ($this->param[2] || $this->param[5]) + $footer = $this->param; + break; + case self::ACTION_PLAY_EMOTE: // 5 -> any target + case self::ACTION_SET_EMOTE_STATE: // 17 -> any target + if ($this->param[0]) + { + $this->param[0] *= -1; // handle creature emote + $this->jsGlobals[Type::EMOTE][$this->param[0]] = $this->param[0]; + } + break; + case self::ACTION_RANDOM_EMOTE: // 10 -> any target + $buff = []; + for ($i = 0; $i < 6; $i++) + { + if (empty($this->param[$i])) + continue; + + $this->param[$i] *= -1; // handle creature emote + $buff[] = '[emote='.$this->param[$i].']'; + $this->jsGlobals[Type::EMOTE][$this->param[$i]] = $this->param[$i]; + } + $this->param[10] = Lang::concat($buff, Lang::CONCAT_OR); + break; + case self::ACTION_SET_FACTION: // 2 -> any target + if ($this->param[0]) + { + $this->param[10] = DB::Aowow()->selectCell('SELECT `factionId` FROM ::factiontemplate WHERE `id` = %i', $this->param[0]); + $this->jsGlobals[Type::FACTION][$this->param[10]] = $this->param[10]; + } + break; + case self::ACTION_MORPH_TO_ENTRY_OR_MODEL: // 3 -> self + case self::ACTION_MOUNT_TO_ENTRY_OR_MODEL: // 43 -> self + if (!$this->param[0] && !$this->param[1]) + $this->param[10] = 1; + break; + case self::ACTION_THREAT_SINGLE_PCT: // 13 -> victim + case self::ACTION_THREAT_ALL_PCT: // 14 -> self + case self::ACTION_ADD_THREAT: // 123 -> any target + $this->param[10] = $this->param[0] - $this->param[1]; + break; + case self::ACTION_FOLLOW: // 29 -> any target + if ($this->param[1]) + { + $this->param[10] = Util::O2Deg($this->param[1])[0]; + $footer = $this->param; + } + if ($this->param[3]) + { + if ($this->param[4]) + { + $this->jsGlobals[Type::QUEST][$this->param[3]] = $this->param[3]; + $this->param[11] = 1; + } + else + { + $this->jsGlobals[Type::NPC][$this->param[3]] = $this->param[3]; + $this->param[12] = 1; + } + } + break; + case self::ACTION_RANDOM_PHASE: // 30 -> self + $buff = []; + for ($i = 0; $i < 7; $i++) + if ($_ = $this->param[$i]) + $buff[] = $_; + + $this->param[10] = Lang::concat($buff); + break; + case self::ACTION_ACTIVATE_TAXI: // 52 -> invoker + $nodes = DB::Aowow()->selectRow( + 'SELECT tn1.`name_loc0` AS "start_loc0", tn1.name_loc%i AS start_loc%i, tn2.`name_loc0` AS "end_loc0", tn2.name_loc%i AS end_loc%i + FROM ::taxipath tp + JOIN ::taxinodes tn1 ON tp.`startNodeId` = tn1.`id` + JOIN ::taxinodes tn2 ON tp.`endNodeId` = tn2.`id` + WHERE tp.`id` = %i', + Lang::getLocale()->value, Lang::getLocale()->value, Lang::getLocale()->value, Lang::getLocale()->value, $this->param[0] + ); + $this->param[10] = Util::localizedString($nodes, 'start'); + $this->param[11] = Util::localizedString($nodes, 'end'); + break; + case self::ACTION_SET_INGAME_PHASE_MASK: // 44 -> any target + if ($this->param[0]) + $this->param[10] = Lang::concat(Util::mask2bits($this->param[0])); + break; + case self::ACTION_TELEPORT: // 62 -> invoker + [$x, $y, $z, $o] = $this->smartAI->getTarget()->getWorldPos(); + // try from areatrigger setup data + if ($this->smartAI->teleportTargetArea) + $this->param[10] = $this->smartAI->teleportTargetArea; + // try calc from SmartTarget data + else if ($pos = WorldPosition::toZonePos($this->param[0], $x, $y)) + { + $this->param[10] = $pos[0]['areaId']; + $this->param[11] = str_pad($pos[0]['posX'] * 10, 3, '0', STR_PAD_LEFT).str_pad($pos[0]['posY'] * 10, 3, '0', STR_PAD_LEFT); + } + // maybe the mapId is an instane map + else if ($areaId = DB::Aowow()->selectCell('SELECT `id` FROM ::zones WHERE `mapId` = %i', $this->param[0])) + $this->param[10] = $areaId; + // ...whelp + else + trigger_error('SmartAction::process - could not resolve teleport target: map:'.$this->param[0].' x:'.$x.' y:'.$y); + + if ($this->param[10]) + $this->jsGlobals[Type::ZONE][$this->param[10]] = $this->param[10]; + break; + case self::ACTION_SET_ORIENTATION: // 66 -> any target + if ($this->smartAI->getTarget()->type == SmartTarget::TARGET_POSITION) + $this->param[10] = Util::O2Deg($this->smartAI->getTarget()->getWorldPos()[3])[1]; + else if ($this->smartAI->getTarget()->type != SmartTarget::TARGET_SELF) + $this->param[10] = '#target#'; + break; + case self::ACTION_EQUIP: // 71 -> any + $equip = []; + + if ($this->param[0]) + { + $slots = $this->param[1] ? Util::mask2bits($this->param[1], 1) : [1, 2, 3]; + $items = DB::World()->selectRow('SELECT `ItemID1`, `ItemID2`, `ItemID3` FROM creature_equip_template WHERE `CreatureID` = %i AND `ID` = %i', $this->smartAI->getEntry(), $this->param[0]); + + foreach ($slots as $s) + if ($_ = $items['ItemID'.$s]) + $equip[] = $_; + } + else if ($this->param[2] || $this->param[3] || $this->param[4]) + { + if ($_ = $this->param[2]) + $equip[] = $_; + if ($_ = $this->param[3]) + $equip[] = $_; + if ($_ = $this->param[4]) + $equip[] = $_; + } + + if ($equip) + { + $this->param[10] = Lang::concat($equip, callback: fn($x) => '[item='.$x.']'); + $footer = true; + + foreach ($equip as $_) + $this->jsGlobals[Type::ITEM][$_] = $_; + } + break; + case self::ACTION_LOAD_EQUIPMENT: // 124 -> any target + $buff = []; + if ($this->param[0]) + { + $items = DB::World()->selectRow('SELECT `ItemID1`, `ItemID2`, `ItemID3` FROM creature_equip_template WHERE `CreatureID` = %i AND `ID` = %i', $this->smartAI->getEntry(), $this->param[0]); + foreach ($items as $i) + { + if (!$i) + continue; + + $this->jsGlobals[Type::ITEM][$i] = $i; + $buff[] = '[item='.$i.']'; + } + } + else if (!$this->param[1]) + trigger_error('SmartAI::action - action #124 (SmartAction::ACTION_LOAD_EQIPMENT) is malformed'); + + $this->param[10] = Lang::concat($buff); + $footer = true; + break; + case self::ACTION_CALL_TIMED_ACTIONLIST: // 80 -> any target + $this->param[10] = match ($this->param[1]) + { + 0, 1, 2 => Lang::smartAI('saiUpdate', $this->param[1]), + default => Lang::smartAI('saiUpdateUNK', [$this->param[1]]) + }; + + $tal = new SmartAI(SmartAI::SRC_TYPE_ACTIONLIST, $this->param[0], ['baseEntry' => $this->smartAI->getEntry()]); + $tal->prepare(); + + $this->smartAI->css .= $tal->css; + + Util::mergeJsGlobals($this->jsGlobals, $tal->getJSGlobals()); + + foreach ($tal->getTabs() as $guid => $tt) + $this->smartAI->addTab($guid, $tt); + + break; + case self::ACTION_CALL_KILLEDMONSTER: // 33: Note: If target is SMART_TARGET_NONE (0) or SMART_TARGET_SELF (1), the kill is credited to all players eligible for loot from this creature. + if ($this->smartAI->getTarget()->type == SmartTarget::TARGET_SELF || $this->smartAI->getTarget()->type == SmartTarget::TARGET_NONE) + $this->param[10] = (new SmartTarget($this->id, SmartTarget::TARGET_LOOT_RECIPIENTS, [], [], $this->smartAI))->process(); + break; + case self::ACTION_CROSS_CAST: // 86 -> entity by TargetingBlock(param3, param4, param5, param6) cross cast spell at any target + $this->param[10] = (new SmartTarget($this->id, $this->param[2], [$this->param[3], $this->param[4], $this->param[5]], [], $this->smartAI))->process(); + break; + case self::ACTION_CALL_RANDOM_TIMED_ACTIONLIST: // 87 -> self + $talBuff = []; + for ($i = 0; $i < 6; $i++) + { + if (!$this->param[$i]) + continue; + + $talBuff[] = sprintf(self::TAL_TAB_ANCHOR, $this->param[$i]); + + $tal = new SmartAI(SmartAI::SRC_TYPE_ACTIONLIST, $this->param[$i], ['baseEntry' => $this->smartAI->getEntry()]); + $tal->prepare(); + + $this->smartAI->css .= $tal->css; + + Util::mergeJsGlobals($this->jsGlobals, $tal->getJSGlobals()); + + foreach ($tal->getTabs() as $guid => $tt) + $this->smartAI->addTab($guid, $tt); + } + $this->param[10] = Lang::concat($talBuff, Lang::CONCAT_OR); + break; + case self::ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST:// 88 -> self + $talBuff = []; + for ($i = $this->param[0]; $i <= $this->param[1]; $i++) + { + $talBuff[] = sprintf(self::TAL_TAB_ANCHOR, $i); + + $tal = new SmartAI(SmartAI::SRC_TYPE_ACTIONLIST, $i, ['baseEntry' => $this->smartAI->getEntry()]); + $tal->prepare(); + + $this->smartAI->css .= $tal->css; + + Util::mergeJsGlobals($this->jsGlobals, $tal->getJSGlobals()); + + foreach ($tal->getTabs() as $guid => $tt) + $this->smartAI->addTab($guid, $tt); + } + $this->param[10] = Lang::concat($talBuff, Lang::CONCAT_OR); + break; + case self::ACTION_SET_HOME_POS: // 101 -> self + if ($this->smartAI->getTarget()?->type == Smarttarget::TARGET_SELF) + $this->param[10] = 1; + // do not break; + case self::ACTION_JUMP_TO_POS: // 97 -> self + case self::ACTION_MOVE_OFFSET: // 114 -> self + array_splice($this->param, 11, replacement: $this->smartAI->getTarget()->getWorldPos()); + break; + case self::ACTION_SUMMON_CREATURE_GROUP: // 107 -> untargeted + if ($this->summons === null) + $this->summons = DB::World()->selectCol('SELECT `groupId` AS ARRAY_KEY, `entry` AS ARRAY_KEY2, COUNT(*) AS "n" FROM creature_summon_groups WHERE `summonerId` = %i GROUP BY `groupId`, `entry`', $this->smartAI->getEntry()); + + $buff = []; + if (!empty($this->summons[$this->param[0]])) + { + foreach ($this->summons[$this->param[0]] as $id => $n) + { + $this->jsGlobals[Type::NPC][$id] = $id; + $buff[] = $n.'x [npc='.$id.']'; + } + } + + if ($buff) + $this->param[10] = Lang::concat($buff); + break; + case self::ACTION_START_CLOSEST_WAYPOINT: // 113 -> any target + $this->param[10] = Lang::concat(array_filter($this->param), Lang::CONCAT_OR, fn($x) => '#[b]'.$x.'[/b]'); + break; + case self::ACTION_RANDOM_SOUND: // 115 -> self + for ($i = 0; $i < 4; $i++) + { + if ($x = $this->param[$i]) + { + $this->jsGlobals[Type::SOUND][$x] = $x; + $this->param[10] .= '[sound='.$x.']'; + } + } + + if ($this->param[5]) + $footer = true; + break; + case self::ACTION_GO_SET_GO_STATE: // 118 -> ??? + $this->param[10] = match ($this->param[0]) + { + 0, 1, 2 => Lang::smartAI('GOStates', $this->param[0]), + default => Lang::smartAI('GOStateUNK', [$this->param[0]]) + }; + break; + case self::ACTION_REMOVE_AURAS_BY_TYPE: // 120 -> any target + $this->param[10] = Lang::spell('auras', $this->param[0]); + break; + case self::ACTION_SPAWN_SPAWNGROUP: // 131 + case self::ACTION_DESPAWN_SPAWNGROUP: // 132 + $this->param[10] = Util::jsEscape(DB::World()->selectCell('SELECT `GroupName` FROM spawn_group_template WHERE `groupId` = %i', $this->param[0])); + $entities = DB::World()->selectAssoc('SELECT `spawnType` AS "0", `spawnId` AS "1" FROM spawn_group WHERE `groupId` = %i', $this->param[0]); + + $n = 5; + $buff = []; + foreach ($entities as [$spawnType, $guid]) + { + $type = Type::NPC; + if ($spawnType == 1) + $type == Type::OBJECT; + + if ($_ = $this->resolveGuid($type, $guid)) + { + $this->jsGlobals[$type][$_] = $_; + $buff[] = '['.Type::getFileString($type).'='.$_.'][small class=q0] (GUID: '.$guid.')[/small]'; + } + else + $buff[] = Lang::smartAI('entityUNK').'[small class=q0] (GUID: '.$guid.')[/small]'; + + if (!--$n) + break; + } + + if (count($entities) > 5) + $buff[] = '+'.(count($entities) - 5).'…'; + + $this->param[12] = '[ul][li]'.implode('[/li][li]', $buff).'[/li][/ul]'; + + // i'd like this stored in $data but numRange can only handle msec + if ($time = $this->numRange($this->param[1] * 1000, $this->param[2] * 1000, true)) + $footer = [$time]; + break; + case self::ACTION_RESPAWN_BY_SPAWNID: // 133 + $type = Type::NPC; + if ($this->param[0] == 1) + $type == Type::OBJECT; + + if ($_ = $this->resolveGuid($type, $this->param[1])) + { + $this->param[10] = '['.Type::getFileString($type).'='.$_.']'; + $this->jsGlobals[$type][$_] = $_; + } + else + $this->param[10] = Lang::smartAI('entityUNK'); + break; + case self::ACTION_SET_MOVEMENT_SPEED: // 136 + $this->param[10] = $this->param[1] + $this->param[2] / pow(10, floor(log10($this->param[2] ?: 1.0) + 1)); // i know string concatenation is a thing. don't @ me! + break; + case self::ACTION_TALK: // 1 -> any target + $talkTarget = $this->param[2]; + case self::ACTION_SIMPLE_TALK: // 84 -> any target + $playerSrc = false; + if ($npcId = $this->smartAI->getTarget()->getTalkSource($playerSrc)) + { + if ($quotes = $this->smartAI->getQuote($npcId, $this->param[0], $npcSrc)) + { + foreach ($quotes as ['text' => $text]) + { + $talkTarget = ($talkTarget ?? true) ? Lang::game('target') : $npcSrc; + $this->param[10] .= sprintf($text, $playerSrc ? Lang::main('thePlayer') : $npcSrc, $talkTarget); + } + } + } + else + trigger_error('SmartAI::action - could not determine talk source for action #'.$this->type); + break; + } + + $this->smartAI->addJsGlobals($this->jsGlobals); + + $body = Lang::smartAI('actions', $this->type, 0, $this->param) ?? Lang::smartAI('actionUNK', [$this->type]); + if ($footer) + $footer = Lang::smartAI('actions', $this->type, 1, (array)$footer); + + // resolve conditionals + $i = 0; + while (strstr($body, ')?') && $i++ < 3) + $body = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):(([^;]*);*);/i', fn($m) => $m[1] ? $m[2] : $m[3], $body); + + $i = 0; + while (strstr($footer, ')?') && $i++ < 3) + $footer = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):(([^;]*);*);/i', fn($m) => $m[1] ? $m[2] : $m[3], $footer); + + // wrap body in tooltip + return [sprintf(self::ACTION_CELL_TPL, $actionTT, $body), $footer]; + } +} + +?> diff --git a/includes/components/SmartAI/SmartEvent.class.php b/includes/components/SmartAI/SmartEvent.class.php new file mode 100644 index 00000000..5a1f0316 --- /dev/null +++ b/includes/components/SmartAI/SmartEvent.class.php @@ -0,0 +1,395 @@ + 0: type, array: [fn, newIdx, extraParam]; error class: int + self::EVENT_UPDATE_IC => [['numRange', 10, true], null, ['numRange', -1, true], null, null, 0], // InitialMin, InitialMax, RepeatMin, RepeatMax + self::EVENT_UPDATE_OOC => [['numRange', 10, true], null, ['numRange', -1, true], null, null, 0], // InitialMin, InitialMax, RepeatMin, RepeatMax + self::EVENT_HEALTH_PCT => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // HPMin%, HPMax%, RepeatMin, RepeatMax + self::EVENT_MANA_PCT => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // ManaMin%, ManaMax%, RepeatMin, RepeatMax + self::EVENT_AGGRO => [null, null, null, null, null, 0], // NONE + self::EVENT_KILL => [['numRange', -1, true], null, null, Type::NPC, null, 0], // CooldownMin0, CooldownMax1, playerOnly2, else creature entry3 + self::EVENT_DEATH => [null, null, null, null, null, 0], // NONE + self::EVENT_EVADE => [null, null, null, null, null, 0], // NONE + self::EVENT_SPELLHIT => [Type::SPELL, ['magicSchool', 10, false], ['numRange', -1, true], null, null, 0], // SpellID, School, CooldownMin, CooldownMax + self::EVENT_RANGE => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // MinDist, MaxDist, RepeatMin, RepeatMax + self::EVENT_OOC_LOS => [['hostilityMode', 10, false], null, ['numRange', -1, true], null, null, 0], // hostilityModes, MaxRange, CooldownMin, CooldownMax + self::EVENT_RESPAWN => [null, null, Type::ZONE, null, null, 0], // type, MapId, ZoneId + self::EVENT_TARGET_HEALTH_PCT => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_VICTIM_CASTING => [['numRange', -1, true], null, Type::SPELL, null, null, 0], // RepeatMin, RepeatMax, spellid + self::EVENT_FRIENDLY_HEALTH => [null, null, ['numRange', -1, true], null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_FRIENDLY_IS_CC => [null, ['numRange', -1, true], null, null, null, 0], // Radius, RepeatMin, RepeatMax + self::EVENT_FRIENDLY_MISSING_BUFF => [Type::SPELL, null, ['numRange', -1, true], null, null, 0], // SpellId, Radius, RepeatMin, RepeatMax + self::EVENT_SUMMONED_UNIT => [Type::NPC, ['numRange', -1, true], null, null, null, 0], // CreatureId(0 all), CooldownMin, CooldownMax + self::EVENT_TARGET_MANA_PCT => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_ACCEPTED_QUEST => [Type::QUEST, ['numRange', -1, true], null, null, null, 0], // QuestID (0 = any), CooldownMin, CooldownMax + self::EVENT_REWARD_QUEST => [Type::QUEST, ['numRange', -1, true], null, null, null, 0], // QuestID (0 = any), CooldownMin, CooldownMax + self::EVENT_REACHED_HOME => [null, null, null, null, null, 0], // NONE + self::EVENT_RECEIVE_EMOTE => [Type::EMOTE, ['numRange', -1, true], null, null, null, 0], // EmoteId, CooldownMin, CooldownMax, condition, val1, val2, val3 + self::EVENT_HAS_AURA => [Type::SPELL, null, ['numRange', -1, true], null, null, 0], // Param1 = SpellID, Param2 = Stack amount, Param3/4 RepeatMin, RepeatMax + self::EVENT_TARGET_BUFFED => [Type::SPELL, null, ['numRange', -1, true], null, null, 0], // Param1 = SpellID, Param2 = Stack amount, Param3/4 RepeatMin, RepeatMax + self::EVENT_RESET => [null, null, null, null, null, 0], // Called after combat, when the creature respawn and spawn. + self::EVENT_IC_LOS => [['hostilityMode', 10, false], null, ['numRange', -1, true], null, null, 0], // hostilityModes, MaxRnage, CooldownMin, CooldownMax + self::EVENT_PASSENGER_BOARDED => [['numRange', -1, true], null, null, null, null, 0], // CooldownMin, CooldownMax + self::EVENT_PASSENGER_REMOVED => [['numRange', -1, true], null, null, null, null, 0], // CooldownMin, CooldownMax + self::EVENT_CHARMED => [null, null, null, null, null, 0], // onRemove (0 - on apply, 1 - on remove) + self::EVENT_CHARMED_TARGET => [null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_SPELLHIT_TARGET => [Type::SPELL, ['magicSchool', 10, false], ['numRange', -1, true], null, null, 0], // SpellID, School, CooldownMin, CooldownMax + self::EVENT_DAMAGED => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // MinDmg, MaxDmg, CooldownMin, CooldownMax + self::EVENT_DAMAGED_TARGET => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // MinDmg, MaxDmg, CooldownMin, CooldownMax + self::EVENT_MOVEMENTINFORM => [['motionType', 10, false], null, null, null, null, 0], // MovementType(any), PointID + self::EVENT_SUMMON_DESPAWNED => [Type::NPC, ['numRange', -1, true], null, null, null, 0], // Entry, CooldownMin, CooldownMax + self::EVENT_CORPSE_REMOVED => [null, null, null, null, null, 0], // NONE + self::EVENT_AI_INIT => [null, null, null, null, null, 0], // NONE + self::EVENT_DATA_SET => [null, null, ['numRange', -1, true], null, null, 0], // Id, Value, CooldownMin, CooldownMax + self::EVENT_WAYPOINT_START => [null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_WAYPOINT_REACHED => [null, null, null, null, null, 0], // PointId(0any), pathID(0any) + self::EVENT_TRANSPORT_ADDPLAYER => [null, null, null, null, null, 2], // NONE + self::EVENT_TRANSPORT_ADDCREATURE => [null, null, null, null, null, 2], // Entry (0 any) + self::EVENT_TRANSPORT_REMOVE_PLAYER => [null, null, null, null, null, 2], // NONE + self::EVENT_TRANSPORT_RELOCATE => [null, null, null, null, null, 2], // PointId + self::EVENT_INSTANCE_PLAYER_ENTER => [null, null, null, null, null, 2], // Team (0 any), CooldownMin, CooldownMax + self::EVENT_AREATRIGGER_ONTRIGGER => [Type::AREATRIGGER, null, null, null, null, 0], // TriggerId(0 any) + self::EVENT_QUEST_ACCEPTED => [null, null, null, null, null, 2], // none + self::EVENT_QUEST_OBJ_COMPLETION => [null, null, null, null, null, 2], // none + self::EVENT_QUEST_COMPLETION => [null, null, null, null, null, 2], // none + self::EVENT_QUEST_REWARDED => [null, null, null, null, null, 2], // none + self::EVENT_QUEST_FAIL => [null, null, null, null, null, 2], // none + self::EVENT_TEXT_OVER => [null, Type::NPC, null, null, null, 0], // GroupId from creature_text, creature entry who talks (0 any) + self::EVENT_RECEIVE_HEAL => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // MinHeal, MaxHeal, CooldownMin, CooldownMax + self::EVENT_JUST_SUMMONED => [null, null, null, null, null, 0], // none + self::EVENT_WAYPOINT_PAUSED => [null, null, null, null, null, 0], // PointId(0any), pathID(0any) + self::EVENT_WAYPOINT_RESUMED => [null, null, null, null, null, 0], // PointId(0any), pathID(0any) + self::EVENT_WAYPOINT_STOPPED => [null, null, null, null, null, 0], // PointId(0any), pathID(0any) + self::EVENT_WAYPOINT_ENDED => [null, null, null, null, null, 0], // PointId(0any), pathID(0any) + self::EVENT_TIMED_EVENT_TRIGGERED => [null, null, null, null, null, 0], // id + self::EVENT_UPDATE => [['numRange', 10, true], null, ['numRange', -1, true], null, null, 0], // InitialMin, InitialMax, RepeatMin, RepeatMax + self::EVENT_LINK => [null, null, null, null, null, 0], // INTERNAL USAGE, no params, used to link together multiple events, does not use any extra resources to iterate event lists needlessly + self::EVENT_GOSSIP_SELECT => [null, null, null, null, null, 0], // menuID, actionID + self::EVENT_JUST_CREATED => [null, null, null, null, null, 0], // none + self::EVENT_GOSSIP_HELLO => [null, null, null, null, null, 0], // noReportUse (for GOs) + self::EVENT_FOLLOW_COMPLETED => [null, null, null, null, null, 0], // none + self::EVENT_EVENT_PHASE_CHANGE => [null, null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_IS_BEHIND_TARGET => [['numRange', -1, true], null, null, null, null, 1], // UNUSED, DO NOT REUSE + self::EVENT_GAME_EVENT_START => [Type::WORLDEVENT, null, null, null, null, 0], // game_event.Entry + self::EVENT_GAME_EVENT_END => [Type::WORLDEVENT, null, null, null, null, 0], // game_event.Entry + self::EVENT_GO_LOOT_STATE_CHANGED => [['lootState', 10, false], null, null, null, null, 0], // go LootState + self::EVENT_GO_EVENT_INFORM => [null, null, null, null, null, 0], // eventId + self::EVENT_ACTION_DONE => [null, null, null, null, null, 0], // eventId (SharedDefines.EventId) + self::EVENT_ON_SPELLCLICK => [null, null, null, null, null, 0], // clicker (unit) + self::EVENT_FRIENDLY_HEALTH_PCT => [['numRange', 10, false], null, ['numRange', -1, true], null, null, 0], // minHpPct, maxHpPct, repeatMin, repeatMax + self::EVENT_DISTANCE_CREATURE => [null, Type::NPC, null, ['numRange', -1, true], null, 0], // guid, entry, distance, repeat + self::EVENT_DISTANCE_GAMEOBJECT => [null, Type::OBJECT, null, ['numRange', -1, true], null, 0], // guid, entry, distance, repeat + self::EVENT_COUNTER_SET => [null, null, ['numRange', -1, true], null, null, 0], // id, value, cooldownMin, cooldownMax + self::EVENT_SCENE_START => [null, null, null, null, null, 2], // don't use on 3.3.5a + self::EVENT_SCENE_TRIGGER => [null, null, null, null, null, 2], // don't use on 3.3.5a + self::EVENT_SCENE_CANCEL => [null, null, null, null, null, 2], // don't use on 3.3.5a + self::EVENT_SCENE_COMPLETE => [null, null, null, null, null, 2], // don't use on 3.3.5a + self::EVENT_SUMMONED_UNIT_DIES => [Type::NPC, ['numRange', -1, true], null, null, null, 0], // CreatureId(0 all), CooldownMin, CooldownMax + self::EVENT_ON_SPELL_CAST => [Type::SPELL, ['numRange', -1, true], null, null, null, 0], // SpellID, CooldownMin, CooldownMax + self::EVENT_ON_SPELL_FAILED => [Type::SPELL, ['numRange', -1, true], null, null, null, 0], // SpellID, CooldownMin, CooldownMax + self::EVENT_ON_SPELL_START => [Type::SPELL, ['numRange', -1, true], null, null, null, 0], // SpellID, CooldownMin, CooldownMax + self::EVENT_ON_DESPAWN => [null, null, null, null, null, 0], // NONE + self::EVENT_SEND_EVENT_TRIGGER => [null, null, null, null, null, 2], // UNUSED NEEDS CHERRYPICK + self::EVENT_AREATRIGGER_EXIT => [null, null, null, null, null, 2], // don't use on 3.3.5a + self::EVENT_ON_AURA_APPLIED => [Type::SPELL, ['numRange', -1, true], null, null, null, 0], // SpellID, CooldownMin, CooldownMax + self::EVENT_ON_AURA_REMOVED => [Type::SPELL, ['numRange', -1, true], null, null, null, 0] // SpellID, CooldownMin, CooldownMax + ); + + private array $jsGlobals = []; + + public function __construct( + private int $id, + public readonly int $type, + public readonly int $phaseMask, + public readonly int $chance, + private int $flags, + private array $param, + private SmartAI &$smartAI) + { + // additional parameters + Util::checkNumeric($this->param, NUM_CAST_INT); + $this->param = array_pad($this->param, 15, ''); + } + + public function process() : array + { + $body = + $footer = ''; + + $phases = Util::mask2bits($this->phaseMask, 1) ?: [0]; + $eventTT = Lang::smartAI('eventTT', array_merge([$this->type, $phases, $this->chance, $this->flags], $this->param)); + + for ($i = 0; $i < 5; $i++) + { + $eParams = $this->data[$this->type]; + + if (is_array($eParams[$i])) + { + [$fn, $idx, $extraParam] = $eParams[$i]; + + if ($idx < 0) + $footer = $this->{$fn}($this->param[$i], $this->param[$i + 1], $extraParam); + else + $this->param[$idx] = $this->{$fn}($this->param[$i], $this->param[$i + 1], $extraParam); + } + else if (is_int($eParams[$i]) && $this->param[$i]) + $this->jsGlobals[$eParams[$i]][$this->param[$i]] = $this->param[$i]; + } + + // non-generic cases + switch ($this->type) + { + case self::EVENT_UPDATE_IC: // 0 - In combat. + case self::EVENT_UPDATE_OOC: // 1 - Out of combat. + if ($this->smartAI->srcType == SmartAI::SRC_TYPE_ACTIONLIST) + $this->param[11] = 1; + // do not break; + case self::EVENT_GOSSIP_HELLO: // 64 - On Right-Click Creature/Gameobject that have gossip enabled. + if ($this->smartAI->srcType == SmartAI::SRC_TYPE_OBJECT) + $footer = array( + $this->param[0] == 1, + $this->param[0] == 2, + ); + break; + case self::EVENT_RESPAWN: // 11 - On Creature/Gameobject Respawn in Zone/Map + if ($this->param[0] == 1) // per map + { + switch ($this->param[1]) + { + case 0: $this->param[10] = Lang::maps('EasternKingdoms'); break; + case 1: $this->param[10] = Lang::maps('Kalimdor'); break; + case 530: $this->param[10] = Lang::maps('Outland'); break; + case 571: $this->param[10] = Lang::maps('Northrend'); break; + default: + if ($aId = DB::Aowow()->selectCell('SELECT `id` FROM ::zones WHERE `mapId` = %i', $this->param[1])) + { + $this->param[11] = $aId; + $this->jsGlobals[Type::ZONE][$aId] = $aId; + } + else + $this->param[11] = '[span class=q10]Unknown Map[/span] #'.$this->param[1]; + }; + } + else if ($this->param[0] == 2) // per zone + $this->param[11] = $this->param[2]; + + break; + case self::EVENT_LINK: // 61 - Used to link together multiple events as a chain of events. + if ($links = DB::World()->selectCol('SELECT `id` FROM smart_scripts WHERE `link` = %i AND `entryorguid` = %i AND `source_type` = %i', $this->id, $this->smartAI->entry, $this->smartAI->srcType)) + $this->param[10] = Lang::concat($links, Lang::CONCAT_OR, fn($x) => "#[b]".$x."[/b]"); + break; + case self::EVENT_GOSSIP_SELECT: // 62 - On gossip clicked (gossip_menu_option335). + $gmo = DB::World()->selectRow( + 'SELECT gmo.`OptionText` AS "text_loc0" %if', Lang::getLocale() != Locale::EN, ', gmol.`OptionText` AS %s', 'text_loc' . Lang::getLocale()->value, '%end + FROM gossip_menu_option gmo + LEFT JOIN gossip_menu_option_locale gmol ON gmo.`MenuID` = gmol.`MenuID` AND gmo.`OptionID` = gmol.`OptionID` AND gmol.`Locale` = %s + WHERE gmo.`MenuId` = %i AND gmo.`OptionID` = %i', + Lang::getLocale()->json(), $this->param[0], $this->param[1] + ); + + if ($gmo) + $this->param[10] = Util::localizedString($gmo, 'text'); + else + trigger_error('SmartAI::event - could not find gossip menu option for event #'.$this->type); + break; + case self::EVENT_DISTANCE_CREATURE: // 75 - On creature guid OR any instance of creature entry is within distance. + if ($this->param[0]) + if ($_ = $this->resolveGuid(Type::NPC, $this->param[0])) + { + $this->jsGlobals[Type::NPC][$this->param[0]] = $this->param[0]; + $this->param[10] = $_; + } + // do not break; + case self::EVENT_DISTANCE_GAMEOBJECT: // 76 - On gameobject guid OR any instance of gameobject entry is within distance. + if ($this->param[0] && !$this->param[10]) + { + if ($_ = $this->resolveGuid(Type::OBJECT, $this->param[0])) + { + $this->jsGlobals[Type::OBJECT][$this->param[0]] = $this->param[0]; + $this->param[10] = $_; + } + } + else if ($this->param[1]) + $this->param[10] = $this->param[1]; + else if (!$this->param[10]) + trigger_error('SmartAI::event - entity for event #'.$this->type.' not defined'); + break; + case self::EVENT_EVENT_PHASE_CHANGE: // 66 - On event phase mask set + $this->param[10] = Lang::concat(Util::mask2bits($this->param[0]), Lang::CONCAT_OR); + break; + } + + $this->smartAI->addJsGlobals($this->jsGlobals); + + $body = Lang::smartAI('events', $this->type, 0, $this->param) ?? Lang::smartAI('eventUNK', [$this->type]); + if ($footer) + $footer = Lang::smartAI('events', $this->type, 1, (array)$footer); + + // resolve conditionals + $i = 0; + while (strstr($body, ')?') && $i++ < 3) + $body = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):(([^;]*);*);/i', fn($m) => $m[1] ? $m[2] : $m[3], $body); + + $i = 0; + while (strstr($footer, ')?') && $i++ < 3) + $footer = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):(([^;]*);*);/i', fn($m) => $m[1] ? $m[2] : $m[3], $footer); + + if ($_ = $this->formatFlags()) + $footer = $_ . ($footer ? '; '.$footer : ''); + + if (User::isInGroup(U_GROUP_EMPLOYEE)) + { + if ($eParams[5] == 1) + $footer = '[span class=rep2]DEPRECATED[/span] ' . $footer; + else if ($eParams[5] == 2) + $footer = '[span class=rep0]RESERVED[/span] ' . $footer; + } + + // wrap body in tooltip + return [sprintf(self::EVENT_CELL_TPL, $eventTT, $body), $footer]; + } + + public function hasPhases() : bool + { + return $this->phaseMask && ($this->phaseMask & 0xFFF) != 0xFFF; + } + + private function formatFlags() : string + { + $flags = $this->flags; + + if ($x = ($flags & ~self::FLAG_VALIDATE)) + { + trigger_error('SmartEvent::formatFlags - unused SmartEventFlags '.Util::asBin($x).' set on id #'.$this->id, E_USER_NOTICE); + $flags &= self::FLAG_VALIDATE; + } + + if (($flags & self::FLAG_ALL_DIFFICULTIES) == self::FLAG_ALL_DIFFICULTIES) + $flags &= ~self::FLAG_ALL_DIFFICULTIES; + + $ef = []; + for ($i = 1; $i <= self::FLAG_WHILE_CHARMED; $i <<= 1) + if ($flags & $i) + if ($x = Lang::smartAI('eventFlags', $i)) + $ef[] = $x; + + return Lang::concat($ef); + } +} + +?> diff --git a/includes/components/SmartAI/SmartTarget.class.php b/includes/components/SmartAI/SmartTarget.class.php new file mode 100644 index 00000000..76aacd7c --- /dev/null +++ b/includes/components/SmartAI/SmartTarget.class.php @@ -0,0 +1,185 @@ + [null, null, null, null], // NONE + self::TARGET_SELF => [null, null, null, null], // Self cast + self::TARGET_VICTIM => [null, null, null, null], // Our current target (ie: highest aggro) + self::TARGET_HOSTILE_SECOND_AGGRO => [null, null, null, null], // Second highest aggro, maxdist, playerOnly, powerType + 1 + self::TARGET_HOSTILE_LAST_AGGRO => [null, null, null, null], // Dead last on aggro, maxdist, playerOnly, powerType + 1 + self::TARGET_HOSTILE_RANDOM => [null, null, null, null], // Just any random target on our threat list, maxdist, playerOnly, powerType + 1 + self::TARGET_HOSTILE_RANDOM_NOT_TOP => [null, null, null, null], // Any random target except top threat, maxdist, playerOnly, powerType + 1 + self::TARGET_ACTION_INVOKER => [null, null, null, null], // Unit who caused this Event to occur + self::TARGET_POSITION => [null, null, null, null], // use xyz from event params + self::TARGET_CREATURE_RANGE => [Type::NPC, ['numRange', 10, false], null, null], // CreatureEntry(0any), minDist, maxDist + self::TARGET_CREATURE_GUID => [null, Type::NPC, null, null], // guid, entry + self::TARGET_CREATURE_DISTANCE => [Type::NPC, null, null, null], // CreatureEntry(0any), maxDist + self::TARGET_STORED => [null, null, null, null], // id, uses pre-stored target(list) + self::TARGET_GAMEOBJECT_RANGE => [Type::OBJECT, ['numRange', 10, false], null, null], // entry(0any), min, max + self::TARGET_GAMEOBJECT_GUID => [null, Type::OBJECT, null, null], // guid, entry + self::TARGET_GAMEOBJECT_DISTANCE => [Type::OBJECT, null, null, null], // entry(0any), maxDist + self::TARGET_INVOKER_PARTY => [null, null, null, null], // invoker's party members + self::TARGET_PLAYER_RANGE => [['numRange', 10, false], null, null, null], // min, max + self::TARGET_PLAYER_DISTANCE => [null, null, null, null], // maxDist + self::TARGET_CLOSEST_CREATURE => [Type::NPC, null, null, null], // CreatureEntry(0any), maxDist, dead? + self::TARGET_CLOSEST_GAMEOBJECT => [Type::OBJECT, null, null, null], // entry(0any), maxDist + self::TARGET_CLOSEST_PLAYER => [null, null, null, null], // maxDist + self::TARGET_ACTION_INVOKER_VEHICLE => [null, null, null, null], // Unit's vehicle who caused this Event to occur + self::TARGET_OWNER_OR_SUMMONER => [null, null, null, null], // Unit's owner or summoner, Use Owner/Charmer of this unit + self::TARGET_THREAT_LIST => [null, null, null, null], // All units on creature's threat list, maxdist + self::TARGET_CLOSEST_ENEMY => [null, null, null, null], // maxDist, playerOnly + self::TARGET_CLOSEST_FRIENDLY => [null, null, null, null], // maxDist, playerOnly + self::TARGET_LOOT_RECIPIENTS => [null, null, null, null], // all players that have tagged this creature (for kill credit) + self::TARGET_FARTHEST => [null, null, null, null], // maxDist, playerOnly, isInLos + self::TARGET_VEHICLE_PASSENGER => [null, null, null, null], // seatMask (0 - all seats) + self::TARGET_CLOSEST_UNSPAWNED_GO => [Type::OBJECT, null, null, null] // entry(0any), maxDist + ); + + private array $jsGlobals = []; + + public function __construct( + private int $id, + public readonly int $type, + private array $param, + private array $worldPos, + private SmartAI &$smartAI) + { + // additional parameters + Util::checkNumeric($this->param, NUM_CAST_INT); + Util::checkNumeric($this->worldPos, NUM_CAST_FLOAT); + $this->param = array_pad($this->param, 15, ''); + $this->worldPos = array_pad($this->worldPos, 4, 0.0); + } + + public function process() : string + { + $target = ''; + + $targetTT = Lang::smartAI('targetTT', array_merge([$this->type], $this->param, $this->worldPos)); + + for ($i = 0; $i < 4; $i++) + { + $tParams = $this->targets[$this->type]; + + if (is_array($tParams[$i])) + { + [$fn, $idx, $extraParam] = $tParams[$i]; + + $this->param[$idx] = $this->{$fn}($this->param[$i], $this->param[$i + 1], $extraParam); + } + else if (is_int($tParams[$i]) && $this->param[$i]) + $this->jsGlobals[$tParams[$i]][$this->param[$i]] = $this->param[$i]; + } + + // non-generic cases + switch ($this->type) + { + case self::TARGET_HOSTILE_SECOND_AGGRO: + case self::TARGET_HOSTILE_LAST_AGGRO: + case self::TARGET_HOSTILE_RANDOM: + case self::TARGET_HOSTILE_RANDOM_NOT_TOP: + if ($this->param[2]) + $this->param[10] = Lang::spell('powerTypes', $this->param[2] - 1); + break; + case self::TARGET_VEHICLE_PASSENGER: + if ($this->param[0]) + $this->param[10] = Lang::concat(Util::mask2bits($this->param[0])); + break; + case self::TARGET_CREATURE_GUID: + if ($_ = $this->resolveGuid(Type::NPC, $this->param[0])) + { + $this->jsGlobals[Type::NPC][$_] = $_; + $this->param[10] = $_; + } + break; + case self::TARGET_GAMEOBJECT_GUID: + if ($_ = $this->resolveGuid(Type::OBJECT, $this->param[0])) + { + $this->jsGlobals[Type::OBJECT][$_] = $_; + $this->param[10] = $_; + } + break; + } + + $this->smartAI->addJsGlobals($this->jsGlobals); + + $target = Lang::smartAI('targets', $this->type, $this->param) ?? Lang::smartAI('targetUNK', [$this->type]); + + // resolve conditionals + $i = 0; + while (strstr($target, ')?') && $i++ < 3) + $target = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):(([^;]*);*);/i', fn($m) => $m[1] ? $m[2] : $m[3], $target); + + // wrap in tooltip (suspend action-tooltip) + return '[/span]'.sprintf(self::TARGET_TPL, $targetTT, $target).'[span tooltip=a-#rowIdx#]'; + } + + public function getWorldPos() : array + { + return $this->worldPos; + } + + // not really feasable. Too many target types can be players or creatures, depending on context + public function getTalkSource(bool &$playerSrc = false) : int + { + if ($this->type == SmartTarget::TARGET_CLOSEST_PLAYER) + $playerSrc = true; + + return match ($this->type) + { + SmartTarget::TARGET_CREATURE_GUID => $this->resolveGuid(Type::NPC, $this->param[0]) ?? 0, + SmartTarget::TARGET_CREATURE_RANGE, + SmartTarget::TARGET_CREATURE_DISTANCE, + SmartTarget::TARGET_CLOSEST_CREATURE => $this->param[0], + SmartTarget::TARGET_CLOSEST_PLAYER, + SmartTarget::TARGET_SELF => $this->smartAI->getEntry(), + default => $this->smartAI->getEntry() + }; + } +} + +?> diff --git a/includes/components/avatarmgr.class.php b/includes/components/avatarmgr.class.php new file mode 100644 index 00000000..4df080c8 --- /dev/null +++ b/includes/components/avatarmgr.class.php @@ -0,0 +1,122 @@ + self::MAX_W || $is[1] > self::MAX_H) + self::$error = Lang::account('selectAvatar'); + } + else + self::$error = Lang::account('selectAvatar'); + + if (!self::$error) + return true; + + self::$fileName = ''; + return false; + } + + /* create icon texture atlas + * ****************************** + * * LARGE * MEDIUM * + * * * * + * * * * + * * ************* + * * * SMOL * * + * * * * * + * * ********* * + * ****************************** + * + * as static/uploads/avatars/.jpg + */ + + public static function createAtlas(string $fileName) : bool + { + if (!self::$img) + return false; + + $sizes = [ICON_SIZE_LARGE, ICON_SIZE_MEDIUM, ICON_SIZE_SMALL]; + + $dest = imagecreatetruecolor(ICON_SIZE_LARGE + ICON_SIZE_MEDIUM, ICON_SIZE_LARGE); + $srcW = imagesx(self::$img); + $srcH = imagesx(self::$img); + + $destX = $destY = 0; + foreach ($sizes as $idx => $dim) + { + imagecopyresampled($dest, self::$img, $destX, $destY, 0, 0, $dim, $dim, $srcW, $srcH); + + if ($idx % 2) + $destY += $dim; + else + $destX += $dim; + } + + if (!imagejpeg($dest, sprintf(self::PATH_AVATARS, $fileName), self::JPEG_QUALITY)) + return false; + + self::$img = null; + $dest = null; + return true; + } + + + /*************/ + /* Admin Mgr */ + /*************/ + + // unsure yet how that's supposed to work + // for now pending uploads can be used right away +} + +?> diff --git a/includes/components/communitycontent.class.php b/includes/components/communitycontent.class.php new file mode 100644 index 00000000..ce7999af --- /dev/null +++ b/includes/components/communitycontent.class.php @@ -0,0 +1,413 @@ + 0 AND ur.`userId` = %i, ur.`value`, 0)) AS "userRating", + IF(r.`id` IS NULL, 0, 1) AS "userReported" + FROM ::comments c + JOIN ::account a1 ON c.`userId` = a1.`id` + LEFT JOIN ::account a2 ON c.`editUserId` = a2.`id` + LEFT JOIN ::account a3 ON c.`deleteUserId` = a3.`id` + LEFT JOIN ::account a4 ON c.`responseUserId` = a4.`id` + LEFT JOIN ::user_ratings ur ON c.`id` = ur.`entry` AND ur.`type` = %i + LEFT JOIN ::reports r ON r.`subject` = c.`id` AND r.`mode` = %i AND r.`userId` = %i + WHERE %and + GROUP BY c.`id` + ORDER BY c.`date` ASC + %lmt'; + + private static string $ssQuery = + 'SELECT s.`id` AS ARRAY_KEY, s.`id`, a.`username` AS "user", s.`date`, s.`width`, s.`height`, s.`caption`, IF(s.`status` & %i, 1, 0) AS "sticky", s.`type`, s.`typeId` + FROM ::screenshots s + LEFT JOIN ::account a ON s.`userIdOwner` = a.`id` + WHERE %and + ORDER BY `date` DESC + %lmt'; + + private static string $viQuery = + 'SELECT v.`id` AS ARRAY_KEY, v.`id`, a.`username` AS "user", v.`date`, v.`videoId`, v.`caption`, IF(v.`status` & %i, 1, 0) AS "sticky", v.`type`, v.`typeId` + FROM ::videos v + LEFT JOIN ::account a ON v.`userIdOwner` = a.`id` + WHERE %and + ORDER BY %by + %lmt'; + + private static string $previewQuery = + 'SELECT c.`id`, + c.`body` AS "preview", + c.`date`, + c.`replyTo` AS "commentid", + IF(c.`flags` & %i, 1, 0) AS "deleted", + IF(c.`type` <> 0, c.`type`, c2.`type`) AS "type", + IF(c.`typeId` <> 0, c.`typeId`, c2.`typeId`) AS "typeId", + IFNULL(SUM(ur.`value`), 0) AS "rating", + a.`username` AS "user" + FROM ::comments c + JOIN ::account a ON c.`userId` = a.`id` + LEFT JOIN ::user_ratings ur ON ur.`entry` = c.`id` AND ur.`userId` <> 0 AND ur.`type` = 1 + LEFT JOIN ::comments c2 ON c.`replyTo` = c2.`id` + WHERE %and + GROUP BY c.`id` + ORDER BY c.`date` DESC + %lmt'; + + private static function addSubject(int $type, int $typeId) : void + { + if (!isset(self::$subjCache[$type][$typeId])) + self::$subjCache[$type][$typeId] = 0; + } + + private static function getSubjects() : void + { + foreach (self::$subjCache as $type => $ids) + { + $_ = array_filter(array_keys($ids), 'is_numeric'); + if (!$_) + continue; + + $obj = Type::newList($type, [['id', $_]]); + if (!$obj) + continue; + + foreach ($obj->iterate() as $id => $__) + self::$subjCache[$type][$id] = $obj->getField('name', true, true); + } + } + + public static function getCommentPreviews(array $opt = [], ?int &$nFound = 0, bool $dateFmt = true, int $resultLimit = PHP_INT_MAX) : array + { + /* + purged:0, <- doesnt seem to be used anymore + domain:'live' <- irrelevant for our case + */ + + // add default values + $opt += ['user' => 0, 'unrated' => 0, 'comments' => 0, 'replies' => 0, 'flags' => 0]; + + $where = []; + if (!User::isInGroup(U_GROUP_COMMENTS_MODERATOR)) + $where[] = [DB::OR, [['(c.`flags` & %i) = 0', CC_FLAG_DELETED], ['c.`userId` = %i', User::$id]]]; + if ($opt['user']) + $where[] = ['c.`userId` = %i', $opt['user']]; + if ($opt['unrated']) + $where[] = ['ur.`entry` IS %sN', null]; + if ($opt['flags']) + $where[] = ['(c.`flags` & %i) > 0', $opt['flags']]; + if ($opt['comments'] && !$opt['replies']) + $where[] = ['c.`replyTo` = 0']; + else if (!$opt['comments'] && $opt['replies']) + $where[] = ['c.`replyTo` <> 0']; + // else + // pick both and no extra constraint needed for that + + $comments = DB::Aowow()->selectAssoc(self::$previewQuery, CC_FLAG_DELETED, $where, $resultLimit); + + if (!$comments) + return []; + + $nFound = DB::Aowow()->selectCell(substr_replace(self::$previewQuery, 'SELECT COUNT(*) ', 0, strpos(self::$previewQuery, 'FROM')), $where, PHP_INT_MAX); + + foreach ($comments as $c) + self::addSubject($c['type'], $c['typeId']); + + self::getSubjects(); + + foreach ($comments as $idx => &$c) + { + if (!empty(self::$subjCache[$c['type']][$c['typeId']])) + { + // apply subject + $c['subject'] = self::$subjCache[$c['type']][$c['typeId']]; + + // format date + $c['elapsed'] = time() - $c['date']; + $c['date'] = $dateFmt ? date(Util::$dateFormatInternal, $c['date']) : intVal($c['date']); + + // remove commentid if not looking for replies + if (empty($opt['replies'])) + unset($c['commentid']); + + // format text for listview + $c['preview'] = Lang::trimTextClean($c['preview']); + } + else + { + trigger_error('Comment '.$c['id'].' belongs to nonexistent subject.', E_USER_NOTICE); + unset($comments[$idx]); + } + } + + return array_values($comments); + } + + public static function getCommentReplies(int $commentId, int $resultLimit = PHP_INT_MAX, ?int &$nFound = 0) : array + { + $where = array( + ['c.`replyTo` = %i', $commentId], + ['c.`type` = %i', 0], + ['c.`typeId` = %i', 0] + ); + + if (!User::isInGroup(U_GROUP_COMMENTS_MODERATOR)) + $where[] = [DB::OR, [['(c.`flags` & %i) = 0', CC_FLAG_DELETED], ['c.`userId` = %i', User::$id]]]; + + // get replies + $replies = []; + if ($results = DB::Aowow()->selectAssoc(self::$coQuery, User::$id, RATING_COMMENT, Report::MODE_COMMENT, User::$id, $where, $resultLimit)) + { + $nFound = DB::Aowow()->selectCell(self::$coCountQuery, $where); + + foreach ($results as $r) + { + Markup::parseTags($r['body'], self::$jsGlobals); + + $reply = array( + 'commentid' => $commentId, + 'id' => $r['id'], + 'body' => $r['body'], + 'username' => $r['user'], + 'roles' => $r['roles'], + 'creationdate' => date(Util::$dateFormatInternal, $r['date']), + 'lasteditdate' => date(Util::$dateFormatInternal, $r['editDate']), + 'rating' => (string)$r['rating'] + ); + + if ($r['userReported']) + $reply['reportedByUser'] = true; + + if ($r['userRating'] > 0) + $reply['votedByUser'] = true; + else if ($r['userRating'] < 0) + $reply['downvotedByUser'] = true; + + $replies[] = $reply; + } + } + + return $replies; + } + + public static function getComments(int $type, int $typeId) : array + { + + $where = array( + ['c.`replyTo` = %i', 0], + ['c.`type` = %i', $type], + ['c.`typeId` = %i', $typeId] + ); + + if (!User::isInGroup(U_GROUP_COMMENTS_MODERATOR)) + $where[] = [DB::OR, [['(c.`flags` & %i) = 0', CC_FLAG_DELETED], ['c.`userId` = %i', User::$id]]]; + + // get replies + $results = DB::Aowow()->selectAssoc(self::$coQuery, User::$id, RATING_COMMENT, Report::MODE_COMMENT, User::$id, $where, PHP_INT_MAX); + $comments = []; + + // additional informations + $i = 0; + foreach ($results as $r) + { + Markup::parseTags($r['body'], self::$jsGlobals); + + self::$jsGlobals[Type::USER][$r['userId']] = $r['userId']; + + $c = array( + 'commentv2' => 1, // always 1.. enables some features i guess..? + 'number' => $i++, // some iterator .. unsued? + 'id' => $r['id'], + 'date' => date(Util::$dateFormatInternal, $r['date']), + 'roles' => $r['roles'], + 'body' => $r['body'], + 'rating' => $r['rating'], + 'userRating' => $r['userRating'], + 'user' => $r['user'], + 'nreplies' => 0 + ); + + $c['replies'] = self::getCommentReplies($r['id'], 5, $c['nreplies']); + + if ($r['responseBody']) // adminResponse + { + $c['response'] = $r['responseBody']; + $c['responseroles'] = $r['responseRoles']; + $c['responseuser'] = $r['responseUser']; + + Markup::parseTags($r['responseBody'], self::$jsGlobals); + } + + if ($r['editCount']) // lastEdit + $c['lastEdit'] = [date(Util::$dateFormatInternal, $r['editDate']), $r['editCount'], $r['editUser']]; + + if ($r['flags'] & CC_FLAG_STICKY) + $c['sticky'] = true; + + if ($r['flags'] & CC_FLAG_DELETED) + { + $c['deleted'] = true; + $c['deletedInfo'] = [date(Util::$dateFormatInternal, $r['deleteDate']), $r['deleteUser']]; + } + + if ($r['flags'] & CC_FLAG_OUTDATED) + $c['outofdate'] = true; + + $comments[] = $c; + } + + return $comments; + } + + public static function getVideos(int $typeOrUser = 0, int $typeId = 0, ?int &$nFound = 0, bool $dateFmt = true, int $resultLimit = PHP_INT_MAX) : array + { + $where = array( + ['v.`status` & %i', CC_FLAG_APPROVED], + ['(v.`status` & %i) = 0', CC_FLAG_DELETED] + + ); + if ($typeOrUser < 0) + $where[] = ['v.`userIdOwner` = %i', -$typeOrUser]; + if ($typeOrUser > 0) + { + $where[] = ['v.`type` = %i', $typeOrUser]; + $where[] = ['v.`typeId` = %i', $typeId]; + } + + $videos = DB::Aowow()->selectAssoc(self::$viQuery, CC_FLAG_STICKY, $where, $typeOrUser ? ['date' => false] : ['pos' => true], $resultLimit); + + if (!$videos) + return []; + + $nFound = DB::Aowow()->selectCell(substr_replace(self::$viQuery, 'SELECT COUNT(*) ', 0, strpos(self::$viQuery, 'FROM')), $where, $typeOrUser ? ['date' => false] : ['pos' => true], PHP_INT_MAX); + + if ($typeOrUser <= 0) // not for search by type/typeId + { + foreach ($videos as $v) + self::addSubject($v['type'], $v['typeId']); + + self::getSubjects(); + } + + // format data to meet requirements of the js + foreach ($videos as &$v) + { + if ($typeOrUser <= 0) // not for search by type/typeId + { + if (!empty(self::$subjCache[$v['type']][$v['typeId']]) && !is_numeric(self::$subjCache[$v['type']][$v['typeId']])) + $v['subject'] = self::$subjCache[$v['type']][$v['typeId']]; + else + $v['subject'] = Lang::user('removed'); + } + + $v['date'] = $dateFmt ? date(Util::$dateFormatInternal, $v['date']) : intVal($v['date']); + $v['videoType'] = 1; // always youtube + + if (!$v['sticky']) + unset($v['sticky']); + + if (!$v['user']) + unset($v['user']); + } + + return array_values($videos); + } + + public static function getScreenshots(int $typeOrUser = 0, int $typeId = 0, ?int &$nFound = 0, bool $dateFmt = true, int $resultLimit = PHP_INT_MAX) : array + { + $where = array( + ['s.`status` & %i', CC_FLAG_APPROVED], + ['(s.`status` & %i) = 0', CC_FLAG_DELETED] + + ); + if ($typeOrUser < 0) + $where[] = ['s.`userIdOwner` = %i', -$typeOrUser]; + if ($typeOrUser > 0) + { + $where[] = ['s.`type` = %i', $typeOrUser]; + $where[] = ['s.`typeId` = %i', $typeId]; + } + + $screenshots = DB::Aowow()->selectAssoc(self::$ssQuery, + CC_FLAG_STICKY, + $where, + $resultLimit + ); + + if (!$screenshots) + return []; + + $nFound = DB::Aowow()->selectCell(substr_replace(self::$ssQuery, 'SELECT COUNT(*) ', 0, strpos(self::$ssQuery, 'FROM')), $where, PHP_INT_MAX); + + if ($typeOrUser <= 0) // not for search by type/typeId + { + foreach ($screenshots as $s) + self::addSubject($s['type'], $s['typeId']); + + self::getSubjects(); + } + + // format data to meet requirements of the js + foreach ($screenshots as &$s) + { + if ($typeOrUser <= 0) // not for search by type/typeId + { + if (!empty(self::$subjCache[$s['type']][$s['typeId']]) && !is_numeric(self::$subjCache[$s['type']][$s['typeId']])) + $s['subject'] = self::$subjCache[$s['type']][$s['typeId']]; + else + $s['subject'] = Lang::user('removed'); + } + + $s['date'] = $dateFmt ? date(Util::$dateFormatInternal, $s['date']) : intVal($s['date']); + + if (!$s['sticky']) + unset($s['sticky']); + + if (!$s['user']) + unset($s['user']); + } + + return array_values($screenshots); + } + + public static function getJSGlobals() : array + { + return self::$jsGlobals; + } +} +?> diff --git a/includes/components/dbtypelist.class.php b/includes/components/dbtypelist.class.php new file mode 100644 index 00000000..c0d98f82 --- /dev/null +++ b/includes/components/dbtypelist.class.php @@ -0,0 +1,965 @@ +: + public bool $error = true; + + /* + * condition as array [expression, value, operator] + * expression: str - must match fieldname; + * int - 1: select everything; 0: select nothing + * array - another condition array + * value: str - operator defaults to: = + * num - operator defaults to: = + * array - operator defaults to: IN () + * null - operator defaults to: IS [NULL] + * operator: modifies/overrides default + * ! - negated default value (NOT LIKE; <>; NOT IN) + * MATCH - creates fulltext search ('value' must be array; column must have fulltext index) + * LIKE / NOT LIKE - partial string matching ('value' must be string (*d'uh*)) + * condition as str + * defines linking (AND || OR) + * condition as int + * defines LIMIT + * + * example: + * array( + * ['id', 45], + * ['name', 'test%', '!'], + * [ + * DB::AND, + * ['flags', 0xFF, '&'], + * ['flags2', 0xF, '&'], + * ] + * [['mask', 0x3, '&'], 0], + * ['nameField', ['+contains*', '-excludes'], 'MATCH], + * ['joinedTbl.field', NULL] // NULL must be explicitly specified "['joinedTbl.field']" would be skipped as erroneous definition (only really usefull when left-joining) + * DB::OR, + * 5 + * ) + * results in + * WHERE ((`id` = 45) OR (`name` NOT LIKE "test%") OR ((`flags` & 255) AND (`flags2` & 15)) OR ((`mask` & 3) = 0)) OR (MATCH(`nameField`) AGAINST("+contains* -excludes" IN BOOLEAN MODE)) OR (`joinedTbl`.`field` IS NULL) LIMIT 5 + */ + public function __construct(array $conditions = [], array $miscData = []) + { + $where = []; + $linking = DB::AND; + $limit = 0; + + $calcTotal = false; + $totalQuery = ''; + + if (!$this->queryBase || $conditions === null) + return; + + if (preg_match('/FROM (?:::)?[\w\_]+( AS)?\s?`?(\w+)`?$/i', $this->queryBase, $match)) + $this->prefixes['base'] = $match[2]; + else + $this->prefixes['base'] = ''; + + if (!empty($miscData['extraOpts'])) + $this->extendQueryOpts($miscData['extraOpts']); + + if (!empty($miscData['calcTotal'])) + $calcTotal = true; + + foreach ($conditions as $i => $c) + { + switch (getType($c)) + { + case 'array': + break; + case 'string': + case 'integer': + if (is_numeric($c)) + $limit = max(0, (int)$c); + else if ($c === DB::AND) + $linking = DB::AND; + else if ($c === DB::OR) + $linking = DB::OR; + default: + unset($conditions[$i]); + } + } + + foreach ($conditions as $c) + if ($x = $this->resolveCondition($c, $linking)) + $where[] = $x; + + // optional query parts may require other optional parts to work + foreach ($this->prefixes as $pre) + if (isset($this->queryOpts[$pre][0])) + foreach ($this->queryOpts[$pre][0] as $req) + if (!in_array($req, $this->prefixes)) + $this->prefixes[] = $req; + + // remove optional query parts, that are not required + foreach ($this->queryOpts as $k => $arr) + if (!in_array($k, $this->prefixes)) + unset($this->queryOpts[$k]); + + // prepare usage of guids if using multiple realms (which have non-zoro indizes) + if (key($this->dbNames) != 0) + $this->queryBase = preg_replace('/\s([^\s]+)\sAS\sARRAY_KEY/i', ' CONCAT("DB_IDX", ":", \1) AS ARRAY_KEY', $this->queryBase); + + // insert additional selected fields + if ($s = array_column($this->queryOpts, 's')) + $this->queryBase = str_replace('ARRAY_KEY', 'ARRAY_KEY '.implode('', $s), $this->queryBase); + + // append joins + if ($j = array_column($this->queryOpts, 'j')) + foreach ($j as $_) + $this->queryBase .= is_array($_) ? (empty($_[1]) ? ' JOIN ' : ' LEFT JOIN ').$_[0] : ' JOIN '.$_; + + // append conditions + if ($where) + $this->queryBase .= ' WHERE '.$linking; + + // append grouping + if ($g = array_filter(array_column($this->queryOpts, 'g'))) + $this->queryBase .= ' GROUP BY '.implode(', ', $g); + + // append post filtering + if ($h = array_filter(array_column($this->queryOpts, 'h'))) + $this->queryBase .= ' HAVING '.implode(' AND ', $h); + + // fill in locale + $this->queryBase = str_replace(['DB_LOC_I', 'DB_LOC_S'], [Lang::getLocale()->value, '"'.Lang::getLocale()->json().'"'], $this->queryBase); + + // without applied LIMIT and ORDER + if ($calcTotal) + $totalQuery = $this->queryBase; + + // append ordering + if ($o = array_filter(array_column($this->queryOpts, 'o'))) + $this->queryBase .= ' ORDER BY '.implode(', ', $o); + + // apply limit + if ($limit) + $this->queryBase .= ' LIMIT '.$limit; + + // execute query (finally) + // this is purely because of multiple realms per server + foreach ($this->dbNames as $dbIdx => $n) + { + try // does not go through the compatibility layer as we need to be able to fetch individual rows here + { + $query = str_replace('DB_IDX', $dbIdx, $this->queryBase); + $result = DB::{$n}($dbIdx)->query($query, $where); + + if ($calcTotal && $result->getRowCount()) + { + // hackfix the inner items query to not contain duplicate column names + // yes i know the real solution would be to not have items and item_stats share column names + // soon™.... + if (get_class($this) == ItemList::class) + $totalQuery = str_replace([', `is`.*', ', i.`id` AS "id"'], '', $totalQuery); + + $this->matches += DB::{$n}($dbIdx)->selectCell('SELECT COUNT(*) FROM ('.$totalQuery.') x', $where); + } + + foreach ($result->getIterator() as $row) + { + // just .. roll with the unparsed, deprecated ARRAY_KEY, hmk? + if (isset($this->templates[$row['ARRAY_KEY']])) + trigger_error('GUID for List already in use #'.$row['ARRAY_KEY'].'. Additional occurrence omitted!', E_USER_ERROR); + else + $this->templates[$row['ARRAY_KEY']] = (array)$row; + } + + $result->free(); + } + catch (\Exception $e) {} // logged via \Dibi\Event in DB::errorLogger + } + + if (!$this->templates) + return; + + // push first element for instant use + $this->reset(); + + // all clear + $this->error = false; + } + + private function resolveCondition(array $c, string $supLink) : ?array + { + if (!$c) + return null; + + // i am recursive subcondition + if ($subLink = array_find($c, fn($x) => $x === DB::AND || $x === DB::OR)) + { + $sql = []; + + foreach ($c as $foo) + if (is_array($foo)) + if ($x = $this->resolveCondition($foo, $supLink)) + $sql[] = $x; + + return $sql ? [$subLink, $sql] : null; + } + + [$expOrField, $value, $op] = array_pad($c, 3, null); + + if (is_numeric($expOrField)) + return [$expOrField ? 1 : 0]; // [1] / [0] + if (!$expOrField) // '', null, [] + return null; + + $literal = false; + + if (is_array($expOrField) && $op != 'MATCH') + $field = $this->resolveCondition($expOrField, $supLink); + else + { + // basic formulas ex: [((minGold + maxGold) / 2), 0, '>'] + if (is_string($expOrField) && preg_match('/^\([\s\+\-\*\/\w\(\)\.]+\)$/i', strtr($expOrField, ['`' => '', '´' => '', '--' => '']))) + { + $field = preg_replace_callback('/[\w\]*\.?[\w]+/i', $this->setColPrefix(...), $expOrField); + $literal = true; + } + else + $field = $this->setColPrefix($expOrField); + + if (!$field) + return null; + } + + $neg = $op === '!'; + $expr = match (gettype($value)) + { + 'integer' => ($neg ? '<>' : ($op ?: '=')) . ' %i', + 'double' => ($neg ? '<>' : ($op ?: '=')) . ' %f', + 'string' => ($neg ? '<>' : ($op ?: '=')) . ' %s', + 'NULL' => ($neg ? 'IS NOT' : 'IS') . ' %sN', + 'array' => ($neg ? 'NOT IN' : 'IN') . ' %in', + default => null + }; + + if (!$expr) + return null; + + if ($op == 'MATCH' && gettype($value) == 'array') + return ['MATCH(%n)', $field, 'AGAINST(%s IN BOOLEAN MODE)', DB::Aowow()->translate($value)]; + if (($op == 'LIKE' || $op == 'NOT LIKE') && gettype($value) == 'string') + return ['%n', $field, $op, '%~like~', $value]; + if (is_array($field)) // $field is expression: [[flags, 0x4, '&'], 0] -> (`flags` & 4) = 0 + return [...$field, $expr, $value]; + + return [$literal ? '%SQL' : '%n', $field, $expr, $value]; + } + + private function setColPrefix(mixed $colName) : null|string|array + { + if (is_array($colName)) + return array_filter(array_map([$this, 'setColPrefix'], $colName)) ?: null; + + // numeric allows for formulas e.g. (1 < 3) + if (Util::checkNumeric($colName)) + return $colName; + + // skip condition if fieldName contains illegal chars + if (preg_match('/[^\d\w\.\_]/i', $colName)) + return null; + + [$prefix, $col, $err] = array_pad(explode('.', $colName), 3, null); + + if ($err) // more than one period + return null; + if (!$col) // prefix not set, so everything is shifted to the left :/ + return $this->prefixes['base'].'.'.$prefix; + + if (!in_array($prefix, $this->prefixes)) + { + // choose table to join or return null if prefix does not exist + if (!in_array($prefix, array_keys($this->queryOpts))) + return null; + + $this->prefixes[] = $prefix; + } + + return $prefix.'.'.$col; + } + + /** + * iterate over fetched templates + * + * @return array the current template + */ + public function &iterate() : \Generator + { + if (!$this->templates) + return; + + $this->itrStack[] = $this->id; + + // reset on __construct + $this->reset(); + + foreach ($this->templates as $id => $__) + { + $this->id = $id; + $this->curTpl = &$this->templates[$id]; // do not use $tpl from each(), as we want to be referenceable + + yield $id => $this->curTpl; + + unset($this->curTpl); // kill reference or it will 'bleed' into the next iteration + } + + // fforward to old index + $this->reset(); + $oldIdx = array_pop($this->itrStack); + do + { + if (key($this->templates) != $oldIdx) + continue; + + $this->curTpl = current($this->templates); + $this->id = key($this->templates); + next($this->templates); + break; + } + while (next($this->templates)); + } + + protected function reset() : void + { + unset($this->curTpl); // kill reference or strange stuff will happen + if (!$this->templates) + return; + + $this->curTpl = reset($this->templates); + $this->id = key($this->templates); + } + + // read-access to templates + public function getEntry(string|int $id) : ?array + { + if (isset($this->templates[$id])) + { + unset($this->curTpl); // kill reference or strange stuff will happen + $this->curTpl = $this->templates[$id]; + $this->id = $id; + return $this->templates[$id]; + } + + return null; + } + + public function getField(string $field, bool $localized = false, bool $silent = false) : mixed + { + if (!$this->curTpl || (!$localized && !isset($this->curTpl[$field]))) + return ''; + + if ($localized) + return Util::localizedString($this->curTpl, $field, $silent); + + $value = $this->curTpl[$field]; + Util::checkNumeric($value); + + return $value; + } + + public function getAllFields(string $field, bool $localized = false, bool $silent = false) : array + { + $data = []; + + foreach ($this->iterate() as $__) + $data[$this->id] = $this->getField($field, $localized, $silent); + + return $data; + } + + public function getRandomId() : int + { + if (preg_match('/SELECT .*? FROM (::[\w_-]+) /i', $this->queryBase, $m)) + return DB::Aowow()->selectCell('SELECT `id` FROM %n WHERE (`cuFlags` & %i) = 0 ORDER BY RAND() ASC LIMIT 1', $m[1], User::isInGroup(U_GROUP_EMPLOYEE) ? 0 : CUSTOM_EXCLUDE_FOR_LISTVIEW) ?: 0; + + return 0; + } + + public function getFoundIDs() : array + { + return array_keys($this->templates); + } + + public function getMatches() : int + { + return $this->matches; + } + + protected function extendQueryOpts(array $extra) : void // needs to be called from __construct + { + foreach ($extra as $tbl => $sets) + { + foreach ($sets as $module => $value) + { + if (!$value || !is_array($value)) + continue; + + switch ($module) + { + // additional (str) + case 'g': // group by + case 's': // select + if (!empty($this->queryOpts[$tbl][$module])) + $this->queryOpts[$tbl][$module] .= implode(' ', $value); + else + $this->queryOpts[$tbl][$module] = implode(' ', $value); + + break; + case 'h': // having + if (!empty($this->queryOpts[$tbl][$module])) + $this->queryOpts[$tbl][$module] .= implode(' AND ', $value); + else + $this->queryOpts[$tbl][$module] = implode(' AND ', $value); + + break; + // additional (arr) + case 'j': // join + if (!empty($this->queryOpts[$tbl][$module]) && is_array($this->queryOpts[$tbl][$module])) + $this->queryOpts[$tbl][$module][0][] = $value; + else + $this->queryOpts[$tbl][$module] = $value; + + break; + // replacement (str) + case 'l': // limit + case 'o': // order by + $this->queryOpts[$tbl][$module] = $value[0]; + break; + } + } + } + } + + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->SelectRow('SELECT `name_loc0`, `name_loc2`, `name_loc3`, `name_loc4`, `name_loc6`, `name_loc8` FROM %n WHERE `id` = %i', static::$dataTable, $id)) + return new LocString($n); + return null; + } + + public static function makeLink(int $id, int $fmt = Lang::FMT_HTML, string $cssClass = '') : string + { + if ($n = static::getName($id)) + { + return match ($fmt) + { + Lang::FMT_HTML => '
'.$n.'', + Lang::FMT_MARKUP => '[url=?'.Type::getFileString(static::$type).'='.$id.']'.$n.'[/url]', + default => $n + }; + } + + return ''; + } + + /* source More .. keys seen used + 'n': name [always set] + 't': type [always set] + 'ti': typeId [always set] + 'bd': BossDrop [0; 1] [Creature / GO] + 'dd': DungeonDifficulty [-2: DungeonHC; -1: DungeonNM; 1: Raid10NM; 2:Raid25NM; 3:Raid10HM; 4: Raid25HM; 99: filler trash] [Creature / GO] + 'q': cssQuality [Items] + 'z': zone [set when all happens in here] + 'p': PvP [pvpSourceId] + 's': Type::TITLE: side; Type::SPELL: skillId (yeah, double use. Ain't life just grand) + 'c': category [Spells / Quests] + 'c2': subCat [Quests] + 'icon': iconString + */ + public function getSourceData(int $id = 0) : array { return []; } + + // should return data required to display a listview of any kind + // this is a rudimentary example, that will not suffice for most Types + abstract public function getListviewData() : array; + + // should return data to extend global js variables for a certain type (e.g. g_items) + abstract public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array; + + // NPC, GO, Item, Quest, Spell, Achievement, Profile would require this + abstract public function renderTooltip() : ?string; +} + +trait listviewHelper +{ + public function hasSetFields(?string ...$fields) : int + { + $result = 0x0; + + foreach ($this->iterate() as $__) + { + foreach ($fields as $k => $str) + { + if (!$str) + { + unset($fields[$k]); + continue; + } + + if ($this->getField($str)) + { + $result |= 1 << $k; + unset($fields[$k]); + } + } + + if (empty($fields)) // all set .. return early + { + $this->reset(); // Generators have no __destruct, reset manually, when not doing a full iteration + return $result; + } + } + + return $result; + } + + public function hasDiffFields(?string ...$fields) : int + { + $base = []; + $result = 0x0; + + foreach ($fields as $k => $str) + $base[$str] = $this->getField($str); + + foreach ($this->iterate() as $__) + { + foreach ($fields as $k => $str) + { + if (!$str) + { + unset($fields[$k]); + continue; + } + + if ($base[$str] != $this->getField($str)) + { + $result |= 1 << $k; + unset($fields[$k]); + } + } + + if (empty($fields)) // all fields diff .. return early + { + $this->reset(); // Generators have no __destruct, reset manually, when not doing a full iteration + return $result; + } + } + + return $result; + } + + public function hasAnySource() : bool + { + if (!isset($this->sources)) + return false; + + foreach ($this->sources as $src) + { + if (!is_array($src)) + continue; + + if (!empty($src)) + return true; + } + + return false; + } +} + +/* + !IMPORTANT! + It is flat out impossible to distinguish between floors for multi-level areas, if the floors overlap each other! + The coordinates generated by the script WILL be on every level and will have to be removed MANUALLY! + + impossible := you are not keen on reading wmo-data; +*/ +trait spawnHelper +{ + private $spawnResult = array( + SPAWNINFO_FULL => null, + SPAWNINFO_SHORT => null, + SPAWNINFO_ZONES => null, + SPAWNINFO_QUEST => null + ); + + private function createShortSpawns() : void // [zoneId, floor, [[x1, y1], [x2, y2], ..]] as tooltip2 if enabled by or anchor #map (one area, one floor, one creature, no survivors) + { + $this->spawnResult[SPAWNINFO_SHORT] = new \StdClass; + + // first get zone/floor with the most spawns + if ($res = DB::Aowow()->selectRow('SELECT `areaId`, `floor` FROM ::spawns WHERE `type` = %i AND `typeId` = %i AND `posX` > 0 AND `posY` > 0 GROUP BY `areaId`, `floor` ORDER BY COUNT(1) DESC LIMIT 1', self::$type, $this->id)) + { + // get relevant spawn points + $points = DB::Aowow()->selectAssoc('SELECT `posX`, `posY` FROM ::spawns WHERE `type` = %i AND `typeId` = %i AND `areaId` = %i AND `floor` = %i AND `posX` > 0 AND `posY` > 0', self::$type, $this->id, $res['areaId'], $res['floor']); + $spawns = []; + foreach ($points as $p) + $spawns[] = [$p['posX'], $p['posY']]; + + $this->spawnResult[SPAWNINFO_SHORT]->zone = $res['areaId']; + $this->spawnResult[SPAWNINFO_SHORT]->coords = [$res['floor'] => $spawns]; + } + } + + // for display on map (object/npc detail page) + private function createFullSpawns(bool $skipWPs = false, bool $skipAdmin = false, bool $hasLabel = false, bool $hasLink = false) : void + { + $data = []; + $wpSum = []; + $wpIdx = 0; + $worldPos = []; + $spawns = DB::Aowow()->selectAssoc( + 'SELECT CASE WHEN z.`type` = %i THEN 1 + WHEN z.`type` = %i THEN 2 + WHEN z.`type` = %i THEN 2 + ELSE 0 + END AS "mapType", s.* + FROM ::spawns s + JOIN ::zones z ON s.areaId = z.id + WHERE s.`type` = %i AND s.`typeId` IN %in AND s.`posX` > 0 AND s.`posY` > 0', + MAP_TYPE_DUNGEON_HC, MAP_TYPE_MMODE_RAID, MAP_TYPE_MMODE_RAID_HC, + self::$type, $this->getFoundIDs() + ) ?: []; + + if (!$skipAdmin && User::isInGroup(U_GROUP_MODERATOR)) + if ($guids = array_column(array_filter($spawns, fn($x) => $x['guid'] > 0 || $x['type'] != Type::NPC), 'guid')) + $worldPos = WorldPosition::getForGUID(self::$type, ...$guids); + + foreach ($spawns as $s) + { + $isAccessory = $s['guid'] < 0 && $s['type'] == Type::NPC; + + // check, if we can attach waypoints to creature + // we will get a nice clusterfuck of dots if we do this for more GUIDs, than we have colors though + if (!$skipWPs && count($spawns) < 6 && $s['type'] == Type::NPC) + { + if ($wPoints = DB::Aowow()->selectAssoc('SELECT * FROM ::creature_waypoints WHERE creatureOrPath = %i AND floor = %i', $s['pathId'] ? -$s['pathId'] : $this->id, $s['floor'])) + { + foreach ($wPoints as $i => $p) + { + $label = [Lang::npc('waypoint').Lang::main('colon').$p['point']]; + + if ($p['wait']) + $label[] = Lang::npc('wait').Lang::main('colon').DateTime::formatTimeElapsedFloat($p['wait']); + + $opts = array( // \0 doesn't get printed and tricks Util::toJSON() into handling this as a string .. i feel slightly dirty now + 'label' => "\0$
".implode('
', $label).'
', + 'type' => $wpIdx + ); + + // connective line + if ($i > 0 && $wPoints[$i - 1]['areaId'] == $p['areaId']) + $opts['lines'] = [[$wPoints[$i - 1]['posX'], $wPoints[$i - 1]['posY']]]; + + $data[$p['areaId']][$p['floor']]['coords'][] = [$p['posX'], $p['posY'], $opts]; + if (empty($wpSum[$p['areaId']][$p['floor']])) + $wpSum[$p['areaId']][$p['floor']] = 1; + else + $wpSum[$p['areaId']][$p['floor']]++; + } + $wpIdx++; + } + } + + $opts = $menu = $tt = $info = []; + $footer = ''; + + if ($s['respawn'] > 0) + $info[1] = ''.Lang::npc('respawnIn', [Lang::formatTime($s['respawn'] * 1000, 'game', 'timeAbbrev', true)]).''; + else if ($s['respawn'] < 0) + { + $info[1] = ''.Lang::npc('despawnAfter', [Lang::formatTime(-$s['respawn'] * 1000, 'game', 'timeAbbrev', true)]).''; + $opts['type'] = 4; // make pip purple + } + + if (!$skipAdmin && User::isInGroup(U_GROUP_STAFF)) + { + if ($isAccessory) + $info[0] = 'Vehicle Accessory'; + else if ($s['guid'] > 0 && ($s['type'] == Type::NPC || $s['type'] == Type::OBJECT)) + $info[0] = 'GUID'.Lang::main('colon').$s['guid']; + + if ($s['phaseMask'] > 1 && ($s['phaseMask'] & 0xFFFF) != 0xFFFF) + $info[2] = Lang::game('phases').Lang::main('colon').Util::asHex($s['phaseMask']); + + if ($s['spawnMask'] == 15) + $info[3] = Lang::game('mode').Lang::game('modes', 0, -1); + else if ($s['spawnMask']) + { + $_ = []; + for ($i = 0; $i < 4; $i++) + if ($s['spawnMask'] & 1 << $i) + $_[] = Lang::game('modes', $s['mapType'], $i); + + $info[4] = Lang::game('mode').implode(', ', $_); + } + + if ($s['ScriptName']) + $info[5] = 'ScriptName'.Lang::main('colon').$s['ScriptName']; + if ($s['StringId']) + $info[6] = 'StringId'.Lang::main('colon').$s['StringId']; + + if ($s['type'] == Type::AREATRIGGER) + { + // teleporter endpoint + if ($s['guid'] < 0) + { + $opts['type'] = 4; + $info[7] = 'Teleport Destination'; + } + else + { + $o = Util::O2Deg($this->getField('orientation')); + $info[7] = 'Orientation'.Lang::main('colon').$o[0].'° ('.$o[1].')'; + } + } + + // guid < 0 are vehicle accessories. those are moved by moving the vehicle + if (User::isInGroup(U_GROUP_MODERATOR) && $worldPos && !$isAccessory && isset($worldPos[$s['guid']])) + $menu = Util::buildPosFixMenu($worldPos[$s['guid']]['mapId'], $worldPos[$s['guid']]['posX'], $worldPos[$s['guid']]['posY'], $s['type'], $s['guid'], $s['areaId'], $s['floor']); + + if ($menu) + $footer = '
Click to move pin'; + } + + /* recognized opts + * url: string - makes pin clickable + * tooltip: array - title => [info: lines, footer: line] + * label: string - single line tooltip (skipped if 'tooltip' is set) + * menu: array - menu definiton (conflicts with url) + * type: int - colors the pip [default, green, red, blue, purple] + * lines: array - [[destX, destY]] - draws line from point to dest + */ + + if ($info) + $tt['info'] = $info; + + if ($footer) + $tt['footer'] = $footer; + + if ($tt && $this->getEntry($s['typeId'])) + $opts['tooltip'] = [$this->getField('name', true) => $tt]; + else if ($hasLabel && $this->getEntry($s['typeId'])) + $opts['label'] = $this->getField('name', true); + + if ($hasLink) + $opts['url'] = '?' . Type::getFileString(self::$type) . '=' . $s['typeId']; + + if ($menu) + $opts['menu'] = $menu; + + $data[$s['areaId']] [$s['floor']] ['coords'] [] = [$s['posX'], $s['posY'], $opts]; + } + foreach ($data as $a => &$areas) + foreach ($areas as $f => &$floor) + $floor['count'] = count($floor['coords']) - ($wpSum[$a][$f] ?? 0); + + uasort($data, [$this, 'sortBySpawnCount']); + $this->spawnResult[SPAWNINFO_FULL] = $data; + } + + private function sortBySpawnCount(array $a, array $b) : int + { + $aCount = current($a)['count']; + $bCount = current($b)['count']; + + return $bCount <=> $aCount; // sort descending + } + + private function createZoneSpawns() : void // [zoneId1, zoneId2, ..] for locations-column in listview + { + $res = DB::Aowow()->selectCol("SELECT `typeId` AS ARRAY_KEY, GROUP_CONCAT(DISTINCT `areaId`) FROM ::spawns WHERE `type` = %i AND `typeId` IN %in AND `posX` > 0 AND `posY` > 0 GROUP BY `typeId`", self::$type, $this->getfoundIDs()); + foreach ($res as &$r) + { + $r = explode(',', $r); + if (count($r) > 3) + array_splice($r, 3, count($r), -1); + } + + $this->spawnResult[SPAWNINFO_ZONES] = $res; + } + + private function createQuestSpawns() :void // [zoneId => [floor => [[x1, y1], [x2, y2], ..]]] mapper on quest detail page + { + if (self::$type == Type::SOUND) + return; + + $res = DB::Aowow()->selectAssoc('SELECT `areaId`, `floor`, `typeId`, `posX`, `posY` FROM ::spawns WHERE `type` = %i AND `typeId` IN %in AND `posX` > 0 AND `posY` > 0', self::$type, $this->getFoundIDs()); + $spawns = []; + foreach ($res as $data) + { + // zone => floor => spawnData + // todo (low): why is there a single set of coordinates; which one should be picked, instead of the first? gets used in ShowOnMap.buildTooltip i think + if (!isset($spawns[$data['areaId']][$data['floor']][$data['typeId']])) + { + $spawns[$data['areaId']][$data['floor']][$data['typeId']] = array( + 'type' => self::$type, + 'id' => $data['typeId'], + 'point' => '', // tbd later (start, end, requirement, sourcestart, sourceend, sourcerequirement) + 'name' => Util::localizedString($this->templates[$data['typeId']], 'name'), + 'coord' => [$data['posX'], $data['posY']], + 'coords' => [[$data['posX'], $data['posY']]], + 'objective' => 0, // tbd later (1-4 set a color; id of creature this entry gives credit for) + 'reactalliance' => $this->templates[$data['typeId']]['A'] ?: 0, + 'reacthorde' => $this->templates[$data['typeId']]['H'] ?: 0 + ); + } + else + $spawns[$data['areaId']][$data['floor']][$data['typeId']]['coords'][] = [$data['posX'], $data['posY']]; + } + + $this->spawnResult[SPAWNINFO_QUEST] = $spawns; + } + + public function getSpawns(int $mode, bool ...$info) : array|\StdClass + { + // only Creatures, GOs and SoundEmitters can be spawned + if (!self::$type || !$this->getfoundIDs() || (self::$type != Type::NPC && self::$type != Type::OBJECT && self::$type != Type::SOUND && self::$type != Type::AREATRIGGER)) + return []; + + switch ($mode) + { + case SPAWNINFO_SHORT: + if ($this->spawnResult[SPAWNINFO_SHORT] === null) + $this->createShortSpawns(); + + return $this->spawnResult[SPAWNINFO_SHORT]; + case SPAWNINFO_FULL: + if (empty($this->spawnResult[SPAWNINFO_FULL])) + $this->createFullSpawns(...$info); + + return $this->spawnResult[SPAWNINFO_FULL]; + case SPAWNINFO_ZONES: + if (empty($this->spawnResult[SPAWNINFO_ZONES])) + $this->createZoneSpawns(); + + return !empty($this->spawnResult[SPAWNINFO_ZONES][$this->id]) ? $this->spawnResult[SPAWNINFO_ZONES][$this->id] : []; + case SPAWNINFO_QUEST: + if (empty($this->spawnResult[SPAWNINFO_QUEST])) + $this->createQuestSpawns(); + + return $this->spawnResult[SPAWNINFO_QUEST]; + } + + return []; + } +} + +trait profilerHelper +{ + public static $brickFile = 'profile'; // profile is multipurpose + + private static $subjectGUID = 0; + + public function selectRealms(?array $fi) : bool + { + $this->dbNames = []; + + foreach(Profiler::getRealms() as $idx => $r) + { + if (!empty($fi['sv']) && Profiler::urlize($r['name']) != Profiler::urlize($fi['sv']) && intVal($fi['sv']) != $idx) + continue; + + if (!empty($fi['rg']) && Profiler::urlize($r['region']) != Profiler::urlize($fi['rg'])) + continue; + + $this->dbNames[$idx] = 'Characters'; + } + + return !!$this->dbNames; + } +} + +trait sourceHelper +{ + protected array $sources = []; + protected ?array $sourceMore = null; + + public function getRawSource(int $src) : array + { + return $this->sources[$this->id][$src] ?? []; + } + + public function getSources(?array &$s = [], ?array &$sm = []) : bool + { + $s = $sm = []; + if (empty($this->sources[$this->id])) + return false; + + if ($this->sourceMore === null) + { + $buff = []; + $this->sourceMore = []; + + foreach ($this->iterate() as $_curTpl) + if ($_curTpl['moreType'] && $_curTpl['moreTypeId']) + $buff[$_curTpl['moreType']][] = $_curTpl['moreTypeId']; + + foreach ($buff as $type => $ids) + $this->sourceMore[$type] = Type::newList($type, [['id', $ids]]); + } + + $s = array_keys($this->sources[$this->id]); + if ($this->curTpl['moreType'] && $this->curTpl['moreTypeId'] && ($srcData = $this->sourceMore[$this->curTpl['moreType']]->getSourceData($this->curTpl['moreTypeId']))) + $sm = $srcData[$this->curTpl['moreTypeId']]; + else if (!empty($this->sources[$this->id][SRC_PVP])) + $sm['p'] = $this->sources[$this->id][SRC_PVP][0]; + + if ($z = $this->curTpl['moreZoneId']) + $sm['z'] = $z; + + if ($this->curTpl['moreMask'] & SRC_FLAG_BOSSDROP) + $sm['bd'] = 1; + + if (isset($this->sources[$this->id][SRC_DROP][0])) + { + /* + mode srcFlag log2 dd Flag + 10N/D-NH 0b0001 0 0b001 + 25N/D-HC 0b0010 1 0b010 + 10H 0b0100 2 0b011 + 25H 0b1000 3 0b100 + */ + if ($this->curTpl['moreMask'] & SRC_FLAG_COMMON) + $sm['dd'] = 99; + else if ($this->curTpl['moreMask'] & SRC_FLAG_DUNGEON_DROP) + $sm['dd'] = $this->sources[$this->id][SRC_DROP][0] * -1; + else if ($this->curTpl['moreMask'] & SRC_FLAG_RAID_DROP) + { + $dd = log($this->sources[$this->id][SRC_DROP][0], 2); + if ($dd == intVal($dd)) // only one bit set + $sm['dd'] = $dd + 1; + } + } + + if ($sm) + $sm = [$sm]; + + return true; + } +} + +?> diff --git a/includes/components/filter.class.php b/includes/components/filter.class.php new file mode 100644 index 00000000..de0339f2 --- /dev/null +++ b/includes/components/filter.class.php @@ -0,0 +1,882 @@ +parentCats[0] = $v; // directly redirect onto this region + $v = ''; // remove from filter + + return true; + } + + return false; + } + + protected function cbServerCheck(string &$v) : bool + { + foreach (Profiler::getRealms() as $realm) + if (Profiler::urlize($realm['name'], true) == $v) + { + $this->parentCats[1] = $v; // directly redirect onto this server + $v = ''; // remove from filter + + return true; + } + + return false; + } +} + +abstract class Filter +{ + private static $wCards = ['*' => '%', '?' => '_']; + + public const CR_BOOLEAN = 1; + public const CR_FLAG = 2; + public const CR_NUMERIC = 3; + public const CR_STRING = 4; + public const CR_ENUM = 5; + public const CR_STAFFFLAG = 6; + public const CR_CALLBACK = 7; + public const CR_NYI_PH = 999; + + public const V_EQUAL = 8; + public const V_RANGE = 9; + public const V_LIST = 10; + public const V_CALLBACK = 11; + public const V_REGEX = 12; + public const V_NAME = 13; + + protected const ENUM_ANY = -2323; + protected const ENUM_NONE = -2324; + + protected const PATTERN_NAME = '/[\p{C};%\\\\]/ui'; + protected const PATTERN_CRV = '/[\p{C};:%\\\\]/ui'; + protected const PATTERN_INT = '/\D/'; + public const PATTERN_PARAM = '/^[\p{L}\p{Sm} \d\p{P}]+$/ui'; + public const PATTERN_FT = '/[^[:alpha:] \d_]/iu'; // +-*<>@()~" have special meaning; ' seems to fuck up the search; other irregular cases? + + protected const ENUM_FACTION = array( 469, 1037, 1106, 529, 1012, 87, 21, 910, 609, 942, 909, 530, 69, 577, 930, 1068, 1104, 729, 369, 92, + 54, 946, 67, 1052, 749, 47, 989, 1090, 1098, 978, 1011, 93, 1015, 1038, 76, 470, 349, 1031, 1077, 809, + 911, 890, 970, 169, 730, 72, 70, 932, 1156, 933, 510, 1126, 1067, 1073, 509, 941, 1105, 990, 934, 935, + 1094, 1119, 1124, 1064, 967, 1091, 59, 947, 81, 576, 922, 68, 1050, 1085, 889, 589, 270); + protected const ENUM_CURRENCY = array(32572, 32569, 29736, 44128, 20560, 20559, 29434, 37829, 23247, 44990, 24368, 43016, 41596, 34052, 45624, 49426, 40752, 47241, 40753, 29024, + 24245, 26045, 26044, 38425, 29735, 24579, 24581, 32897, 22484, 4291, 28558, 43228, 34664, 37836, 20558, 34597, 43589); + protected const ENUM_EVENT = array( 372, 283, 285, 353, 420, 400, 284, 201, 374, 409, 141, 324, 321, 424, 423, 327, 341, 181, 404, 398, + 301); + protected const ENUM_ZONE = array( 36, 45, 3, 4, 46, 41, 2257, 1, 10, 139, 12, 3430, 3433, 267, 1537, 4080, 38, 4298, 44, 51, + 3487, 130, 1519, 33, 8, 47, 85, 1497, 28, 40, 11, 331, 16, 3524, 3525, 148, 1657, 405, 14, 15, + 361, 357, 493, 215, 1637, 1377, 406, 440, 141, 17, 3557, 400, 1638, 490, 618, 4494, 3790, 4277, 719, 1584, + 1583, 3713, 1581, 2557, 4196, 721, 4416, 4272, 4820, 4264, 3562, 4131, 3792, 2100, 2367, 4813, 2437, 722, 491, 796, + 2057, 3791, 3789, 209, 3714, 3717, 717, 2017, 1477, 3848, 2366, 3847, 4100, 4809, 3717, 3849, 4265, 4228, 3715, 4723, + 1337, 3716, 206, 1196, 4415, 718, 1176, 3428, 3959, 2677, 3923, 4812, 3457, 3836, 2717, 3456, 2159, 3429, 3607, 3845, + 3606, 4500, 4493, 4987, 4075, 4722, 4273, 4603, 3805, 1977, 2597, 3358, 3820, 4710, 4384, 3277, 3522, 3483, 3518, 3523, + 3520, 3703, 3519, 3521, 3702, 4378, 3698, 3968, 4406, 3537, 2817, 4395, 65, 394, 495, 4742, 210, 3711, 67, 4197, + 66); + protected const ENUM_HEROICDUNGEON = array( 4494, 3790, 4277, 4196, 4416, 4272, 4820, 4264, 3562, 4131, 3792, 2367, 4813, 3791, 3789, 3848, 2366, 3713, 3847, 4100, + 4809, 3849, 4265, 4228, 3714, 3717, 3715, 3716, 4415, 4723, 206, 1196); + protected const ENUM_MULTIMODERAID = array( 4812, 3456, 2159, 4500, 4493, 4722, 4273, 4603, 4987); + protected const ENUM_HEROICRAID = array( 4987, 4812, 4722); + protected const ENUM_CLASSS = array( null, 1, 2, 3, 4, 5, 6, 7, 8, 9, null, 11, true, false); + protected const ENUM_RACE = array( null, 1, 2, 3, 4, 5, 6, 7, 8, null, 10, 11, true, false); + protected const ENUM_PROFESSION = array( null, 171, 164, 185, 333, 202, 129, 755, 165, 186, 197, true, false, 356, 182, 773); + + public bool $error = false; + public bool $shouldReload = false; // erroneous params have been corrected. Build GET string and reload + + // item related + public array $upgrades = []; // [itemId => slotId] + public array $extraOpts = []; // score for statWeights + public array $wtCnd = []; // DBType condition for statWeights + + private array $cndSet = []; // db type query storage + private array $rawData = []; + + protected string $type = ''; // set by child + protected array $parentCats = []; // used to validate ty-filter + protected array $inTokens = []; // text search includes + protected array $exTokens = []; // text search excludes + protected array $ftTokens = []; // fulltext search + + /* $genericFilter: [FILTER_TYPE, colOrFnName, param1, param2] + [self::CR_BOOLEAN, , , null] + [self::CR_FLAG, , , ] # default param2: matchExact + [self::CR_NUMERIC, , , ] + [self::CR_STRING, , , , , ] # param3 ? crv is val in enum : key in enum + [self::CR_STAFFFLAG, , null, null] + [self::CR_CALLBACK, , , ] + [self::CR_NYI_PH, null, , param2] # mostly 1: to ignore this criterium; 0: to fail the whole query + + $inputFields: fieldName => [VALUE_TYPE, checkInfo, fieldIsArray] + [self::V_EQUAL, , ] + [self::V_RANGE, , ] + [self::V_LIST, , ] + [self::V_CALLBACK, , ] + [self::V_REGEX, , ] + [self::V_NAME, , ] + */ + protected static array $genericFilter = []; + protected static array $inputFields = []; // list of input fields defined per page + protected static array $enums = []; // validation for opt lists per page - criteriumID => [validOptionList] + + // express Filters in template + public string $fiInit = ''; // str: filter template (and init html form) + public string $fiType = ''; // str: filter template (set without init) + public array $fiSetCriteria = []; // fn params (cr, crs, crv) + public array $fiSetWeights = []; // fn params (weights, nt, ids, stealth) + public array $fiReputationCols = []; // fn params ([[factionId, factionName], ...]) + public array $fiExtraCols = []; // + public string $query = ''; // as in url query params + public array $values = []; // prefiltered rawData + + // parse the provided request into a usable format + public function __construct(string|array $data, array $opts = []) + { + $this->parentCats = $opts['parentCats'] ?? []; + + // use fn fi_init() if we have a criteria selector, else use var fi_type + if (static::$genericFilter) + $this->fiInit = $this->type; + else + $this->fiType = $this->type; + + if (is_array($data)) + $this->rawData = $data; // could set >query for consistency sake, but is not used when converting from POST + + if (is_string($data)) + { + // an error occured, while processing POST + if (isset($_SESSION['error']['fi'])) + { + $this->error = $_SESSION['error']['fi'] == get_class($this); + unset($_SESSION['error']['fi']); + } + + $this->query = $data; + $this->rawData = $this->transformGET($data); + } + + $this->initFields(); + $this->evalCriteria(); + $this->evalWeights(); + } + + public function mergeCat(array &$cats) : void + { + foreach ($this->parentCats as $idx => $cat) + $cats[$idx] = $cat; + } + + private function &criteriaIterator() : \Generator + { + if (empty($this->values['cr'])) + return; + + for ($i = 0; $i < count($this->values['cr']); $i++) + { + // throws a notice if yielded directly "Only variable references should be yielded by reference" + $v = [&$this->values['cr'][$i], &$this->values['crs'][$i], &$this->values['crv'][$i]]; + yield $i => $v; + } + } + + public static function getCriteriaIndex(int $cr, int|bool $lookup) : ?int + { + // can't use array_search() as bools are valid enum content + foreach (static::$enums[$cr] ?? [] as $k => $v) + if ($v === $lookup) + return $k; + return null; + } + + + /***********************/ + /* get prepared values */ + /***********************/ + + public function buildGETParam(array $override = [], array $addCr = []) : string + { + $get = []; + foreach (array_merge($this->values, $override) as $k => $v) + { + if (isset($addCr[$k])) + { + $v = $v ? array_merge((array)$v, (array)$addCr[$k]) : $addCr[$k]; + unset($addCr[$k]); + } + + if ($v === '' || $v === null || $v === []) + continue; + + $get[$k] = $k.'='.(is_array($v) ? implode(':', $v) : $v); + } + + // no criteria were set, so no merge occured .. append + if ($addCr) + { + $get['cr'] = 'cr='.$addCr['cr']; + $get['crs'] = 'crs='.$addCr['crs']; + $get['crv'] = 'crv='.$addCr['crv']; + } + + return implode(';', $get); + } + + public function getConditions() : array + { + if (!$this->cndSet) + { + // values + $this->cndSet = $this->createSQLForValues(); + + // criteria + $filters = []; + foreach ($this->criteriaIterator() as $_cr) + if ($cnd = $this->createSQLForCriterium(...$_cr)) + $filters[] = $cnd; + + if ($filters) // if a filter uses criteria it must have a [ma]tch selector + { + array_unshift($filters, $this->values['ma'] ? DB::OR : DB::AND); + $this->cndSet[] = $filters; + } + } + + if ($this->cndSet) + array_unshift($this->cndSet, DB::AND); + + return $this->cndSet; + } + + public function getSetCriteria(int ...$cr) : array + { + if (!$cr || empty($this->values['cr'])) + return []; + + return array_values(array_intersect($this->values['cr'], $cr)); + } + + + /**********************/ + /* input sanitization */ + /**********************/ + + private function transformGET(string $get) : array + { + if (!$get) + return []; + + $data = []; + + // someone copy/pasted a WH filter + $get = preg_replace('/^(\d+(:\d+)*);(\d+(:\d+)*);(\P{C}+(:\P{C}+)*)$/', 'cr=$1;crs=$3;crv=$5', $get); + + foreach (explode(';', $get) as $field) + { + if (!strstr($field, '=')) + { + trigger_error('Filter::transformGET - malformed GET string', E_USER_NOTICE); + $this->error = + $this->shouldReload = true; + continue; + } + + [$k, $v] = explode('=', $field); + + if (!isset(static::$inputFields[$k])) + { + trigger_error('Filter::transformGET - GET param not in filter: '.$k, E_USER_NOTICE); + $this->error = + $this->shouldReload = true; + continue; + } + + $asArray = static::$inputFields[$k][2]; + + $data[$k] = $asArray ? explode(':', $v) : $v; + } + + return $data; + } + + private function initFields() : void + { + /* quirks: + * - in the POST step there may be excess criteria selectors with a value of '', as unselecting a criteria that is not the last will not remove the row from the UI + * - if there are no criteria selected, the placeholder selection will always be sent as ['', null, null], similar to the previous quirk + * + * same for stat weights on ItemListFilter + */ + if (!empty($this->rawData['cr'])) + $this->rawData['cr'] = array_filter($this->rawData['cr'], fn($x) => $x !== '') ?: null; + + if (!empty($this->rawData['wt'])) + $this->rawData['wt'] = array_filter($this->rawData['wt'], fn($x) => $x !== '') ?: null; + + $cleanupCr = []; + foreach (static::$inputFields as $inp => [$type, $valid, $asArray]) + { + if (!isset($this->rawData[$inp]) || $this->rawData[$inp] === '') + { + $this->values[$inp] = $asArray ? [] : null; + continue; + } + + $val = $this->rawData[$inp]; + + if ($asArray) + { + $buff = []; + foreach ((array)$val as $i => $v) // can be string|int in POST step if only one value present + { + if (in_array($inp, ['cr', 'crs', 'crv'])) + { + if (!$this->checkInput($type, $valid, $v)) + $cleanupCr[] = $i; + $buff[] = $v; // always assign, gets removed later as tuple + } + else if ($this->checkInput($type, $valid, $v)) + $buff[] = $v; + } + + $this->values[$inp] = $buff; + } + else + $this->values[$inp] = $this->checkInput($type, $valid, $val) ? $val : null; + } + + if ($cleanupCr) + { + $this->error = + $this->shouldReload = true; + + foreach (array_unique($cleanupCr) as $i) + unset($this->values['cr'][$i], $this->values['crs'][$i], $this->values['crv'][$i]); + + $this->values['cr'] = array_values($this->values['cr']); + $this->values['crs'] = array_values($this->values['crs']); + $this->values['crv'] = array_values($this->values['crv']); + } + } + + private function evalCriteria() : void // [cr]iterium, [cr].[s]ign, [cr].[v]alue + { + if (empty($this->values['cr']) && empty($this->values['crs']) && empty($this->values['crv'])) + return; + + if (empty($this->values['cr']) || empty($this->values['crs']) || empty($this->values['crv'])) + { + trigger_error('Filter::evalCriteria - one of cr, crs, crv is missing', E_USER_NOTICE); + unset($this->values['cr'], $this->values['crs'], $this->values['crv']); + + $this->error = + $this->shouldReload = true; + return; + } + + $_cr = &$this->values['cr']; + $_crs = &$this->values['crs']; + $_crv = &$this->values['crv']; + + if (count($_cr) != count($_crv) || count($_cr) != count($_crs) || count($_cr) > 5 || count($_crs) > 5 /*|| count($_crv) > 5*/) + { + // use min provided criterion as basis; 5 criteria at most + $min = min(5, count($_cr), count($_crv), count($_crs)); + if (count($_cr) > $min) + array_splice($_cr, $min); + + if (count($_crv) > $min) + array_splice($_crv, $min); + + if (count($_crs) > $min) + array_splice($_crs, $min); + + trigger_error('Filter::evalCriteria - cr, crs, crv are imbalanced', E_USER_NOTICE); + $this->error = + $this->shouldReload = true; + } + + for ($i = 0; $i < count($_cr); $i++) + { + if (!isset(static::$genericFilter[$_cr[$i]]) || $_crs[$i] === '' || $_crv[$i] === '') + { + if ($_crs[$i] === '' || $_crv[$i] === '') + trigger_error('Filter::evalCriteria - received malformed criterium ["'.$_cr[$i].'", "'.$_crs[$i].'", "'.$_crv[$i].'"]', E_USER_NOTICE); + else + trigger_error('Filter::evalCriteria - received unhandled criterium: '.$_cr[$i], E_USER_NOTICE); + + unset($_cr[$i], $_crs[$i], $_crv[$i]); + + $this->error = + $this->shouldReload = true; + continue; + } + + [$crType, $colOrFn, $param1, $param2] = array_pad(static::$genericFilter[$_cr[$i]], 4, null); + + // conduct filter specific checks & casts here + switch ($crType) + { + case self::CR_NUMERIC: + $_ = $_crs[$i]; + if (Util::checkNumeric($_crv[$i], $param1) && $this->int2Op($_)) + continue 2; + break; + case self::CR_BOOLEAN: + case self::CR_FLAG: + $_ = $_crs[$i]; + if ($this->int2Bool($_)) + continue 2; + break; + case self::CR_STAFFFLAG: + if (User::isInGroup(U_GROUP_EMPLOYEE) && Util::checkNumeric($_crs[$i], NUM_CAST_INT)) + continue 2; + break; + case self::CR_ENUM: + if (Util::checkNumeric($_crs[$i], NUM_CAST_INT) && ( + (!$param2 && isset(static::$enums[$_cr[$i]][$_crs[$i]])) || + ($param2 && in_array($_crs[$i], static::$enums[$_cr[$i]])) || + ($param1 && ($_crs[$i] == self::ENUM_ANY || $_crs[$i] == self::ENUM_NONE)) + )) + continue 2; + break; + case self::CR_STRING: + if ($param1 & STR_LOCALIZED) + $colOrFn .= '_loc'.Lang::getLocale()->value; + + if ($this->tokenizeString($colOrFn, $_crv[$i], $param1 & STR_MATCH_EXACT, $param1 & STR_ALLOW_SHORT)) + continue 2; + break; + case self::CR_CALLBACK: + case self::CR_NYI_PH: + continue 2; + default: + trigger_error('Filter::evalCriteria - unknown criteria type: '.$crType, E_USER_WARNING); + break; + } + + trigger_error('Filter::evalCriteria - generic check failed ["'.$_cr[$i].'", "'.$_crs[$i].'", "'.$_crv[$i].'"]', E_USER_NOTICE); + unset($_cr[$i], $_crs[$i], $_crv[$i]); + + $this->error = + $this->shouldReload = true; + } + + $this->fiSetCriteria = [$_cr, $_crs, $_crv]; + } + + private function evalWeights() : void + { + // both empty: not in use; not an error + if (empty($this->values['wt']) && empty($this->values['wtv'])) + return; + + // one empty: erroneous manual input? + if (!$this->values['wt'] || !$this->values['wtv']) + { + trigger_error('Filter::setWeights - one of wt, wtv is missing', E_USER_NOTICE); + unset($this->values['wt'], $this->values['wtv']); + + $this->error = + $this->shouldReload = true; + return; + } + + $_wt = &$this->values['wt']; + $_wtv = &$this->values['wtv']; + + $nwt = count($_wt); + $nwtv = count($_wtv); + + if ($nwt != $nwtv) + { + trigger_error('Filter::setWeights - wt, wtv are imbalanced', E_USER_NOTICE); + $this->error = + $this->shouldReload = true; + } + + if ($nwt > $nwtv) + array_splice($_wt, $nwtv); + else if ($nwtv > $nwt) + array_splice($_wtv, $nwt); + + $this->fiSetWeights = [$_wt, $_wtv]; + } + + protected function checkInput(int $type, mixed $checkInfo, mixed &$val, bool $recursive = false) : bool + { + switch ($type) + { + case self::V_EQUAL: + if (gettype($checkInfo) == 'integer') + $val = intval($val); + else if (gettype($checkInfo) == 'double') + $val = floatval($val); + else /* if (gettype($checkInfo) == 'string') */ + $val = strval($val); + + if ($checkInfo == $val) + return true; + + break; + case self::V_LIST: + if (!Util::checkNumeric($val, NUM_CAST_INT)) + return false; + + if (in_array($val, $checkInfo)) + return true; + + foreach ($checkInfo as $v) + { + if (gettype($v) != 'array') + continue; + + if ($this->checkInput(self::V_RANGE, $v, $val, true)) + return true; + } + + break; + case self::V_RANGE: + if (Util::checkNumeric($val, NUM_CAST_INT) && $val >= $checkInfo[0] && $val <= $checkInfo[1]) + return true; + + break; + case self::V_CALLBACK: + if ($this->$checkInfo($val)) + return true; + + break; + case self::V_REGEX: + if (!preg_match($checkInfo, $val)) + return true; + + break; + case self::V_NAME: + if (preg_match(self::PATTERN_NAME, $val)) + break; + + if (!$this->tokenizeString('na', $val, $checkInfo && $this->values['ex'])) + return false; // quit without logging more errors + + return true; + } + + if (!$recursive) + { + trigger_error('Filter::checkInput - check failed [type: '.$type.' valid: '.Util::toString($checkInfo).' val: '.((string)$val).']', E_USER_NOTICE); + $this->error = true; + } + + return false; + } + + public static function transformToken(string &$string, bool $allowShort = false) : ?array + { + $lenTest = fn($x) => $x !== '' && (mb_strlen($x) > 2 || $allowShort || Lang::getLocale()->isLogographic()); + $string = trim($string); + + if ($string === '') + return null; + + // invalid chars for both LIKE and MATCH + $str = str_replace(['\\', '%'], '', $string); + + if ($neg = ($str[0] === '-')) + $str = mb_substr($str, 1); + + if (!$lenTest($str)) + return null; + + // if the fulltext token contains invalid chars, should it be sub-tokenized (current behavior) or should the chars just be stripped + if ($tok = explode(' ', preg_replace(self::PATTERN_FT, ' ', $str))) + { + $ft = array_filter($tok, $lenTest); + + if (count($tok) > 1) + $ft[] = implode('', $tok); + } + + // escape manually entered _; entering % should be prohibited + // then replace search wildcards with sql wildcards + $lk = strtr(str_replace('_', '\\_', $str), self::$wCards); + + return [$lk, $ft, $neg]; + } + + protected function tokenizeString(string $field, string $string, bool $exact = false, bool $allowShort = false) : bool + { + // always allow sub 3 chars for logographic locales + if (Lang::getLocale()->isLogographic()) + $allowShort = true; + + $tokens = $exact ? [$string] : explode(' ', $string); + foreach ($tokens as $t) + { + if ([$like, $fulltext, $ex] = self::transformToken($t, $allowShort)) + { + if ($like) + $this->{$ex ? 'exTokens' : 'inTokens'}[$field][] = $like; + + // don't bother with fulltext search if exact is specified + if ($exact) + continue; + + // note: a fulltext search purely from exclude tokens will return no result + foreach ($fulltext as $ft) + $this->ftTokens[$field][] = ($ex ? '-' : '+') . '(' . $ft . '* ' . Util::strrev($ft) . '*)'; + } + } + + if (empty($this->inTokens[$field])) + { + trigger_error('Filter::tokenizeString - could not tokenize string: "'.$string.'" for input: '.$field, E_USER_NOTICE); + $this->error = true; + return false; + } + + return true; + } + + protected function buildLikeLookup(array $fields, bool $exact = false) : array + { + $qry = []; + foreach ($fields as [$field, $col]) + { + $sub = []; + if (!empty($this->inTokens[$field])) + $sub = array_merge($sub, array_map(fn($x) => [$col, $x, $exact ? null : 'LIKE'], $this->inTokens[$field])); + if (!empty($this->exTokens[$field])) + $sub = array_merge($sub, array_map(fn($x) => [$col, $x, $exact ? null : 'NOT LIKE'], $this->exTokens[$field])); + + if (count($sub) > 1) + array_unshift($sub, DB::AND); + else if ($sub) + $sub = $sub[0]; + + if ($sub) + $qry[] = $sub; + } + + return $qry ? [DB::OR, ...$qry] : []; + } + + protected function buildMatchLookup(array $fields, bool $exact = false) : array + { + if (Lang::getLocale()->isLogographic() && !Cfg::get('LOGOGRAPHIC_FT_SEARCH')) + return []; + + $qry = []; + foreach ($fields as [$field, $col]) + { + if (!empty($this->ftTokens[$field])) + $qry[] = [$col, array_unique($this->ftTokens[$field]), 'MATCH']; + else + { + $tok = $this->values[$field]; + if (self::transformToken($tok)) + { + if (!is_array($col)) + $qry[] = [$col, $tok]; + else + foreach ($col as $c) + $qry[] = [$c, $tok]; + } + } + } + + return $qry ? [DB::OR, ...$qry] : []; + } + + protected function int2Op(mixed &$op) : bool + { + $op = match ($op) { + 1 => '>', + 2 => '>=', + 3 => '=', + 4 => '<=', + 5 => '<', + 6 => '!=', + default => null + }; + + return $op !== null; + } + + protected function int2Bool(mixed &$op) : bool + { + $op = match ($op) { + 1 => true, + 2 => false, + default => null + }; + + return $op !== null; + } + + protected function list2Mask(array $list, bool $noOffset = false) : int + { + $mask = 0x0; + $o = $noOffset ? 0 : 1; // schoolMask requires this..? + + foreach ($list as $itm) + $mask += (1 << (intval($itm) - $o)); + + return $mask; + } + + + /**************************/ + /* create conditions from */ + /* generic criteria */ + /**************************/ + + private function genericBoolean(string $field, int $op, bool $isString) : ?array + { + if ($this->int2Bool($op)) + { + $value = $isString ? '' : 0; + $operator = $op ? '!' : null; + + return [$field, $value, $operator]; + } + + return null; + } + + private function genericBooleanFlags(string $field, int $value, int $op, ?bool $matchAny = false) : ?array + { + if (!$this->int2Bool($op)) + return null; + + if (!$op) + return [[$field, $value, '&'], 0]; + else if ($matchAny) + return [[$field, $value, '&'], 0, '!']; + else + return [[$field, $value, '&'], $value]; + } + + private function genericString(string $field, ?int $strFlags, ?string $fulltextCol = null) : ?array + { + $strFlags ??= 0x0; + + $lkCol = $field; + if ($strFlags & STR_LOCALIZED) + $lkCol .= '_loc'.Lang::getLocale()->value; + + $lookup = null; + if ($fulltextCol) + $lookup = $this->buildMatchLookup([[$lkCol, $fulltextCol]]); + + return $lookup ?: ($field ? $this->buildLikeLookup([[$lkCol, $lkCol]]) : null); + } + + private function genericNumeric(string $field, int|float $value, int $op, int $typeCast) : ?array + { + if (!Util::checkNumeric($value, $typeCast)) + return null; + + if ($this->int2Op($op)) + return [$field, $value, $op]; + + return null; + } + + private function genericEnum(string $field, mixed $value) : ?array + { + if (is_bool($value)) + return [$field, 0, ($value ? '>' : '<=')]; + else if ($value == self::ENUM_ANY) + return [$field, 0, '!']; + else if ($value == self::ENUM_NONE) + return [$field, 0]; + else if ($value !== null) + return [$field, $value]; + + return null; + } + + + /***********************************/ + /* create conditions from */ + /* non-generic values and criteria */ + /***********************************/ + + protected function createSQLForCriterium(int $cr, int $crs, string $crv) : array + { + if (!static::$genericFilter) // criteria not in use - no error + return []; + + [$crType, $colOrFn, $param1, $param2] = array_pad(static::$genericFilter[$cr], 4, null); + + $handleEnum = function(int $cr, int $crs, string $col, ?bool $hasAnyNone, ?bool $crsAsVal) : ?array + { + if ($hasAnyNone && ($crs == self::ENUM_ANY || $crs == self::ENUM_NONE)) + return $this->genericEnum($col, $crs); + else if (!$crsAsVal && isset(static::$enums[$cr][$crs])) + return $this->genericEnum($col, static::$enums[$cr][$crs]); + else if ($crsAsVal && in_array($crs, static::$enums[$cr])) + return $this->genericEnum($col, $crs); + + return null; + }; + + $handleNYIPH = function(int $crs, string $crv, ?int $forceResult) : ?array + { + if (is_int($forceResult)) + return [$forceResult]; + + // for nonsensical values; compare against 0 + if ($this->int2Op($crs) && Util::checkNumeric($crv)) + { + if ($crs == '=') + $crs = '=='; + + return eval('return ('.$crv.' '.$crs.' 0);') ? [1] : [0]; + } + else + return [0]; + }; + + $result = match ($crType) + { + self::CR_NUMERIC => $this->genericNumeric($colOrFn, $crv, $crs, $param1), + self::CR_FLAG => $this->genericBooleanFlags($colOrFn, $param1, $crs, $param2), + self::CR_STAFFFLAG => $this->genericBooleanFlags($colOrFn, (1 << ($crs - 1)), true), + self::CR_BOOLEAN => $this->genericBoolean($colOrFn, $crs, !empty($param1)), + self::CR_STRING => $this->genericString($colOrFn, $param1, $param2), + self::CR_CALLBACK => $this->{$colOrFn}($cr, $crs, $crv, $param1, $param2), + self::CR_ENUM => $handleEnum($cr, $crs, $colOrFn, $param1, $param2), + self::CR_NYI_PH => $handleNYIPH($crs, $crv, $param1), + default => null + }; + + if (!$result) + { + // this really should not have happened. The relevant checks are run on __construct() + trigger_error('Filter::createSQLForCriterium - failed to resolve criterium: ["'.$cr.'", "'.$crs.'", "'.$crv.'"]', E_USER_WARNING); + return []; + } + + if ($crType == self::CR_NUMERIC && !empty($param2)) + $this->fiExtraCols[] = $cr; + + return $result; + } + + abstract protected function createSQLForValues() : array; +} + +?> diff --git a/includes/components/frontend/announcement.class.php b/includes/components/frontend/announcement.class.php new file mode 100644 index 00000000..16382009 --- /dev/null +++ b/includes/components/frontend/announcement.class.php @@ -0,0 +1,69 @@ +editable = true; + + if ($this->mode != self::MODE_PAGE_TOP && $this->mode != self::MODE_CONTENT_TOP) + $this->mode = self::MODE_PAGE_TOP; + + if ($status != self::STATUS_DISABLED && $status != self::STATUS_ENABLED && $status != self::STATUS_DELETED) + $this->status = self::STATUS_DELETED; + else + $this->status = $status; + } + + public function jsonSerialize() : array + { + $json = array( + 'parent' => 'announcement-' . $this->id, + 'id' => $this->editable ? -$this->id : $this->id, + 'mode' => $this->mode, + 'status' => $this->status, + 'name' => $this->name, + 'text' => (string)$this->text // force LocString to naive string for display + ); + + if ($this->style) + $json['style'] = $this->style; + + return $json; + } + + public function __toString() : string + { + if ($this->status == self::STATUS_DELETED) + return ''; + + return "new Announcement(".Util::toJSON($this).");\n"; + } +} + +?> diff --git a/includes/components/frontend/book.class.php b/includes/components/frontend/book.class.php new file mode 100644 index 00000000..b1ef62e7 --- /dev/null +++ b/includes/components/frontend/book.class.php @@ -0,0 +1,50 @@ +parent) + trigger_error(self::class.'::__construct - initialized without parent element', E_USER_WARNING); + + if (!$this->pages) + trigger_error(self::class.'::__construct - initialized without content', E_USER_WARNING); + else + $this->pages = Util::parseHtmlText($this->pages); + } + + public function &iterate() : \Generator + { + reset($this->pages); + + foreach ($this->pages as $idx => &$page) + yield $idx => $page; + } + + public function jsonSerialize() : array + { + $result = []; + + foreach ($this as $prop => $val) + if ($val !== null && $prop[0] != '_') + $result[$prop] = $val; + + return $result; + } + + public function __toString() : string + { + return "new Book(".Util::toJSON($this).");\n"; + } +} + +?> diff --git a/includes/components/frontend/iconelement.class.php b/includes/components/frontend/iconelement.class.php new file mode 100644 index 00000000..6014bd31 --- /dev/null +++ b/includes/components/frontend/iconelement.class.php @@ -0,0 +1,161 @@ +quality = 'q'.$quality; + else if ($quality !== null) + $this->quality = 'q'; + else + $this->quality = ''; + + if ($size < self::SIZE_SMALL || $size > self::SIZE_LARGE) + { + trigger_error('IconElement::__construct - invalid icon size '.$size.'. Normalied to 1 [small]', E_USER_WARNING); + $this->size = self::SIZE_SMALL; + } + else + $this->size = $size; + + if ($align && !in_array($align, ['left', 'right', 'center', 'justify'])) + { + trigger_error('IconElement::__construct - unset invalid align value "'.$align.'".', E_USER_WARNING); + $this->align = null; + } + else + $this->align = $align; + + if ($type && $typeId && !Type::validateIds($type, $typeId)) + { + $link = false; + trigger_error('IconElement::__construct - invalid typeId '.$typeId.' for '.Type::getFileString($type).'.', E_USER_WARNING); + } + else if (!$type || !$typeId) + $link = false; + + if ($link || $url) + $this->href = $url ?: '?'.Type::getFileString($this->type).'='.$this->typeId; + else + $this->href = null; + + // see Spell/Tools having icon container but no actual icon and having to be inline with other IconElements + $this->noIcon = !$typeId || !Type::hasIcon($type); + } + + public function renderContainer(int $lpad = 0, int &$iconIdxOffset = 0, bool $rowWrap = false) : string + { + if (!$this->noIcon) + $this->idx = ++$iconIdxOffset; + + $dom = new \DOMDocument('1.0', 'UTF-8'); + + $td = $dom->createElement('td'); + $th = $dom->createElement('th'); + + if ($this->noIcon) // see Spell/Tools or AchievementCriteria having no actual icon, but placeholder + { + $ul = $dom->createElement('ul'); + $li = $dom->createElement('li'); + $var = $dom->createElement('var', ' '); + $li->appendChild($var); + $ul->appendChild($li); + $th->appendChild($ul); + } + else + { + $th->setAttribute('id', $this->element . $this->idx); + if ($this->align) + $th->setAttribute('align', $this->align); + } + + if ($this->href) + ($a = $dom->createElement('a', htmlentities($this->text)))->setAttribute('href', $this->href); + else + $a = $dom->createTextNode($this->text); + + if ($this->quality) + { + ($sp = $dom->createElement('span'))->setAttribute('class', $this->quality); + $sp->appendChild($a); + $td->appendChild($sp); + } + else + $td->appendChild($a); + + // extraText can be HTML, so import it as a fragment + if ($this->extraText) + { + $fragment = $dom->createDocumentFragment(); + $fragment->appendXML(' '.$this->extraText); + $td->appendChild($fragment); + } + // only for objectives list..? + if ($this->num && $this->size == self::SIZE_SMALL) + $td->appendChild($dom->createTextNode(' ('.$this->num.')')); + + if ($rowWrap) + { + $tr = $dom->createElement('tr'); + $tr->appendChild($th); + $tr->appendChild($td); + $dom->append($tr); + } + else + $dom->append($th, $td); + + return str_repeat(' ', $lpad) . $dom->saveHTML(); + } + + // $WH.ge('icontab-icon1').appendChild(g_spells.createIcon(40120, 1, '1-4', 0)); + + public function renderJS(int $lpad = 0) : string + { + if ($this->noIcon) + return ''; + + $params = [$this->typeId, $this->size]; + if ($this->num || $this->qty) + $params[] = is_int($this->num) ? $this->num : "'".$this->num."'"; + if ($this->qty) + $params[] = is_int($this->qty) ? $this->qty : "'".$this->qty."'"; + + return str_repeat(' ', $lpad) . sprintf(self::CREATE_ICON_TPL, $this->element, $this->idx, Type::getJSGlobalString($this->type), implode(', ', $params)); + } +} + +?> diff --git a/includes/components/frontend/infoboxmarkup.class.php b/includes/components/frontend/infoboxmarkup.class.php new file mode 100644 index 00000000..dff22075 --- /dev/null +++ b/includes/components/frontend/infoboxmarkup.class.php @@ -0,0 +1,70 @@ += count($this->items)) + $this->items[] = $item; + else + array_splice($this->items, $pos, 0, $item); + } + + public function append(string $text) : self + { + if ($_ = $this->prepare()) + $this->replace($_); + + return parent::append($text); + } + + public function __toString() : string + { + // inject before output to avoid adding it to cache + if ($this->completionRowType && User::getCharacters()) + $this->items[] = [Lang::profiler('completion') . '[span class="compact-completion-display"][/span]', ['style' => 'display:none']]; + + if ($_ = $this->prepare()) + $this->replace($_); + + return parent::__toString(); + } + + public function getJsGlobals() : array + { + if ($_ = $this->prepare()) + $this->replace($_); + + return parent::getJsGlobals(); + } + + private function prepare() : string + { + if (!$this->items || $this->__text) + return ''; + + $buff = ''; + foreach ($this->items as $row) + { + if (is_array($row)) + $buff .= '[li'.Util::nodeAttributes($row[1]).']' . $row[0] . '[/li]'; + else if (is_string($row)) + $buff .= '[li]' . $row . '[/li]'; + } + + return $buff ? '[ul]'.$buff.'[/ul]' : ''; + } +} + +?> diff --git a/includes/components/frontend/listview.class.php b/includes/components/frontend/listview.class.php new file mode 100644 index 00000000..0c6da5c8 --- /dev/null +++ b/includes/components/frontend/listview.class.php @@ -0,0 +1,191 @@ + ['template' => 'achievement', 'id' => 'achievements', 'name' => '$LANG.tab_achievements' ], + 'areatrigger' => ['template' => 'areatrigger', 'id' => 'areatrigger', ], + 'calendar' => ['template' => 'holidaycal', 'id' => 'calendar', 'name' => '$LANG.tab_calendar' ], + 'class' => ['template' => 'classs', 'id' => 'classes', 'name' => '$LANG.tab_classes' ], + 'commentpreview' => ['template' => 'commentpreview', 'id' => 'comments', 'name' => '$LANG.tab_comments' ], + 'npc' => ['template' => 'npc', 'id' => 'npcs', 'name' => '$LANG.tab_npcs' ], + 'currency' => ['template' => 'currency', 'id' => 'currencies', 'name' => '$LANG.tab_currencies' ], + 'emote' => ['template' => 'emote', 'id' => 'emotes', ], + 'enchantment' => ['template' => 'enchantment', 'id' => 'enchantments', ], + 'event' => ['template' => 'holiday', 'id' => 'holidays', 'name' => '$LANG.tab_holidays' ], + 'faction' => ['template' => 'faction', 'id' => 'factions', 'name' => '$LANG.tab_factions' ], + 'genericmodel' => ['template' => 'genericmodel', 'id' => 'same-model-as', 'name' => '$LANG.tab_samemodelas' ], + 'icongallery' => ['template' => 'icongallery', 'id' => 'icons', ], + 'item' => ['template' => 'item', 'id' => 'items', 'name' => '$LANG.tab_items' ], + 'itemset' => ['template' => 'itemset', 'id' => 'itemsets', 'name' => '$LANG.tab_itemsets' ], + 'mail' => ['template' => 'mail', 'id' => 'mails', ], + 'model' => ['template' => 'model', 'id' => 'gallery', 'name' => '$LANG.tab_gallery' ], + 'object' => ['template' => 'object', 'id' => 'objects', 'name' => '$LANG.tab_objects' ], + 'pet' => ['template' => 'pet', 'id' => 'hunter-pets', 'name' => '$LANG.tab_pets' ], + 'profile' => ['template' => 'profile', 'id' => 'profiles', 'name' => '$LANG.tab_profiles' ], + 'quest' => ['template' => 'quest', 'id' => 'quests', 'name' => '$LANG.tab_quests' ], + 'race' => ['template' => 'race', 'id' => 'races', 'name' => '$LANG.tab_races' ], + 'replypreview' => ['template' => 'replypreview', 'id' => 'comment-replies', 'name' => '$LANG.tab_commentreplies'], + 'reputationhistory' => ['template' => 'reputationhistory', 'id' => 'reputation', 'name' => '$LANG.tab_reputation' ], + 'screenshot' => ['template' => 'screenshot', 'id' => 'screenshots', 'name' => '$LANG.tab_screenshots' ], + 'skill' => ['template' => 'skill', 'id' => 'skills', 'name' => '$LANG.tab_skills' ], + 'sound' => ['template' => 'sound', 'id' => 'sounds', 'name' => '$LANG.types[19][2]' ], + 'spell' => ['template' => 'spell', 'id' => 'spells', 'name' => '$LANG.tab_spells' ], + 'title' => ['template' => 'title', 'id' => 'titles', 'name' => '$LANG.tab_titles' ], + 'topusers' => ['template' => 'topusers', 'id' => 'topusers', 'name' => '$LANG.topusers' ], + 'video' => ['template' => 'video', 'id' => 'videos', 'name' => '$LANG.tab_videos' ], + 'zone' => ['template' => 'zone', 'id' => 'zones', 'name' => '$LANG.tab_zones' ], + 'guide' => ['template' => 'guide', 'id' => 'guides', ] + ); + + private string $id = ''; + private ?string $name = null; + private ?array $data = null; // js:array of object + private ?string $tabs = null; // js:Object; instance of "Tabs" + private ?string $parent = 'lv-generic'; // HTMLNode.id; can be null but is pretty much always 'lv-generic' + private ?string $template = null; + private ?int $mode = null; // js:int; defaults to MODE_DEFAULT + private ?string $note = null; // text in top band + + private ?int $poundable = null; // 0 (no); 1 (always); 2 (yes, w/o sorting); defaults to 1 + private ?int $searchable = null; // js:bool; defaults to FALSE + private ?int $filtrable = null; // js:bool; defaults to FALSE + private ?int $sortable = null; // js:bool; defaults to FALSE + private ?int $searchDelay = null; // in ms; defalts to 333 + private ?int $clickable = null; // js:bool; defaults to TRUE + private ?int $hideBands = null; // js:int; 1:top, 2:bottom, 3:both; + private ?int $hideNav = null; // js:int; 1:top, 2:bottom, 3:both; + private ?int $hideHeader = null; // js:bool + private ?int $hideCount = null; // js:bool + private ?int $debug = null; // js:bool + private ?int $_truncated = null; // js:bool; adds predefined note to top band, because there was too much data to display + private ?int $_errors = null; // js:bool; adds predefined note to top band, because there was an error + private ?int $_petTalents = null; // js:bool; applies modifier for talent levels + + private ?int $nItemsPerPage = null; // js:int; defaults to 50 + private ?int $_totalCount = null; // js:int; used by loot and comments + private ?array $clip = null; // js:array of int {w:, h:} + private ?string $customPound = null; + private ?string $genericlinktype = null; // sometimes set when expecting to display model + private ?array $_upgradeIds = null; // js:array of int (itemIds) + + private null|array|string $extraCols = null; // js:callable or js:array of object + private null|array|string $visibleCols = null; // js:callable or js:array of string + private null|array|string $hiddenCols = null; // js:callable or js:array of string + private null|array|string $sort = null; // js:callable or js:array of colIndizes + + private ?string $onBeforeCreate = null; // js:callable + private ?string $onAfterCreate = null; // js:callable + private ?string $onNoData = null; // js:callable + private ?string $computeDataFunc = null; // js:callable + private ?string $onSearchSubmit = null; // js:callable + private ?string $createNote = null; // js:callable + private ?string $createCbControls = null; // js:callable + private ?string $customFilter = null; // js:callable + private ?string $getItemLink = null; // js:callable + private ?array $sortOptions = null; // js:array of object {id:, name:, hidden:, type:"text", sortFunc:} + + private string $__addIn = ''; + + public function __construct(array $opts, string $template = '', string $addIn = '') + { + if ($template && isset(self::TEMPLATES[$template])) + foreach (self::TEMPLATES[$template] as $k => $v) + $this->$k = $v; + + foreach ($opts as $k => $v) + { + if (property_exists($this, $k)) + { + // reindex arrays to force json_encode to treat them as arrays + if (is_array($v)) // in_array($k, ['data', 'extraCols', 'visibleCols', 'hiddenCols', 'sort', 'sortOptions'])) + $v = array_values($v); + $this->$k = $v; + } + else + trigger_error(self::class.'::__construct - unrecognized option: ' . $k); + } + + if ($addIn && !Template\PageTemplate::test('listviews/', $addIn.'.tpl')) + trigger_error('Nonexistent Listview addin requested: template/listviews/'.$addIn.'.tpl', E_USER_ERROR); + else if ($addIn) + $this->__addIn = 'template/listviews/'.$addIn.'.tpl'; + } + + /** + * @return \Generator rowIndex => dataRow + */ + public function &iterate() : \Generator + { + reset($this->data); + + foreach ($this->data as $idx => &$row) + yield $idx => $row; + } + + public function appendData(array $moreData) : void + { + foreach ($moreData as $md) + $this->data[] = $md; + } + + public function getTemplate() : string + { + return $this->template; + } + + public function getId() : string + { + return $this->id; + } + + public function setTabs(string $tabVar) : void + { + if ($tabVar[0] !== '$') // expects a jsVar, which we denote with a prefixed $ + $tabVar = '$' . $tabVar; + + $this->tabs = $tabVar; + } + + public function setError(bool $enable) : void + { + $this->_errors = $enable ? 1 : null; + } + + public function jsonSerialize() : array + { + $result = []; + + foreach ($this as $prop => $val) + if ($val !== null && substr($prop, 0, 2) != '__') + $result[$prop] = $val; + + return $result; + } + + public function __toString() : string + { + $addIn = ''; + if ($this->__addIn) + $addIn = file_get_contents($this->__addIn).PHP_EOL; + + return $addIn.'new Listview('.Util::toJSON($this).');'.PHP_EOL; + } +} + +?> diff --git a/includes/components/frontend/markup.class.php b/includes/components/frontend/markup.class.php new file mode 100644 index 00000000..23cfc5de --- /dev/null +++ b/includes/components/frontend/markup.class.php @@ -0,0 +1,294 @@ + $v) + { + if (property_exists($this, $k)) + $this->$k = $v; + else + trigger_error(self::class.'::__construct - unrecognized option: ' . $k); + } + + $this->__text = $text; + + if ($parent) + $this->__parent = $parent; + } + + public function getJsGlobals() : array + { + return $this->_parseTags(); + } + + public function getParent() : string + { + return $this->__parent; + } + + + /***********************/ + /* Markup tag handling */ + /***********************/ + + private function _parseTags(array &$jsg = []) : array + { + return self::parseTags($this->__text, $jsg); + } + + public static function parseTags(string $text, array &$jsg = []) : array + { + $jsGlobals = []; + + if (preg_match_all(self::DB_TAG_PATTERN, $text, $matches, PREG_SET_ORDER)) + { + foreach ($matches as $match) + { + if ($match[1] == 'statistic') + $match[1] = 'achievement'; + else if ($match[1] == 'icondb') + $match[1] = 'icon'; + + // todo - respecte forced locale + // match[0] => [achievement=3579 domain=ru], [spell=40120 site=fr] + + if ($match[1] == 'money') + { + if (stripos($match[0], 'items')) + { + if (preg_match('/items=([0-9,]+)/i', $match[0], $submatch)) + { + $sm = explode(',', $submatch[1]); + for ($i = 0; $i < count($sm); $i+=2) + $jsGlobals[Type::ITEM][$sm[$i]] = $sm[$i]; + } + } + + if (stripos($match[0], 'currency')) + { + if (preg_match('/currency=([0-9,]+)/i', $match[0], $submatch)) + { + $sm = explode(',', $submatch[1]); + for ($i = 0; $i < count($sm); $i+=2) + $jsGlobals[Type::CURRENCY][$sm[$i]] = $sm[$i]; + } + } + } + else if ($type = Type::getIndexFrom(Type::IDX_FILE_STR, $match[1])) + $jsGlobals[$type][$match[2]] = $match[2]; + } + } + + Util::mergeJsGlobals($jsg, $jsGlobals); + + return $jsGlobals; + } + + private function _stripTags(array $jsgData = []) : string + { + return self::stripTags($this->__text, $jsgData); + } + + public static function stripTags(string $text, array $jsgData = []) : string + { + // replace DB Tags + $text = preg_replace_callback(self::DB_TAG_PATTERN, function ($match) use ($jsgData) { + if ($match[1] == 'statistic') + $match[1] = 'achievement'; + else if ($match[1] == 'icondb') + $match[1] = 'icon'; + else if ($match[1] == 'money') + { + $moneys = []; + if (stripos($match[0], 'items')) + { + if (preg_match('/items=([0-9,]+)/i', $match[0], $submatch)) + { + $sm = explode(',', $submatch[1]); + for ($i = 0; $i < count($sm); $i += 2) + { + if (!empty($jsgData[Type::ITEM][1][$sm[$i]])) + $moneys[] = $jsgData[Type::ITEM][1][$sm[$i]]['name'] ?? $jsgData[Type::ITEM][1][$match[2]]['name_' . Lang::getLocale()->json()]; + else + $moneys[] = Util::ucFirst(Lang::game('item')).' #'.$sm[$i]; + } + } + } + + if (stripos($match[0], 'currency')) + { + if (preg_match('/currency=([0-9,]+)/i', $match[0], $submatch)) + { + $sm = explode(',', $submatch[1]); + for ($i = 0; $i < count($sm); $i += 2) + { + if (!empty($jsgData[Type::CURRENCY][1][$sm[$i]])) + $moneys[] = $jsgData[Type::CURRENCY][1][$sm[$i]]['name'] ?? $jsgData[Type::CURRENCY][1][$match[2]]['name_' . Lang::getLocale()->json()]; + else + $moneys[] = Util::ucFirst(Lang::game('curency')).' #'.$sm[$i]; + } + } + } + + return Lang::concat($moneys); + } + if ($type = Type::getIndexFrom(Type::IDX_FILE_STR, $match[1])) + { + if (!empty($jsgData[$type][1][$match[2]])) + return $jsgData[$type][1][$match[2]]['name'] ?? $jsgData[$type][1][$match[2]]['name_' . Lang::getLocale()->json()]; + else + return Util::ucFirst(Lang::game($match[1])).' #'.$match[2]; + } + + trigger_error('Markup::stripTags() - encountered unhandled db-tag: '.var_export($match)); + return ''; + }, $text); + + // replace line endings + $text = str_replace('[br]', "\n", $text); + + // strip other Tags + $stripped = ''; + $inTag = false; + for ($i = 0; $i < strlen($text); $i++) + { + if ($text[$i] == '[' && (!$i || $text[$i - 1] != '\\')) + $inTag = true; + if (!$inTag) + $stripped .= $text[$i]; + if ($inTag && $text[$i] == ']' && (!$i || $text[$i - 1] != '\\')) + $inTag = false; + } + + return $stripped; + } + + + /*********************/ + /* String Operations */ + /*********************/ + + public function append(string $text) : self + { + $this->__text .= $text; + return $this; + } + + public function prepend(string $text) : self + { + $this->__text = $text . $this->__text; + return $this; + } + + public function apply(\Closure $fn) : void + { + $this->__text = $fn($this->__text); + } + + public function replace(string $middle, int $offset = 0, ?int $len = null) : self + { + // y no mb_substr_replace >:( + $start = $end = ''; + + if ($offset < 0) + $offset = mb_strlen($this->__text) + $offset; + + $start = mb_substr($this->__text, 0, $offset); + + if (!is_null($len) && $len >= 0) + $end = mb_substr($this->__text, $offset + $len); + else if (!is_null($len) && $len < 0) + $end = mb_substr($this->__text, $offset + mb_strlen($this->__text) + $len); + + $this->__text = $start . $middle . $end; + return $this; + } + + private function cleanText() : string + { + // break script-tags, unify newlines + $val = preg_replace(['/script\s*\>/i', "/\r\n/", "/\r/"], ['script>', "\n", "\n"], $this->__text); + + return strtr(Util::jsEscape($val), ['script>' => 'scr"+"ipt>']); + } + + public function jsonSerialize() : array + { + $result = []; + + foreach ($this as $prop => $val) + if ($val !== null && $prop[0] != '_') + $result[$prop] = $val; + + return $result; + } + + public function __toString() : string + { + if ($this->jsonSerialize()) + return 'Markup.printHtml("'.$this->cleanText().'", "'.$this->__parent.'", '.Util::toJSON($this).");\n"; + + return 'Markup.printHtml("'.$this->cleanText().'", "'.$this->__parent."\");\n"; + } +} + +?> diff --git a/includes/components/frontend/summary.class.php b/includes/components/frontend/summary.class.php new file mode 100644 index 00000000..62ad87e5 --- /dev/null +++ b/includes/components/frontend/summary.class.php @@ -0,0 +1,70 @@ + $v) + { + if (property_exists($this, $k)) + $this->$k = $v; + else + trigger_error(self::class.'::__construct - unrecognized option: ' . $k); + } + + if (!$this->template) + trigger_error(self::class.'::__construct - initialized without template', E_USER_WARNING); + if (!$this->id) + trigger_error(self::class.'::__construct - initialized without HTMLNode#id to reference', E_USER_WARNING); + } + + public function &iterate() : \Generator + { + reset($this->groups); + + foreach ($this->groups as $idx => &$group) + yield $idx => $group; + } + + public function addGroup(array $group) : void + { + $this->groups[] = $group; + } + + public function jsonSerialize() : array + { + $result = []; + + foreach ($this as $prop => $val) + if ($val !== null && $prop[0] != '_') + $result[$prop] = $val; + + return $result; + } + + public function __toString() : string + { + return "new Summary(".Util::toJSON($this).");\n"; + } +} + +?> diff --git a/includes/components/frontend/tabs.class.php b/includes/components/frontend/tabs.class.php new file mode 100644 index 00000000..1fa14cd1 --- /dev/null +++ b/includes/components/frontend/tabs.class.php @@ -0,0 +1,145 @@ + $v) + { + if (property_exists($this, $k)) + $this->$k = $v; + else + trigger_error(self::class.'::__construct - unrecognized option: ' . $k); + } + } + + /** + * @return \Generator tabIndex => Listview + */ + public function &iterate() : \Generator + { + reset($this->__tabs); + + foreach ($this->__tabs as $idx => &$tab) + yield $idx => $tab; + } + + public function addListviewTab(Listview $lv) : void + { + $this->__tabs[] = $lv; + } + + public function addDataTab(string $id, string $name, string $data) : void + { + $this->__tabs[] = ['id' => $id, 'name' => $name, 'data' => $data]; + $this->__forceTabs = true; // otherwise a single DataTab could not be accessed + } + + public function getDataContainer() : \Generator + { + foreach ($this->__tabs as $tab) + if (is_array($tab)) + yield ''; + } + + public function getFlush() : string + { + if ($this->isTabbed()) + return $this->__tabVar.".flush();"; + + return ''; + } + + public function isTabbed() : bool + { + return count($this->__tabs) > 1 || $this->__forceTabs; + } + + + /***********************/ + /* enable deep cloning */ + /***********************/ + + public function __clone() + { + foreach ($this->__tabs as $idx => $tab) + { + if (is_array($tab)) + continue; + + $this->__tabs[$idx] = clone $tab; + } + } + + + /******************/ + /* make countable */ + /******************/ + + public function count() : int + { + return count($this->__tabs); + } + + + /************************/ + /* make Tabs stringable */ + /************************/ + + public function jsonSerialize() : array + { + $result = []; + + foreach ($this as $prop => $val) + if ($val !== null && $prop[0] != '_') + $result[$prop] = $val; + + return $result; + } + + public function __toString() : string + { + $result = ''; + + if ($this->isTabbed()) + $result .= "var ".$this->__tabVar." = new Tabs(".Util::toJSON($this).");\n"; + + foreach ($this->__tabs as $tab) + { + if (is_array($tab)) + { + $n = $tab['name'][0] == '$' ? substr($tab['name'], 1) : "'".$tab['name']."'"; + $result .= $this->__tabVar.".add(".$n.", { id: '".$tab['id']."' });\n"; + } + else + { + if ($this->isTabbed()) + $tab->setTabs($this->__tabVar); + + $result .= $tab; // Listview::__toString here + } + } + + return $result . "\n"; + } +} + +?> diff --git a/includes/components/frontend/tooltip.class.php b/includes/components/frontend/tooltip.class.php new file mode 100644 index 00000000..0042c417 --- /dev/null +++ b/includes/components/frontend/tooltip.class.php @@ -0,0 +1,64 @@ +__subject)) + $this->__subject = Util::toJSON($this->__subject, JSON_UNESCAPED_UNICODE); + + foreach ($opts as $k => $v) + { + if (property_exists($this, $k)) + $this->$k = $v; + else + trigger_error(self::class.'::__construct - unrecognized option: ' . $k); + } + } + + public function jsonSerialize() : array + { + $out = []; + + $locString = Lang::getLocale()->json(); + + foreach ($this as $k => $v) + { + if ($v === null || $k[0] == '_') + continue; + + if ($k == 'icon') + $out[$k] = rawurldecode($v); + else if ($k == 'quality' || $k == 'map' || $k == 'daily') + $out[$k] = $v; + else + $out[$k . '_' . $locString] = $v; + } + + return $out; + } + + public function __toString() : string + { + return sprintf($this->__powerTpl, $this->__subject, Lang::getLocale()->value, Util::toJSON($this, JSON_AOWOW_POWER))."\n"; + } +} + +?> diff --git a/includes/components/guidemgr.class.php b/includes/components/guidemgr.class.php new file mode 100644 index 00000000..61a41b5b --- /dev/null +++ b/includes/components/guidemgr.class.php @@ -0,0 +1,104 @@ + '#71D5FF', + self::STATUS_REVIEW => '#FFFF00', + self::STATUS_APPROVED => '#1EFF00', + self::STATUS_REJECTED => '#FF4040', + self::STATUS_ARCHIVED => '#FFD100' + ); + + private static array $ratingsStore = []; + private static ?int $imgUploadIdx = null; + + public static function createDescription(string $text) : string + { + return Lang::trimTextClean(Markup::stripTags($text), 120); + } + + public static function getRatings(array $guideIds) : array + { + if (!$guideIds) + return []; + + if (array_keys(self::$ratingsStore) == $guideIds) + return self::$ratingsStore; + + self::$ratingsStore = array_fill_keys($guideIds, ['nvotes' => 0, 'rating' => -1]); + + $ratings = DB::Aowow()->selectAssoc('SELECT `entry` AS ARRAY_KEY, IFNULL(SUM(`value`), 0) AS "0", IFNULL(COUNT(*), 0) AS "1", IFNULL(MAX(IF(`userId` = %i, `value`, 0)), 0) AS "2" FROM ::user_ratings WHERE `type` = %i AND `entry` IN %in GROUP BY `entry`', User::$id, RATING_GUIDE, $guideIds); + foreach ($ratings as $id => [$total, $count, $self]) + { + self::$ratingsStore[$id]['nvotes'] = (int)$count; + self::$ratingsStore[$id]['_self'] = (int)$self; + if ($count >= 5 ) + self::$ratingsStore[$id]['rating'] = $total / $count; + } + + return self::$ratingsStore; + } + + public static function handleUpload() : array + { + require_once('includes/libs/qqFileUploader.class.php'); + + $tmpFile = User::$username.'-'.Type::GUIDE.'-0-'.Util::createHash(16); + + $uploader = new \qqFileUploader(['jpg', 'jpeg', 'png'], 10 * 1024 * 1024); + $result = $uploader->handleUpload(self::IMG_TMP_DIR, $tmpFile, true); + + if (isset($result['error'])) + return $result; + + $mime = (new \finfo(FILEINFO_MIME))?->file(self::IMG_TMP_DIR . $result['newFilename']); + + if (!preg_match('/^image\/(png|jpe?g)/i', $mime, $m)) + return ['error' => Lang::screenshot('error', 'unkFormat')]; + + // find next empty image name (an int) + if (is_null(self::$imgUploadIdx)) + { + if ($files = scandir(self::IMG_DEST_DIR, SCANDIR_SORT_DESCENDING)) + if (rsort($files, SORT_NATURAL) && $files[0] != '.' && $files[0] != '..') + $i = explode('.', $files[0])[0] + 1; + + self::$imgUploadIdx = $i ?? 1; + } + + $targetFile = self::$imgUploadIdx . ($m[1] == 'png' ? '.png' : '.jpg'); + + // move to final location + if (!rename(self::IMG_TMP_DIR.$result['newFilename'], self::IMG_DEST_DIR.$targetFile)) + { + trigger_error('GuideMgr::handleUpload - failed to move file', E_USER_ERROR); + return ['error' => Lang::main('intError')]; + } + + return array( + 'success' => true, + 'id' => self::$imgUploadIdx, + 'type' => $m[1] == 'png' ? 3 : 2 + ); + } +} + +?> diff --git a/includes/components/imageupload.class.php b/includes/components/imageupload.class.php new file mode 100644 index 00000000..e8fa79a5 --- /dev/null +++ b/includes/components/imageupload.class.php @@ -0,0 +1,306 @@ + self::loadFromJPG(), + self::MIME_PNG => self::loadFromPNG(), + self::MIME_WEBP => self::loadFromWEBP(), + default => false + }; + } + + public static function loadFile(string $path, string $nameBase) : bool + { + self::$fileName = sprintf($path, $nameBase); + + if (!file_exists(self::$fileName)) + { + trigger_error('ImageUpload::loadFile - image ('.self::$fileName.') not found', E_USER_ERROR); + self::$fileName = ''; + return false; + } + + // we are using only jpg internally + return self::loadFromJPG(); + } + + public static function calcImgDimensions() : array + { + if (!self::$img) + return []; + + $oSize = $rSize = [imagesx(self::$img), imagesy(self::$img)]; + $rel = $oSize[0] / $oSize[1]; + + // check for oversize and refit to crop-screen + if ($rel >= 1.5 && $oSize[0] > self::CROP_W) + $rSize = [self::CROP_W, self::CROP_W / $rel]; + else if ($rel < 1.5 && $oSize[1] > self::CROP_H) + $rSize = [self::CROP_H * $rel, self::CROP_H]; + + // r: resized; o: original + // r: x <= 488 && y <= 325 while x proportional to y + return array( + 'oWidth' => $oSize[0], + 'rWidth' => $rSize[0], + 'oHeight' => $oSize[1], + 'rHeight' => $rSize[1] + ); + } + + public static function tempSaveUpload(array $tmpNameParts, ?string &$uid) : bool + { + if (!self::$img || !$tmpNameParts) + return false; + + $uid = Util::createHash(16); + + $nameBase = User::$username.'-'.implode('-', $tmpNameParts).'-'.$uid; + + // use this image for work + if (!self::writeImage(static::$tmpPath, $nameBase.'_original')) + return false; + + ['oWidth' => $oW, 'rWidth' => $rW, 'oHeight' => $oH, 'rHeight' => $rH] = self::calcImgDimensions(); + + // use this image to display in cropper + $res = imagecreatetruecolor($rW, $rH); + if (!$res) + { + trigger_error('ImageUpload::tempSaveUpload - imagecreate failed', E_USER_ERROR); + return false; + } + + if (!imagecopyresampled($res, self::$img, 0, 0, 0, 0, $rW, $rH, $oW, $oH)) + { + trigger_error('ImageUpload::tempSaveUpload - imagecopy failed', E_USER_ERROR); + return false; + } + + self::$img = $res; + unset($res); + + return self::writeImage(static::$tmpPath, $nameBase); + } + + public static function cropImg(float $scaleX, float $scaleY, float $scaleW, float $scaleH) : bool + { + if (!self::$img) + return false; + + $x = (int)(imagesx(self::$img) * $scaleX); + $y = (int)(imagesy(self::$img) * $scaleY); + $w = (int)(imagesx(self::$img) * $scaleW); + $h = (int)(imagesy(self::$img) * $scaleH); + + $destImg = imagecreatetruecolor($w, $h); + if (!$destImg) + return false; + + // imagefill($destImg, 0, 0, imagecolorallocate($destImg, 255, 255, 255)); + imagecopy($destImg, self::$img, 0, 0, $x, $y, $w, $h); + + self::$img = $destImg; + imagedestroy($destImg); + + return true; + } + + public static function writeImage(string $path, string $file) : bool + { + if (!self::$img) + return false; + + if (imagejpeg(self::$img, sprintf($path, $file), self::JPEG_QUALITY)) + return true; + + trigger_error('ImageUpload::writeImage - write failed', E_USER_ERROR); + return false; + } + + private static function setMimeType() : bool + { + if (!self::$hasUpload) + return false; + + $mime = (new \finfo(FILEINFO_MIME))?->file(self::$fileName); + + if ($mime && stripos($mime, 'image/png') === 0) + self::$mimeType = self::MIME_PNG; + else if ($mime && stripos($mime, 'image/webp') === 0) + self::$mimeType = self::MIME_WEBP; + else if ($mime && preg_match('/^image\/jpe?g/i', $mime)) + self::$mimeType = self::MIME_JPG; + else + trigger_error('ImageUpload::setMimeType - uploaded file is of type: '.$mime, E_USER_WARNING); + + return self::$mimeType != self::MIME_UNK; + } + + private static function loadFromPNG() : bool + { + // straight self::$img = imagecreatefrompng(self::$fileName); causes issues when transforming the alpha channel + // this roundabout way through imagealphablending() avoids that + $image = imagecreatefrompng(self::$fileName); + if (!$image) + return false; + + self::$img = imagecreatetruecolor(imagesx($image), imagesy($image)) ?: null; + if (!self::$img) + return false; + + imagealphablending(self::$img, true); + imagecopy(self::$img, $image, 0, 0, 0, 0, imagesx($image), imagesy($image)); + imagedestroy($image); + + return true; + } + + private static function loadFromJPG() : bool + { + self::$img = imagecreatefromjpeg(self::$fileName) ?: null; + + return !is_null(self::$img); + } + + private static function loadFromWEBP() : bool + { + $image = imagecreatefromwebp(self::$fileName); + if (!$image) + return false; + + self::$img = imagecreatetruecolor(imagesx($image), imagesy($image)) ?: null; + if (!self::$img) + return false; + + imagealphablending(self::$img, true); + imagecopy(self::$img, $image, 0, 0, 0, 0, imagesx($image), imagesy($image)); + imagedestroy($image); + + return true; + } + + protected static function resizeAndWrite(int $limitW, int $limitH, string $path, string $file) : bool + { + $srcW = imagesx(self::$img); + $srcH = imagesy(self::$img); + + // already small enough + if ($srcW < $limitW && $srcH < $limitH) + return true; + + $scale = min(1.0, $limitW / $srcW, $limitH / $srcH); + $destW = $srcW * $scale; + $destH = $srcH * $scale; + + $destImg = imagecreatetruecolor($destW, $destH); + + // imagefill($destImg, 0, 0, imagecolorallocate($destImg, 255, 255, 255)); + imagecopyresampled($destImg, self::$img, 0, 0, 0, 0, $destW, $destH, $srcW, $srcH); + + return imagejpeg($destImg, sprintf($path, $file), self::JPEG_QUALITY); + } +} + +?> diff --git a/includes/components/locstring.class.php b/includes/components/locstring.class.php new file mode 100644 index 00000000..2bfec006 --- /dev/null +++ b/includes/components/locstring.class.php @@ -0,0 +1,67 @@ +store = new \WeakMap(); + + $callback ??= fn($x) => $x; + + if (!array_filter($data, fn($v, $k) => $v && strstr($k, $key.'_loc'), ARRAY_FILTER_USE_BOTH)) + trigger_error('LocString - is entrirely empty', E_USER_WARNING); + + foreach (Locale::cases() as $l) + if ($l->validate()) + $this->store[$l] = (string)$callback($data[$key.'_loc'.$l->value] ?? ''); + } + + public function jsonSerialize() : string + { + return $this->__toString(); + } + + public function __toString() : string + { + if ($str = $this->store[Lang::getLocale()]) + return $str; + + foreach (Locale::cases() as $l) // desired loc not set, use any other + if (isset($this->store[$l])) + return Cfg::get('DEBUG') ? '['.$this->store[$l].']' : $this->store[$l]; + + return Cfg::get('DEBUG') ? '[LOCSTRING]' : ''; + } + + public function __serialize(): array + { + $data = []; + foreach (Locale::cases() as $l) + if (isset($this->store[$l])) + $data[$l->value] = $this->store[$l]; + + return ['store' => $data]; + } + + public function __unserialize(array $data): void + { + $this->store = new \WeakMap(); + + if (empty($data['store'])) + return; + + foreach ($data['store'] as $locId => $str) + if (($l = Locale::tryFrom($locId))?->validate()) + $this->store[$l] = (string)$str; + } +} + +?> diff --git a/includes/components/pagetemplate.class.php b/includes/components/pagetemplate.class.php new file mode 100644 index 00000000..59c3d50b --- /dev/null +++ b/includes/components/pagetemplate.class.php @@ -0,0 +1,579 @@ +locale = Lang::getLocale(); + $this->user = User::class; + + self::__wakeup(); // init non-cached properties; + } + + public function addDataLoader(string ...$dataFile) : void + { + foreach ($dataFile as $df) + $this->dataLoader[] = $df; + } + + public function addScript(int $type, string $str, int $flags = 0x0) : bool + { + $tpl = match ($type) + { + SC_CSS_FILE => '', + SC_CSS_STRING => '', + SC_JS_FILE => '', + SC_JS_STRING => '', + default => '' + }; + + if (!$str) + { + trigger_error('PageTemplate::addScript - content empty', E_USER_WARNING); + return false; + } + + if (!$tpl) + { + trigger_error('PageTemplate::addScript - unknown script type #'.$type, E_USER_WARNING); + return false; + } + + // insert locale string + if ($flags & SC_FLAG_LOCALIZED) + $str = sprintf($str, Lang::getLocale()->json()); + + $this->scripts[] = [$type, $str, $flags, $tpl]; + return true; + } + + /* (optional) set pre-render hooks */ + + public function registerDisplayHook(string $var, callable $fn) : void + { + $this->displayHooks[$var][] = $fn; + } + + private function getDisplayHooks(string $var) : array + { + return $this->displayHooks[$var] ?? []; + } + + /* 3) self test, ready to be cached now */ + + public function prepare() : bool + { + if (!self::test('template/pages/', $this->template)) + { + trigger_error('Error: nonexistent template requested: template/pages/'.$this->template.'.tpl.php', E_USER_ERROR); + return false; + } + + // TODO - more checks and preparations + + return true; + } + + /* 4) display */ + + public function render() : void + { + $this->update(); + + include('template/pages/'.$this->template.'.tpl.php'); + } + + + /***********/ + /* loaders */ + /***********/ + + // "template_exists" + public static function test(string $path, string $file) : bool + { + if (!preg_match('/^[\w\-_]+(\.tpl(\.php)?)?$/i', $file)) + return false; + + if ($path && preg_match('/\\{2,}|\/{2,}|\.{2,}|~/i', $path)) + return false; + + if (!is_file('template/'.$path.$file)) + return false; + + return true; + } + + // load brick + private function brick(string $file, array $localVars = []) : void + { + $file .= '.tpl.php'; + + if (!self::test('bricks/', $file)) + { + trigger_error('Nonexistent template requested: template/bricks/'.$file, E_USER_ERROR); + return; + } + + foreach ($localVars as $n => $v) + $$n = $v; + + include('template/bricks/'.$file); + } + + private function brickIf(mixed $boolish, string $file, array $localVars = []) : void + { + if ($boolish) + $this->brick($file, $localVars); + } + + // load brick with more text then vars + private function localizedBrick(string $file, array $localVars = []) : void + { + foreach ($localVars as $n => $v) + $$n = $v; + + $_file = $file.'_'.$this->locale->value.'.tpl.php'; + if (self::test('localized/', $_file)) + { + include('template/localized/'.$_file); + return; + } + + $_file = $file.'_'.$this->locale->getFallback()->value.'.tpl.php'; + if (self::test('localized/', $_file)) + { + include('template/localized/'.$_file); + return; + } + + trigger_error('Nonexistent template requested: template/localized/'.$_file, E_USER_ERROR); + } + + private function localizedBrickIf(mixed $boolish, string $file, array $localVars = []) : void + { + if ($boolish) + $this->localizedBrick($file, $localVars); + } + + + /****************/ + /* Util wrapper */ + /****************/ + + private function cfg(string $name) : mixed + { + return Cfg::get($name); + } + + private function json(mixed $var, int $jsonFlags = 0x0, bool $varRef = false) : string + { + if (!is_string($var)) + return preg_replace('/script\s*\>/i', 'scr"+"ipt>', Util::toJSON($var, $jsonFlags) ?: "{}"); + + return preg_replace('/script\s*\>/i', 'scr"+"ipt>', Util::toJSON($varRef ? $this->$var : $var, $jsonFlags) ?: "{}"); + } + + private function escHTML(string $var, bool $varRef = false) : string|array + { + return Util::htmlEscape($varRef ? $this->$var : $var); + } + + private function escJS(string $var, bool $varRef = false) : string|array + { + return Util::jsEscape($varRef ? $this->$var : $var); + } + + private function ucFirst(string $var, bool $varRef = false) : string + { + return Util::ucFirst($varRef ? $this->$var : $var); + } + + + /*****************/ + /* render helper */ + /*****************/ + + private function concat(string $arrVar, string $separator = '') : string + { + if (!is_array($this->$arrVar)) + return ''; + + return implode($separator, $this->$arrVar); + } + + private function renderArray(string|array $arrVar, int $lpad = 0) : string + { + $data = []; + if (is_string($arrVar) && isset($this->$arrVar) && is_array($this->$arrVar)) + $data = $this->$arrVar; + else if (is_array($arrVar)) + $data = $arrVar; + + $buff = ''; + foreach ($data as $x) + $buff .= str_repeat(' ', $lpad) . $x . "\n"; + + return $buff; + } + + // load jsGlobals + private function renderGlobalVars(int $lpad = 0) : string + { + $buff = ''; + + if ($this->guideRating) + $buff .= str_repeat(' ', $lpad).sprintf(self::GUIDE_RATING_TPL, ...$this->guideRating); + + foreach ($this->jsGlobals as [$jsVar, $data, $extraData]) + { + $buff .= str_repeat(' ', $lpad).'var _ = '.$jsVar.';'; + + foreach ($data as $key => $data) + $buff .= ' _['.(is_numeric($key) ? $key : "'".$key."'")."]=".Util::toJSON($data).';'; + + $buff .= "\n"; + + if (isset($this->gPageInfo['type']) && isset($this->gPageInfo['typeId']) && isset($extraData[$this->gPageInfo['typeId']])) + { + $buff .= "\n"; + foreach ($extraData[$this->gPageInfo['typeId']] as $k => $v) + if ($v) + $buff .= str_repeat(' ', $lpad).'_['.$this->gPageInfo['typeId'].'].'.$k.' = '.Util::toJSON($v).";\n"; + $buff .= "\n"; + } + } + + return $buff; + } + + private function renderSeriesItem(int $idx, array $list, int $lpad = 0) : string + { + $result = ''.($idx + 1).'
\n"; + } + + private function renderFilter(int $lpad = 0) : string + { + $result = []; + + // it's worth noting, that this only works on non-cached page calls. Luckily Profiler pages are not cached. + if ($this->context instanceof \Aowow\IProfilerList) + { + $result[] = "pr_setRegionRealm(\$WH.ge('fi').firstChild, '".$this->region."', '".$this->realm."');"; + + if (!empty($this->filter->values['ra'])) + $result[] = "pr_onChangeRace();"; + } + + if ($this->filter->fiInit) // str: filter template (and init html form) + $result[] = "fi_init('".$this->filter->fiInit."');"; + else if ($this->filter->fiType) // str: filter template (set without init) + $result[] = "var fi_type = '".$this->filter->fiType."'"; + + if ($this->filter->fiSetCriteria) // arr:criteria, arr:signs, arr:values + $result[] = 'fi_setCriteria('.mb_substr(Util::toJSON($this->filter->fiSetCriteria), 1, -1).");"; + + /* + nt: don't try to match provided weights on predefined weight sets (preselects preset from opt list and ..?) + ids: weights are encoded as ids, not by their js name and need conversion before use + stealth: the ub-selector (items filter) will not visually change (so what..?) + */ + if ($this->filter->fiSetWeights) // arr:weights, bool:nt[0], bool:ids[1], bool:stealth[1] + $result[] = 'fi_setWeights('.Util::toJSON(array_values($this->filter->fiSetWeights)).', 0, 1, 1);'; + + if ($this->filter->fiExtraCols) // arr:extraCols + $result[] = 'fi_extraCols = '.Util::toJSON(array_values(array_unique($this->filter->fiExtraCols))).";"; + + return str_repeat(' ', $lpad)."\n"; + } + + private function makeOptionsList(array $data, mixed $selectedIdx = null, int $lpad = 0, ?callable $callback = null) : string + { + $callback ??= fn(&$v, &$k) => $v; // default callback: skip empty descriptors + $options = ''; + + foreach ($data as $idx => $str) + { + $extraAttributes = []; + if (!$callback($str, $idx, $extraAttributes)) + continue; + + if ($idx === '' || !$str) + continue; + + $options .= str_repeat(' ', max(0, $lpad)).' $v) + $options .= ' '.$k.'="'.$v.'"'; + + if (is_array($selectedIdx) && in_array($idx, $selectedIdx)) + $options .= ' selected="selected"'; + else if (!is_null($selectedIdx) && $selectedIdx == $idx) + $options .= ' selected="selected"'; + + $options .= ' value="'.$idx.'">'.$str.''.($lpad < 0 ? '' : "\n"); + } + + return $options; + } + + private function makeRadiosList(string $name, array $data, mixed $selectedIdx = null, int $lpad = 0, ?callable $callback = null) : string + { + $callback ??= fn(&$v, &$k) => $v; // default callback: skip empty descriptors + $options = ''; + + foreach ($data as $idx => [$title, $id]) + { + $extraAttributes = []; + if (!$callback($title, $idx, $extraAttributes)) + continue; + + if ($id === '' || !$title) + continue; + + $options .= str_repeat(' ', max(0, $lpad)).' $v) + $options .= ' '.$k.'="'.$v.'"'; + + $options .= '>'.$title.''.($lpad < 0 ? '' : "\n"); + } + + return $options; + } + + // unordered stuff + + private function prepareScripts() : void + { + $this->js = $this->css = []; + + foreach ($this->scripts as [$type, $str, $flags, $tpl]) + { + $app = []; + + if (($flags & SC_FLAG_APPEND_LOCALE) && $this->locale != \Aowow\Locale::EN) + $app[] = 'lang='.$this->locale->domain(); + + // append anti-cache timestamp + if (!($flags & SC_FLAG_NO_TIMESTAMP)) + if ($type == SC_JS_FILE || $type == SC_CSS_FILE) + $app[] = filemtime('static/'.$str) ?: 0; + + if ($app) + $appendix = '?'.implode('&', $app); + + if ($type == SC_JS_FILE || $type == SC_CSS_FILE) + $str = Cfg::get('STATIC_URL').'/'.$str; + + if ($flags & SC_FLAG_PREFIX) + { + if ($type == SC_JS_FILE || $type == SC_JS_STRING) + array_unshift($this->js, sprintf($tpl, $str, $appendix ?? '')); + else + array_unshift($this->css, sprintf($tpl, $str, $appendix ?? '')); + } + else + { + if ($type == SC_JS_FILE || $type == SC_JS_STRING) + array_push($this->js, sprintf($tpl, $str, $appendix ?? '')); + else + array_push($this->css, sprintf($tpl, $str, $appendix ?? '')); + } + } + + if ($data = array_unique($this->dataLoader)) + { + $args = array( + 'data' => implode('.', $data), + 'locale' => $this->locale->value, + 't' => $_SESSION['dataKey'] + ); + + array_push($this->js, ''); + } + } + + // refresh vars that shouldn't be cached + private function update() : void + { + // not set, but should be + if (!isset($_COOKIE['consent']) && $this->hasAnalytics) + { + $this->addScript(SC_CSS_FILE, 'css/consent.css', SC_FLAG_NOCACHE); + $this->addScript(SC_JS_FILE, 'js/consent.js', SC_FLAG_NOCACHE); + + $this->consentFooter = true; + } + else + $this->consentFooter = false; + + // analytics + consent + // not set or declined + if (empty($_COOKIE['consent'])) + $this->hasAnalytics = false; + + // js + css + $this->prepareScripts(); + + // db profiling + if (Cfg::get('DEBUG') >= LOG_LEVEL_INFO && User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN)) + $this->dbProfiles = \Aowow\DB::getProfiles(); + } + + public function setListviewError() : void + { + if (!$this->lvTabs) + return; + + foreach ($this->lvTabs->iterate() as $lv) + if ($lv instanceof \Aowow\Listview) + $lv->setError(true); + } + + // pre-serialization: if a var is relevant it was stored in $rawData + public function __sleep() : array + { + $this->context = null; // unlink from TemplateResponse + $this->pageData = []; // clear modified data + + unset( // must be recreated on __wakeup + $this->gStaticUrl, + $this->gHost, + $this->hasAnalytics, + $this->gServerTime, + $this->gUser, + $this->gFavorites + ); + + if ($this->lvTabs) // do not store lvErrors in cache + foreach ($this->lvTabs->iterate() as $lv) + if ($lv instanceof \Aowow\Listview) + $lv->setError(false); + + // clear out scripts flagged as non-caching + $this->scripts = array_filter($this->scripts, fn($x) => !($x[2] & SC_FLAG_NOCACHE)); + + $vars = []; + foreach ($this as $k => $_) + $vars[] = $k; + + return $vars; + } + + public function __wakeup() : void + { + $this->gStaticUrl = Cfg::get('STATIC_URL'); + $this->gHost = Cfg::get('HOST_URL'); + $this->hasAnalytics = !!Cfg::get('GTAG_MEASUREMENT_ID'); + $this->gServerTime = sprintf("new Date('%s')", date(Util::$dateFormatInternal)); + $this->gUser = Util::toJSON(User::getUserGlobal()); + $this->gFavorites = Util::toJSON(User::getFavorites()); + } + + public function __set(string $var, mixed $value) : void + { + $this->pageData[$var] = $value; + } + + public function __get(string $var) : mixed + { + // modified data exists + if (isset($this->pageData[$var])) + return $this->pageData[$var]; + + if (!isset($this->rawData[$var])) + { + if (!$this->context) + return null; + + if (!isset(get_object_vars($this->context)[$var])) + return null; + + $this->rawData[$var] = $this->context->$var; + } + + if ($hooks = $this->getDisplayHooks($var)) + { + if (is_object($this->rawData[$var])) // is frontend component + $this->pageData[$var] = clone $this->rawData[$var]; + else + $this->pageData[$var] = $this->rawData[$var]; + + foreach ($hooks as $fn) + $fn($this, $this->pageData[$var]); + } + + return $this->pageData[$var] ?? $this->rawData[$var]; + } +} diff --git a/includes/components/profiler.class.php b/includes/components/profiler.class.php new file mode 100644 index 00000000..e3c4d452 --- /dev/null +++ b/includes/components/profiler.class.php @@ -0,0 +1,1078 @@ + [2, 3, 4, 5], // US (us, oceanic, latin america, americas - tournament) + 'kr' => [6, 7], // KR (kr, tournament) + 'eu' => [8, 9, 10, 11, 12, 13], // EU (english, german, french, spanish, russian, eu - tournament) + 'tw' => [14, 15], // TW (tw, tournament) + 'cn' => [16, 17, 18, 19, 20, 21, 22, 23, 24, 25], // CN (cn, CN1-8, tournament) + 'dev' => [1, 26, 27, 28, 30] // Development, Test Server, Test Server - tournament, QA Server, Test Server 2 + ); + + private static array $realms = []; + + public static array $slot2InvType = array( + 1 => [INVTYPE_HEAD], // head + 2 => [INVTYPE_NECK], // neck + 3 => [INVTYPE_SHOULDERS], // shoulder + 4 => [INVTYPE_BODY], // shirt + 5 => [INVTYPE_CHEST, INVTYPE_ROBE], // chest + 6 => [INVTYPE_WAIST], // waist + 7 => [INVTYPE_LEGS], // legs + 8 => [INVTYPE_FEET], // feet + 9 => [INVTYPE_WRISTS], // wrists + 10 => [INVTYPE_HANDS], // hands + 11 => [INVTYPE_FINGER], // finger1 + 12 => [INVTYPE_FINGER], // finger2 + 13 => [INVTYPE_TRINKET], // trinket1 + 14 => [INVTYPE_TRINKET], // trinket2 + 15 => [INVTYPE_CLOAK], // chest + 16 => [INVTYPE_WEAPONMAINHAND, INVTYPE_WEAPON, INVTYPE_2HWEAPON], // mainhand + 17 => [INVTYPE_WEAPONOFFHAND, INVTYPE_WEAPON, INVTYPE_HOLDABLE, INVTYPE_SHIELD], // offhand + 18 => [INVTYPE_RANGED, INVTYPE_THROWN, INVTYPE_RELIC], // ranged + relic + 19 => [INVTYPE_TABARD], // tabard + ); + + public static array $raidProgression = array( // statisticAchievement => relevantCriterium ; don't forget to enable this in /js/Profiler.js as well + 1361 => 5100, 1362 => 5101, 1363 => 5102, 1365 => 5104, 1366 => 5108, 1364 => 5110, 1369 => 5112, 1370 => 5113, 1371 => 5114, 1372 => 5117, 1373 => 5119, 1374 => 5120, 1375 => 7805, 1376 => 5122, 1377 => 5123, // Naxxramas 10 + 1367 => 5103, 1368 => 5111, 1378 => 5124, 1379 => 5125, 1380 => 5126, 1381 => 5127, 1382 => 5128, 1383 => 7806, 1384 => 5130, 1385 => 5131, 1386 => 5132, 1387 => 5133, 1388 => 5134, 1389 => 5135, 1390 => 5136, // Naxxramas 25 + 2856 => 9938, 2857 => 9939, 2858 => 9940, 2859 => 9941, 2861 => 9943, 2865 => 9947, 2866 => 9948, 2868 => 9950, 2869 => 9951, 2870 => 9952, 2863 => 10558, 2864 => 10559, 2862 => 10560, 2867 => 10565, 2860 => 10580, // Ulduar 10 + 2872 => 9954, 2873 => 9955, 2874 => 9956, 2884 => 9957, 2875 => 9959, 2879 => 9963, 2880 => 9964, 2882 => 9966, 2883 => 9967, 3236 => 10542, 3257 => 10561, 3256 => 10562, 3258 => 10563, 2881 => 10566, 2885 => 10581, // Ulduar 25 + 1098 => 3271, // Onyxia's Lair 10 + 1756 => 13345, // Onyxia's Lair 25 + 4031 => 12230, 4034 => 12234, 4038 => 12238, 4042 => 12242, 4046 => 12246, // Trial of the Crusader 25 nh + 4029 => 12231, 4035 => 12235, 4039 => 12239, 4043 => 12243, 4047 => 12247, // Trial of the Crusader 25 hc + 4030 => 12229, 4033 => 12233, 4037 => 12237, 4041 => 12241, 4045 => 12245, // Trial of the Crusader 10 hc + 4028 => 12228, 4032 => 12232, 4036 => 12236, 4040 => 12240, 4044 => 12244, // Trial of the Crusader 10 nh + 4642 => 13091, 4656 => 13106, 4661 => 13111, 4664 => 13114, 4667 => 13117, 4670 => 13120, 4673 => 13123, 4676 => 13126, 4679 => 13129, 4682 => 13132, 4685 => 13135, 4688 => 13138, // Icecrown Citadel 25 hc + 4641 => 13092, 4655 => 13105, 4660 => 13109, 4663 => 13112, 4666 => 13115, 4669 => 13118, 4672 => 13121, 4675 => 13124, 4678 => 13127, 4681 => 13130, 4683 => 13133, 4687 => 13136, // Icecrown Citadel 25 nh + 4640 => 13090, 4654 => 13104, 4659 => 13110, 4662 => 13113, 4665 => 13116, 4668 => 13119, 4671 => 13122, 4674 => 13125, 4677 => 13128, 4680 => 13131, 4684 => 13134, 4686 => 13137, // Icecrown Citadel 10 hc + 4639 => 13089, 4643 => 13093, 4644 => 13094, 4645 => 13095, 4646 => 13096, 4647 => 13097, 4648 => 13098, 4649 => 13099, 4650 => 13100, 4651 => 13101, 4652 => 13102, 4653 => 13103, // Icecrown Citadel 10 nh + 4823 => 13467, // Ruby Sanctum 25 hc + 4820 => 13465, // Ruby Sanctum 25 nh + 4822 => 13468, // Ruby Sanctum 10 hc + 4821 => 13466, // Ruby Sanctum 10 nh + ); + + public static function getBuyoutForItem(int $itemId) : int + { + if (!$itemId) + return 0; + + // try, when having filled char-DB at hand + // return DB::Characters()->selectCell('SELECT SUM(a.buyoutprice) / SUM(ii.count) FROM auctionhouse a JOIN item_instance ii ON ii.guid = a.itemguid WHERE ii.itemEntry = %i', $itemId); + return 0; + } + + public static function queueStart(?string &$msg = '') : bool + { + $queuePID = self::queueStatus(); + + if ($queuePID) + { + $msg = 'queue already running'; + return true; + } + + if (OS_WIN) // here be gremlins! .. suggested was "start /B php prQueue" as background process. but that closes itself + pclose(popen('start php prQueue --log=cache/profiling.log', 'r')); + else + exec('php prQueue --log=cache/profiling.log > /dev/null 2>/dev/null &'); + + usleep(500000); + if (self::queueStatus()) + return true; + else + { + $msg = 'failed to start queue'; + return false; + } + } + + public static function queueStatus() : int + { + if (!file_exists(self::PID_FILE)) + return 0; + + $pid = file_get_contents(self::PID_FILE); + $cmd = OS_WIN ? 'tasklist /NH /FO CSV /FI "PID eq %d"' : 'ps --no-headers p %d'; + + exec(sprintf($cmd, $pid), $out); + if ($out && stripos($out[0], $pid) !== false) + return $pid; + + // have pidFile but no process with this pid + self::queueFree(); + return 0; + } + + public static function queueLock(int $pid) : bool + { + $queuePID = self::queueStatus(); + if ($queuePID && $queuePID != $pid) + { + trigger_error('pSync - another queue with PID #'.$queuePID.' is already running', E_USER_ERROR); + return false; + } + + // no queue running; create or overwrite pidFile + $ok = false; + if ($fh = fopen(self::PID_FILE, 'w')) + { + if (fwrite($fh, $pid)) + $ok = true; + + fclose($fh); + } + + return $ok; + } + + public static function queueFree() : void + { + unlink(self::PID_FILE); + } + + public static function urlize(string $str, bool $allowLocales = false, bool $profile = false) : string + { + $search = ['<', '>', ' / ', "'"]; + $replace = ['<', '>', '-', '' ]; + $str = str_replace($search, $replace, $str); + + if ($profile) + { + $str = str_replace(['(', ')'], ['', ''], $str); + $accents = array( + "ß" => "ss", + "á" => "a", "ä" => "a", "à" => "a", "â" => "a", + "è" => "e", "ê" => "e", "é" => "e", "ë" => "e", + "í" => "i", "î" => "i", "ì" => "i", "ï" => "i", + "ñ" => "n", + "ò" => "o", "ó" => "o", "ö" => "o", "ô" => "o", + "ú" => "u", "ü" => "u", "û" => "u", "ù" => "u", + "œ" => "oe", + "Á" => "A", "Ä" => "A", "À" => "A", "Â" => "A", + "È" => "E", "Ê" => "E", "É" => "E", "Ë" => "E", + "Í" => "I", "Î" => "I", "Ì" => "I", "Ï" => "I", + "Ñ" => "N", + "Ò" => "O", "Ó" => "O", "Ö" => "O", "Ô" => "O", + "Ú" => "U", "Ü" => "U", "Û" => "U", "Ù" => "U", + "Œ" => "Oe" + ); + $str = strtr($str, $accents); + } + + $str = trim($str); + + if ($allowLocales) + $str = str_replace(' ', '-', $str); + else + $str = preg_replace('/[^a-z0-9]/i', '-', $str); + + $str = str_replace('--', '-', $str); + $str = str_replace('--', '-', $str); + + $str = rtrim($str, '-'); + $str = strtolower($str); + + return $str; + } + + public static function getRealms() : array + { + if (!DB::isConnectable(DB_AUTH) || self::$realms) + return self::$realms; + + $realms = DB::Auth()->selectAssoc( + 'SELECT `id` AS ARRAY_KEY, + `name`, + CASE WHEN `timezone` BETWEEN 2 AND 5 THEN "us" # US, Oceanic, Latin America, Americas-Tournament + WHEN `timezone` BETWEEN 6 AND 7 THEN "kr" # KR, KR-Tournament + WHEN `timezone` BETWEEN 8 AND 13 THEN "eu" # GB, DE, FR, ES, RU, EU-Tournament + WHEN `timezone` BETWEEN 14 AND 15 THEN "tw" # TW, TW-Tournament + WHEN `timezone` BETWEEN 16 AND 25 THEN "cn" # CN, CN1-8, CN-Tournament + ELSE "dev" END AS "region", # 1: Dev, 26: Test, 27: Test Tournament, 28: QA, 30: Test2, 31+: misc + `allowedSecurityLevel` AS "access" + FROM `realmlist` + WHERE `gamebuild` = %i', + WOW_BUILD + ); + + if (!$realms) + return []; + + foreach ($realms as $rId => $rData) + { + // realm in db but no connection info set + if (!DB::isConnectable(DB_CHARACTERS . $rId)) + continue; + + // filter by access level + if ($rData['access'] == SEC_ADMINISTRATOR && (CLI || User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN))) + $rData['access'] = U_GROUP_DEV | U_GROUP_ADMIN; + else if ($rData['access'] == SEC_GAMEMASTER && (CLI || User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN | U_GROUP_MOD))) + $rData['access'] = U_GROUP_DEV | U_GROUP_ADMIN | U_GROUP_MOD; + else if ($rData['access'] == SEC_MODERATOR && (CLI || User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN | U_GROUP_MOD | U_GROUP_BUREAU))) + $rData['access'] = U_GROUP_DEV | U_GROUP_ADMIN | U_GROUP_MOD | U_GROUP_BUREAU; + else if ($rData['access'] > SEC_PLAYER && !CLI) + continue; + + // filter dev realms + if ($rData['region'] === 'dev') + { + if (CLI || User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN)) + $rData['access'] = U_GROUP_DEV | U_GROUP_ADMIN; + else + continue; + } + + self::$realms[$rId] = $rData; + } + + return self::$realms; + } + + public static function getRegions() : array + { + self::getRealms(); + + // sort depends on encountered order in `auth`.`realmlist`. Is that a problem? + return array_unique(array_column(self::$realms, 'region')); + } + + private static function queueInsert(int $realmId, int $guid, int $type, int $localId) : void + { + if ($rData = DB::Aowow()->selectRow('SELECT `requestTime` AS "time", `status` FROM ::profiler_sync WHERE `realm` = %i AND `realmGUID` = %i AND `type` = %i AND `typeId` = %i AND `status` <> %i', $realmId, $guid, $type, $localId, PR_QUEUE_STATUS_WORKING)) + { + // not on already scheduled - recalc time and set status to PR_QUEUE_STATUS_WAITING + if ($rData['status'] != PR_QUEUE_STATUS_WAITING) + { + $newTime = Cfg::get('DEBUG') ? time() : max($rData['time'] + Cfg::get('PROFILER_RESYNC_DELAY'), time()); + DB::Aowow()->qry('UPDATE ::profiler_sync SET `requestTime` = %i, `status` = %i, `errorCode` = 0 WHERE `realm` = %i AND `realmGUID` = %i AND `type` = %i AND `typeId` = %i', $newTime, PR_QUEUE_STATUS_WAITING, $realmId, $guid, $type, $localId); + } + } + else + DB::Aowow()->qry('REPLACE INTO ::profiler_sync (`realm`, `realmGUID`, `type`, `typeId`, `requestTime`, `status`, `errorCode`) VALUES (%i, %i, %i, %i, UNIX_TIMESTAMP(), %i, 0)', $realmId, $guid, $type, $localId, PR_QUEUE_STATUS_WAITING); + } + + public static function scheduleResync(int $type, int $realmId, int $guid) : int + { + $newId = 0; + + switch ($type) + { + case Type::PROFILE: + if ($newId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_profiles WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $guid)) + self::queueInsert($realmId, $guid, Type::PROFILE, $newId); + + break; + case Type::GUILD: + if ($newId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_guild WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $guid)) + self::queueInsert($realmId, $guid, Type::GUILD, $newId); + + break; + case Type::ARENA_TEAM: + if ($newId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_arena_team WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $guid)) + self::queueInsert($realmId, $guid, Type::ARENA_TEAM, $newId); + + break; + default: + trigger_error('scheduling resync for unknown type #'.$type.' omiting..', E_USER_WARNING); + return 0; + } + + if (!$newId) + trigger_error('Profiler::scheduleResync() - tried to resync type #'.$type.' guid #'.$guid.' from realm #'.$realmId.' without preloaded data', E_USER_ERROR); + else if (!self::queueStart($msg)) + trigger_error('Profiler::scheduleResync() - '.$msg, E_USER_ERROR); + + return $newId; + } + + /* return + [ + nQueueProcesses, + [statusCode, timeToRefresh, curQueuePos, errorCode, nResyncs], + [] + ... + ] + + statusCode: + 0: end the request + 1: waiting + 2: working... + 3: ready; click to view + 4: error / retry + timeToRefresh: + msec till the client may ask for another update + curQueuePos: + position in the queue + errorCode: + 0: unk error + 1: char does not exist + 2: armory gone + nResyncs: + ??? .. if !nResyncs && !timeToRefresh prints "Adding to queue..." but will not ping the server for updates...? + */ + public static function resyncStatus(int $type, array $subjectGUIDs) : string + { + $response = [Cfg::get('PROFILER_ENABLE') ? 2 : 0]; // in theory you could have multiple queues; used as divisor in wait time estimation: (15 / x) + 2 + if (!$subjectGUIDs) + $response[] = [PR_QUEUE_STATUS_ENDED, 0, 0, PR_QUEUE_ERROR_CHAR]; + else + { + // error out all profiles with status WORKING, that are older than 60sec + DB::Aowow()->qry('UPDATE ::profiler_sync SET `status` = %i, `errorCode` = %i WHERE `status` = %i AND `requestTime` < %i', PR_QUEUE_STATUS_ERROR, PR_QUEUE_ERROR_UNK, PR_QUEUE_STATUS_WORKING, time() - MINUTE); + + $subjectStatus = DB::Aowow()->selectAssoc('SELECT `typeId` AS ARRAY_KEY, `status`, `realm`, `errorCode`, `requestTime` FROM ::profiler_sync WHERE `type` = %i AND `typeId` IN %in', $type, $subjectGUIDs); + $queue = DB::Aowow()->selectCol('SELECT CONCAT(`type`, ":", `typeId`) FROM ::profiler_sync WHERE `status` = %i AND `requestTime` < UNIX_TIMESTAMP() ORDER BY `requestTime` ASC', PR_QUEUE_STATUS_WAITING); + foreach ($subjectGUIDs as $guid) + { + if (empty($subjectStatus[$guid])) // whelp, thats some error.. + $response[] = [PR_QUEUE_STATUS_ERROR, 0, 0, PR_QUEUE_ERROR_UNK]; + else if ($subjectStatus[$guid]['status'] == PR_QUEUE_STATUS_ERROR) + $response[] = [PR_QUEUE_STATUS_ERROR, 0, 0, $subjectStatus[$guid]['errorCode']]; + else if ($subjectStatus[$guid]['requestTime'] > time()) + $response[] = [PR_QUEUE_STATUS_READY, 0, 0, 0]; + else + $response[] = array( + $subjectStatus[$guid]['status'], + $subjectStatus[$guid]['status'] != PR_QUEUE_STATUS_READY ? Cfg::get('PROFILER_RESYNC_PING') : 0, + array_search($type.':'.$guid, $queue) + 1, + 0, + 1 // nResyncs - unsure about this one + ); + } + } + + return Util::toJSON($response); + } + + public static function getCharFromRealm(int $realmId, int $charGuid) : int + { + $char = DB::Characters($realmId)->selectRow('SELECT c.* FROM characters c WHERE c.`guid` = %i', $charGuid); + if (!$char) + return self::FETCH_RESULT_ERR_NOT_FOUND; + + if (!$char['name']) + return self::FETCH_RESULT_ERR_NAME_EMPTY; + + // reminder: this query should not fail: a placeholder entry is created as soon as a char listview is created or profile detail page is called + $profile = DB::Aowow()->selectRow('SELECT `id`, `lastupdated` FROM ::profiler_profiles WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $char['guid']); + if (!$profile) + return self::FETCH_RESULT_ERR_INTERNAL; // well ... it failed + + $profileId = $profile['id']; + + CLI::write('fetching char '.$char['name'].' (#'.$charGuid.') from realm #'.$realmId); + + if (!$char['online'] && $char['logout_time'] <= $profile['lastupdated']) + { + DB::Aowow()->qry('UPDATE ::profiler_profiles SET `lastupdated` = %i WHERE `id` = %i', time(), $profileId); + return self::FETCH_RESULT_OK_UNCHANGED; + } + + CLI::write('writing...'); + + $ra = ChrRace::from($char['race']); + $cl = ChrClass::from($char['class']); + + + /*************/ + /* equipment */ + /*************/ + + /* enchantment-Indizes + * 0: permEnchant + * 3: tempEnchant + * 6: gem1 + * 9: gem2 + * 12: gem3 + * 15: socketBonus [not used] + * 18: extraSocket [only check existance] + * 21 - 30: randomProp enchantments + */ + + + DB::Aowow()->qry('DELETE FROM ::profiler_items WHERE `id` = %i', $profileId); + $items = DB::Characters($realmId)->selectAssoc('SELECT ci.`slot` AS ARRAY_KEY, ii.`itemEntry`, ii.`enchantments`, ii.`randomPropertyId` FROM character_inventory ci JOIN item_instance ii ON ci.`item` = ii.`guid` WHERE ci.`guid` = %i AND `bag` = 0 AND `slot` BETWEEN 0 AND 18', $char['guid']); + + $gemItems = []; + $permEnch = []; + $mhItem = 0; + $ohItem = 0; + + foreach ($items as $slot => $item) + { + $ench = explode(' ', $item['enchantments']); + $gEnch = []; + foreach ([6, 9, 12] as $idx) + if ($ench[$idx]) + $gEnch[$idx] = $ench[$idx]; + + if ($gEnch) + { + $gi = DB::Aowow()->selectCol('SELECT `gemEnchantmentId` AS ARRAY_KEY, `id` FROM ::items WHERE `class` = %i AND `gemEnchantmentId` IN %in', ITEM_CLASS_GEM, $gEnch); + foreach ($gEnch as $eId) + { + if (isset($gemItems[$eId])) + $gemItems[$eId][1]++; + else + $gemItems[$eId] = [$gi[$eId], 1]; + } + } + + if ($slot + 1 == 16) + $mhItem = $item['itemEntry']; + if ($slot + 1 == 17) + $ohItem = $item['itemEntry']; + + if ($ench[0]) + $permEnch[$slot] = $ench[0]; + + $data = array( + 'id' => $profileId, + 'slot' => $slot + 1, + 'item' => $item['itemEntry'], + 'subItem' => $item['randomPropertyId'], + 'permEnchant' => $ench[0], + 'tempEnchant' => $ench[3], + 'extraSocket' => (int)!!$ench[18], + 'gem1' => isset($gemItems[$ench[6]]) ? $gemItems[$ench[6]][0] : 0, + 'gem2' => isset($gemItems[$ench[9]]) ? $gemItems[$ench[9]][0] : 0, + 'gem3' => isset($gemItems[$ench[12]]) ? $gemItems[$ench[12]][0] : 0, + 'gem4' => 0 // serverside items cant have more than 3 sockets. (custom profile thing) + ); + + DB::Aowow()->qry('INSERT INTO ::profiler_items %v', $data); + } + + CLI::write(' ..inventory'); + + + /**************/ + /* basic info */ + /**************/ + + $data = array( + 'realm' => $realmId, + 'realmGUID' => $charGuid, + 'name' => $char['name'], + 'renameItr' => 0, + 'race' => $char['race'], + 'class' => $char['class'], + 'level' => $char['level'], + 'gender' => $char['gender'], + 'skincolor' => $char['skin'], + 'facetype' => $char['face'], // maybe features + 'hairstyle' => $char['hairStyle'], + 'haircolor' => $char['hairColor'], + 'features' => $char['facialStyle'], // maybe facetype + 'title' => $char['chosenTitle'] ? DB::Aowow()->selectCell('SELECT `id` FROM ::titles WHERE `bitIdx` = %i', $char['chosenTitle']) : 0, + 'playedtime' => $char['totaltime'], + 'nomodelMask' => ($char['playerFlags'] & 0x400 ? (1 << SLOT_HEAD) : 0) | ($char['playerFlags'] & 0x800 ? (1 << SLOT_BACK) : 0), + 'talenttree1' => 0, + 'talenttree2' => 0, + 'talenttree3' => 0, + 'talentbuild1' => '', + 'talentbuild2' => '', + 'glyphs1' => '', + 'glyphs2' => '', + 'activespec' => $char['activeTalentGroup'], + 'guild' => null, + 'guildRank' => null, + 'gearscore' => 0, + 'achievementpoints' => 0 + ); + + // char is flagged for rename + if ($char['at_login'] & 0x1) + { + if ($ri = DB::Aowow()->selectCell('SELECT MAX(`renameItr`) FROM ::profiler_profiles WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $charGuid)) + $data['renameItr'] = $ri; + else if ($ri = DB::Aowow()->selectCell('SELECT MAX(`renameItr`) FROM ::profiler_profiles WHERE `realm` = %i AND `custom` = 0 AND `name` = %s', $realmId, $char['name'])) + $data['renameItr'] = ++$ri; + else + $data['renameItr'] = 1; + } + + + /********************/ + /* talents + glyphs */ + /********************/ + + $t = DB::Characters($realmId)->selectCol('SELECT `talentGroup` AS ARRAY_KEY, `spell` AS ARRAY_KEY2, `spell` FROM character_talent WHERE `guid` = %i', $char['guid']); + $g = DB::Characters($realmId)->selectAssoc('SELECT `talentGroup` AS ARRAY_KEY, `glyph1` AS "g1", `glyph2` AS "g4", `glyph3` AS "g5", `glyph4` AS "g2", `glyph5` AS "g3", `glyph6` AS "g6" FROM character_glyphs WHERE `guid` = %i', $char['guid']); + for ($i = 0; $i < 2; $i++) + { + // talents + for ($j = 0; $j < 3; $j++) + { + $_ = DB::Aowow()->selectCol('SELECT `spell` AS ARRAY_KEY, MAX(IF(`spell` IN %in, `rank`, 0)) FROM ::talents WHERE `class` = %i AND `tab` = %i GROUP BY `id` ORDER BY `row`, `col` ASC', $t[$i] ?? [0], $cl->value, $j); + $data['talentbuild'.($i + 1)] .= implode('', $_); + if ($data['activespec'] == $i) + $data['talenttree'.($j + 1)] = array_sum($_); + } + + // glyphs + if (isset($g[$i])) + { + $gProps = []; + for ($j = 1; $j <= 6; $j++) + if ($g[$i]['g'.$j]) + $gProps[$j] = $g[$i]['g'.$j]; + + if ($gProps) + { + $gItems = DB::Aowow()->selectCol( + 'SELECT i.`id` + FROM ::glyphproperties gp + JOIN ::spell s ON s.`effect1MiscValue` = gp.`id` AND s.`effect1Id` = %i + JOIN ::items i ON i.`class` = %i AND i.`spellId1` = s.`id` AND (i.`cuFlags` & %i) = 0 + WHERE gp.`id` IN %in', + SPELL_EFFECT_APPLY_GLYPH, ITEM_CLASS_GLYPH, CUSTOM_DISABLED | CUSTOM_UNAVAILABLE | CUSTOM_EXCLUDE_FOR_LISTVIEW, $gProps + ); + + if ($gItems) + $data['glyphs'.($i + 1)] = implode(':', $gItems); + } + } + } + + $t = array( + 'spent' => [$data['talenttree1'], $data['talenttree2'], $data['talenttree3']], + 'spec' => 0 + ); + if ($t['spent'][0] > $t['spent'][1] && $t['spent'][0] > $t['spent'][2]) + $t['spec'] = 1; + else if ($t['spent'][1] > $t['spent'][0] && $t['spent'][1] > $t['spent'][2]) + $t['spec'] = 2; + else if ($t['spent'][2] > $t['spent'][1] && $t['spent'][2] > $t['spent'][0]) + $t['spec'] = 3; + + // calc gearscore + if ($items) + $data['gearscore'] += (new ItemList(array(['id', array_column($items, 'itemEntry')])))->getScoreTotal($data['class'], $t, $mhItem, $ohItem); + + if ($gemItems) + { + $gemScores = new ItemList(array(['id', array_column($gemItems, 0)])); + foreach ($gemItems as [$itemId, $mult]) + if (isset($gemScores->json[$itemId]['gearscore'])) + $data['gearscore'] += $gemScores->json[$itemId]['gearscore'] * $mult; + } + + if ($permEnch) // fuck this shit .. we are guestimating this! + { + // enchantId => multiple spells => multiple items with varying itemlevels, quality, whatevs + // cant reasonably get to the castItem from enchantId and slot + + $profSpec = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `skillLevel` AS "1", `skillLine` AS "0" FROM ::itemenchantment WHERE `id` IN %in', $permEnch); + foreach ($permEnch as $slot => $eId) + { + if (!isset($profSpec[$eId])) + { + trigger_error('char #'.$charGuid.' on realm #'.$realmId.' has item in slot #'.$slot.' with invalid perm enchantment #'.CLI::bold($eId), E_USER_WARNING); + continue; + } + + if ($x = Util::getEnchantmentScore(0, 0, !!$profSpec[$eId][1], $eId)) + $data['gearscore'] += $x; + else if ($profSpec[$eId][0] != 776) // not runeforging + $data['gearscore'] += 17; // assume high quality enchantment for unknown cases + } + } + + $data['lastupdated'] = time(); + + CLI::write(' ..basic info'); + + + /***************/ + /* hunter pets */ + /***************/ + + if ($cl == ChrClass::HUNTER) + { + DB::Aowow()->qry('DELETE FROM ::profiler_pets WHERE `owner` = %i', $profileId); + $pets = DB::Characters($realmId)->selectAssoc('SELECT `id` AS ARRAY_KEY, `entry`, `modelId`, `name` FROM character_pet WHERE `owner` = %i', $charGuid); + foreach ($pets as $petGuid => $petData) + { + $petSpells = DB::Characters($realmId)->selectCol('SELECT `spell` FROM pet_spell WHERE `guid` = %i', $petGuid); + $morePet = DB::Aowow()->selectRow( + 'SELECT IFNULL(c3.`id`, IFNULL(c2.`id`, IFNULL(c1.`id`, c.`id`))) AS "entry", p.`type`, c.`family` + FROM ::pet p + JOIN ::creature c ON c.`family` = p.`id` + LEFT JOIN ::creature c1 ON c1.`difficultyEntry1` = c.`id` + LEFT JOIN ::creature c2 ON c2.`difficultyEntry2` = c.`id` + LEFT JOIN ::creature c3 ON c3.`difficultyEntry3` = c.`id` + WHERE c.`id` = %i', + $petData['entry'] + ); + + if (!$morePet) + { + trigger_error('char #'.$charGuid.' on realm #'.$realmId.' owns pet #'.$petGuid.' (creature entry: #'.$petData['entry'].') without pet family. skipping...', E_USER_WARNING); + continue; + } + + $_ = DB::Aowow()->selectCol( + 'SELECT IFNULL(t2.`rank`, 0) + FROM ::talents t1 + LEFT JOIN (SELECT `id`, `rank` FROM ::talents WHERE `spell` IN %in) t2 ON t2.`id` = t1.`id` + WHERE `class` = 0 AND `petTypeMask` = %i + GROUP BY t1.`id` + ORDER BY t1.`row`, t1.`col`, t1.`id` ASC', + $petSpells ?: [0], 1 << $morePet['type'] + ); + + $pet = array( + 'id' => $petGuid, + 'owner' => $profileId, + 'name' => $petData['name'], + 'family' => $morePet['family'], + 'npc' => $morePet['entry'], + 'displayId' => $petData['modelId'], + 'talents' => implode('', $_) + ); + + DB::Aowow()->qry('INSERT INTO ::profiler_pets %v', $pet); + } + + CLI::write(' ..hunter pets'); + } + + + /*******************/ + /* completion data */ + /*******************/ + + // done quests // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_quests WHERE `id` = %i', $profileId); + + if ($quests = DB::Characters($realmId)->selectCol('SELECT `quest` FROM character_queststatus_rewarded WHERE `guid` = %i', $char['guid'])) + DB::Aowow()->qry('INSERT INTO ::profiler_completion_quests %m', array( + 'id' => array_fill(0, count($quests), $profileId), + 'questId' => $quests + )); + + CLI::write(' ..quests'); + + + // known skills (professions only) // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_skills WHERE `id` = %i', $profileId); + + $skAllowed = DB::Aowow()->selectCol('SELECT `id` FROM ::skillline WHERE `typeCat` IN (9, 11) AND (`cuFlags` & %i) = 0', CUSTOM_EXCLUDE_FOR_LISTVIEW); + if ($skills = DB::Characters($realmId)->selectAssoc('SELECT `skill`, `value`, `max` FROM character_skills WHERE `guid` = %i AND `skill` IN %in', $char['guid'], $skAllowed)) + { + $racials = DB::Aowow()->selectAssoc('SELECT `effect1MiscValue` AS ARRAY_KEY, `effect1DieSides` + `effect1BasePoints` AS qty, `reqRaceMask`, `reqClassMask` FROM ::spell WHERE `typeCat` = -4 AND `effect1Id` = %i AND `effect1AuraId` = %i', SPELL_EFFECT_APPLY_AURA, SPELL_AURA_MOD_SKILL_TALENT); + + foreach ($skills as &$sk) // apply racial profession bonuses + { + if (!isset($racials[$sk['skill']])) + continue; + + $r = $racials[$sk['skill']]; + if ($ra->matches($r['reqRaceMask']) && $cl->matches($r['reqClassMask'])) + { + $sk['value'] += $r['qty']; + $sk['max'] += $r['qty']; + } + } + unset($sk); + + DB::Aowow()->qry('INSERT INTO ::profiler_completion_skills %m', array( + 'id' => array_fill(0, count($skills), $profileId), + 'skillId' => array_column($skills, 'skill'), + 'value' => array_column($skills, 'value'), + 'max' => array_column($skills, 'max') + )); + } + + CLI::write(' ..professions'); + + + // reputation // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_reputation WHERE `id` = %i', $profileId); + + // get base values for this race/class + $reputation = []; + $baseRep = DB::Aowow()->selectCol( + 'SELECT `id` AS ARRAY_KEY, `baseRepValue1` FROM ::factions WHERE `baseRepValue1` AND (`baseRepRaceMask1` & %i OR (`baseRepClassMask1` AND NOT `baseRepRaceMask1`)) AND ((`baseRepClassMask1` & %i) OR NOT `baseRepClassMask1`) UNION + SELECT `id` AS ARRAY_KEY, `baseRepValue2` FROM ::factions WHERE `baseRepValue2` AND (`baseRepRaceMask2` & %i OR (`baseRepClassMask2` AND NOT `baseRepRaceMask2`)) AND ((`baseRepClassMask2` & %i) OR NOT `baseRepClassMask2`) UNION + SELECT `id` AS ARRAY_KEY, `baseRepValue3` FROM ::factions WHERE `baseRepValue3` AND (`baseRepRaceMask3` & %i OR (`baseRepClassMask3` AND NOT `baseRepRaceMask3`)) AND ((`baseRepClassMask3` & %i) OR NOT `baseRepClassMask3`) UNION + SELECT `id` AS ARRAY_KEY, `baseRepValue4` FROM ::factions WHERE `baseRepValue4` AND (`baseRepRaceMask4` & %i OR (`baseRepClassMask4` AND NOT `baseRepRaceMask4`)) AND ((`baseRepClassMask4` & %i) OR NOT `baseRepClassMask4`)', + $ra->toMask(), $cl->toMask(), $ra->toMask(), $cl->toMask(), $ra->toMask(), $cl->toMask(), $ra->toMask(), $cl->toMask() + ); + + $insCols = []; + if ($reputation = DB::Characters($realmId)->selectAssoc('SELECT `faction`, `standing` FROM character_reputation WHERE `guid` = %i AND (`flags` & 0x4) = 0', $char['guid'])) + { + // merge back base values for encountered factions + foreach ($reputation as $set) + { + $insCols['id'][] = $profileId; + $insCols['factionId'][] = $set['faction']; + $insCols['standing'][] = $set['standing'] + ($baseRep[$set['faction']] ?? 0); + + unset($baseRep[$set['faction']]); + } + } + + // insert base values for not yet encountered factions + foreach ($baseRep as $id => $val) + { + $insCols['id'][] = $profileId; + $insCols['factionId'][] = $id; + $insCols['standing'][] = $val; + } + + DB::Aowow()->qry('INSERT INTO ::profiler_completion_reputation %m', $insCols); + + CLI::write(' ..reputation'); + + + // known titles // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_titles WHERE `id` = %i', $profileId); + + if ($indizes = Util::indexBitBlob($char['knownTitles'])) + DB::Aowow()->qry('INSERT INTO ::profiler_completion_titles SELECT %i, `id` FROM ::titles WHERE `bitIdx` IN %in', $profileId, $indizes); + + CLI::write(' ..titles'); + + + // achievements // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_achievements WHERE `id` = %i', $profileId); + + if ($achievements = DB::Characters($realmId)->selectAssoc('SELECT `achievement`, `date` FROM character_achievement WHERE `guid` = %i', $char['guid'])) + { + DB::Aowow()->qry('INSERT INTO ::profiler_completion_achievements %m', array( + 'id' => array_fill(0, count($achievements), $profileId), + 'achievementId' => array_column($achievements, 'achievement'), + 'date' => array_column($achievements, 'date') + )); + + $data['achievementpoints'] = DB::Aowow()->selectCell('SELECT SUM(`points`) FROM ::achievement WHERE `id` IN %in AND (`flags` & %i) = 0', array_column($achievements, 'achievement'), ACHIEVEMENT_FLAG_COUNTER); + } + + CLI::write(' ..achievements'); + + + // raid progression // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_statistics WHERE `id` = %i', $profileId); + + if ($progress = DB::Characters($realmId)->selectAssoc('SELECT `criteria`, `date`, `counter` FROM character_achievement_progress WHERE `guid` = %i AND `criteria` IN %in', $char['guid'], self::$raidProgression)) + { + array_walk($progress, fn(&$x) => $x['achievement'] = array_search($x['criteria'], self::$raidProgression)); + + DB::Aowow()->qry('INSERT INTO ::profiler_completion_statistics %m', array( + 'id' => array_fill(0, count($progress), $profileId), + 'achievementId' => array_column($progress, 'achievement'), + 'date' => array_column($progress, 'date'), + 'counter' => array_column($progress, 'counter') + )); + } + + CLI::write(' ..raid progression'); + + + // known spells // + + DB::Aowow()->qry('DELETE FROM ::profiler_completion_spells WHERE `id` = %i', $profileId); + + if ($spells = DB::Characters($realmId)->selectCol('SELECT `spell` FROM character_spell WHERE `guid` = %i AND `disabled` = 0', $char['guid'])) + DB::Aowow()->qry('INSERT INTO ::profiler_completion_spells %m', array( + 'id' => array_fill(0, count($spells), $profileId), + 'questId' => $spells + )); + + // apply auto-learned spells from trade skills + if ($skills) + DB::Aowow()->qry( + 'INSERT INTO ::profiler_completion_spells + SELECT %i, `spellId` + FROM ::skilllineability + WHERE `skillLineId` IN %in AND + `acquireMethod` = 1 AND + (`reqRaceMask` = 0 OR `reqRaceMask` & %i) AND + (`reqClassMask` = 0 OR `reqClassMask` & %i)', + $profileId, + array_column($skills, 'skillId'), + $ra->toMask(), + $cl->toMask() + ); + + CLI::write(' ..known spells (vanity pets & mounts)'); + + + /****************/ + /* related data */ + /****************/ + + // guilds + if ($guild = DB::Characters($realmId)->selectRow('SELECT g.`name` AS `name`, g.`guildid` AS `id`, gm.`rank` FROM guild_member gm JOIN guild g ON g.`guildid` = gm.`guildid` WHERE gm.`guid` = %i', $char['guid'])) + { + $guildId = 0; + if (!($guildId = DB::Aowow()->selectCell('SELECT id FROM ::profiler_guild WHERE realm = %i AND realmGUID = %i', $realmId, $guild['id']))) + { + $gData = array( // only most basic data + 'realm' => $realmId, + 'realmGUID' => $guild['id'], + 'name' => $guild['name'], + 'nameUrl' => self::urlize($guild['name']), + 'stub' => 1 + ); + + $guildId = DB::Aowow()->qry('INSERT IGNORE INTO ::profiler_guild %v', $gData); + } + + $data['guild'] = $guildId; + $data['guildRank'] = $guild['rank']; + } + + CLI::write(' ..basic guild data'); + + + // arena teams + $teams = DB::Characters($realmId)->selectAssoc('SELECT at.`arenaTeamId` AS ARRAY_KEY, at.`name`, at.`type`, IF(at.`captainGuid` = atm.`guid`, 1, 0) AS `captain`, atm.* FROM arena_team at JOIN arena_team_member atm ON atm.`arenaTeamId` = at.`arenaTeamId` WHERE atm.`guid` = %i', $char['guid']); + foreach ($teams as $rGuid => $t) + { + $teamId = 0; + if (!($teamId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_arena_team WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $rGuid))) + { + $team = array( // only most basic data + 'realm' => $realmId, + 'realmGUID' => $rGuid, + 'name' => $t['name'], + 'nameUrl' => self::urlize($t['name']), + 'type' => $t['type'], + 'stub' => 1 + ); + + $teamId = DB::Aowow()->qry('INSERT IGNORE INTO ::profiler_arena_team %v', $team); + } + + $member = array( + 'arenaTeamId' => $teamId, + 'profileId' => $profileId, + 'captain' => $t['captain'], + 'weekGames' => $t['weekGames'], + 'weekWins' => $t['weekWins'], + 'seasonGames' => $t['seasonGames'], + 'seasonWins' => $t['seasonWins'], + 'personalRating' => $t['personalRating'] + ); + + // delete members from other teams of the same type + DB::Aowow()->qry( + 'DELETE atm + FROM ::profiler_arena_team_member atm + JOIN ::profiler_arena_team at ON atm.`arenaTeamId` = at.`id` AND at.`type` = %i + WHERE atm.`profileId` = %i AND atm.`arenaTeamId` <> %i', + $t['type'], $profileId, $teamId + ); + + DB::Aowow()->qry('INSERT INTO ::profiler_arena_team_member %v ON DUPLICATE KEY UPDATE %a', $member, array_slice($member, 2)); + } + + CLI::write(' ..associated arena teams'); + + /*********************/ + /* mark char as done */ + /*********************/ + + if (DB::Aowow()->qry('UPDATE ::profiler_profiles SET %a WHERE `realm` = %i AND `realmGUID` = %i', $data, $realmId, $charGuid) !== null) + DB::Aowow()->qry('UPDATE ::profiler_profiles SET `stub` = 0 WHERE `id` = %i', $profileId); + + return self::FETCH_RESULT_OK; + } + + public static function getGuildFromRealm(int $realmId, int $guildGuid) : int + { + $guild = DB::Characters($realmId)->selectRow('SELECT `guildId`, `name`, `createDate`, `info`, `backgroundColor`, `emblemStyle`, `emblemColor`, `borderStyle`, `borderColor` FROM guild WHERE `guildId` = %i', $guildGuid); + if (!$guild) + return self::FETCH_RESULT_ERR_NOT_FOUND; + + if (!$guild['name']) + return self::FETCH_RESULT_ERR_NAME_EMPTY; + + // reminder: this query should not fail: a placeholder entry is created as soon as a team listview is created or team detail page is called + $guildId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_guild WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $guild['guildId']); + + CLI::write('fetching guild #'.$guildGuid.' from realm #'.$realmId); + CLI::write('writing...'); + + + /**************/ + /* Guild Data */ + /**************/ + + unset($guild['guildId']); + $guild['nameUrl'] = self::urlize($guild['name']); + + DB::Aowow()->qry('UPDATE ::profiler_guild SET %a WHERE `realm` = %i AND `realmGUID` = %i', $guild, $realmId, $guildGuid); + + // ranks + DB::Aowow()->qry('DELETE FROM ::profiler_guild_rank WHERE `guildId` = %i', $guildId); + if ($ranks = DB::Characters($realmId)->selectAssoc('SELECT %i AS `guildId`, `rid` AS "rank", `rname` AS "name" FROM guild_rank WHERE `guildid` = %i', $guildId, $guildGuid)) + foreach ($ranks as $r) // at most 10 per guild. don't bother setting up a multi-insert (%m) + DB::Aowow()->qry('INSERT INTO ::profiler_guild_rank %v', $r); + + CLI::write(' ..guild data'); + + + /***************/ + /* Member Data */ + /***************/ + + $conditions = array( + ['g.guildid', $guildGuid], + ['deleteInfos_Account', null], + ['level', MAX_LEVEL, '<='], // prevents JS errors + [['extra_flags', self::CHAR_GMFLAGS, '&'], 0] // not a staff char + ); + + // this here should all happen within ProfileList + $members = new RemoteProfileList($conditions, ['sv' => $realmId]); + if ($members->error) + return self::FETCH_RESULT_ERR_NO_MEMBERS; + + $members->initializeLocalEntries(); + + CLI::write(' ..guild members'); + + + /*********************/ + /* mark guild as done */ + /*********************/ + + DB::Aowow()->qry('UPDATE ::profiler_guild SET `stub` = 0 WHERE `id` = %i', $guildId); + + return self::FETCH_RESULT_OK; + } + + public static function getArenaTeamFromRealm(int $realmId, int $teamGuid) : int + { + $team = DB::Characters($realmId)->selectRow('SELECT `arenaTeamId`, `name`, `type`, `captainGuid`, `rating`, `seasonGames`, `seasonWins`, `weekGames`, `weekWins`, `rank`, `backgroundColor`, `emblemStyle`, `emblemColor`, `borderStyle`, `borderColor` FROM arena_team WHERE `arenaTeamId` = %i', $teamGuid); + if (!$team) + return self::FETCH_RESULT_ERR_NOT_FOUND; + + if (!$team['name']) + return self::FETCH_RESULT_ERR_NAME_EMPTY; + + // reminder: this query should not fail: a placeholder entry is created as soon as a team listview is created or team detail page is called + $teamId = DB::Aowow()->selectCell('SELECT `id` FROM ::profiler_arena_team WHERE `realm` = %i AND `realmGUID` = %i', $realmId, $team['arenaTeamId']); + + CLI::write('fetching arena team #'.$teamGuid.' from realm #'.$realmId); + CLI::write('writing...'); + + + /*************/ + /* Team Data */ + /*************/ + + $captain = $team['captainGuid']; + unset($team['captainGuid'], $team['arenaTeamId']); + $team['nameUrl'] = self::urlize($team['name']); + + DB::Aowow()->qry('UPDATE ::profiler_arena_team SET %a WHERE `realm` = %i AND `realmGUID` = %i', $team, $realmId, $teamGuid); + + CLI::write(' ..team data'); + + + /***************/ + /* Member Data */ + /***************/ + + $members = DB::Characters($realmId)->selectAssoc( + 'SELECT atm.`guid` AS ARRAY_KEY, atm.`arenaTeamId`, atm.`weekGames`, atm.`weekWins`, atm.`seasonGames`, atm.`seasonWins`, atm.`personalrating` + FROM arena_team_member atm + JOIN characters c ON c.`guid` = atm.`guid` AND + c.`deleteInfos_Account` IS NULL AND + c.`level` <= %i AND + (c.`extra_flags` & %i) = 0 + WHERE `arenaTeamId` = %i', + MAX_LEVEL, + self::CHAR_GMFLAGS, + $teamGuid + ); + + $conditions = array( + ['c.guid', array_keys($members)], + ['deleteInfos_Account', null], + ['level', MAX_LEVEL, '<='], // prevents JS errors + [['extra_flags', self::CHAR_GMFLAGS, '&'], 0] // not a staff char + ); + + $mProfiles = new RemoteProfileList($conditions, ['sv' => $realmId]); + if ($mProfiles->error) + return self::FETCH_RESULT_ERR_NO_MEMBERS; + + $mProfiles->initializeLocalEntries(); + foreach ($mProfiles->iterate() as $__) + { + + $mGuid = $mProfiles->getField('guid'); + + $members[$mGuid]['arenaTeamId'] = $teamId; + $members[$mGuid]['captain'] = (int)($mGuid == $captain); + $members[$mGuid]['profileId'] = $mProfiles->getField('id'); + } + + // delete members from other teams of the same type... + DB::Aowow()->qry( + 'DELETE atm + FROM ::profiler_arena_team_member atm + JOIN ::profiler_arena_team at ON atm.`arenaTeamId` = at.`id` AND at.`type` = %i + WHERE atm.`profileId` IN %in', + $team['type'], + array_column($members, 'profileId') + ); + + // ...and purge this teams member + DB::Aowow()->qry('DELETE FROM ::profiler_arena_team_member WHERE `arenaTeamId` = %i', $teamId); + + foreach ($members as $m) // at most 10 per team (5x2) don't bother setting up multi-insert (%m) + DB::Aowow()->qry('INSERT INTO ::profiler_arena_team_member %v', $m); + + CLI::write(' ..team members'); + + + /*********************/ + /* mark team as done */ + /*********************/ + + DB::Aowow()->qry('UPDATE ::profiler_arena_team SET `stub` = 0 WHERE `id` = %i', $teamId); + + return self::FETCH_RESULT_OK; + } +} + +?> diff --git a/includes/components/report.class.php b/includes/components/report.class.php new file mode 100644 index 00000000..26b4ca63 --- /dev/null +++ b/includes/components/report.class.php @@ -0,0 +1,305 @@ + array( + self::GEN_FEEDBACK => true, + self::GEN_BUG_REPORT => true, + self::GEN_TYPO_TRANSLATION => true, + self::GEN_OP_ADVERTISING => true, + self::GEN_OP_PARTNERSHIP => true, + self::GEN_PRESS_INQUIRY => true, + self::GEN_MISCELLANEOUS => true, + self::GEN_MISINFORMATION => true + ), + self::MODE_COMMENT => array( + self::CO_ADVERTISING => U_GROUP_MODERATOR, + self::CO_INACCURATE => true, + self::CO_OUT_OF_DATE => true, + self::CO_SPAM => U_GROUP_MODERATOR, + self::CO_INAPPROPRIATE => U_GROUP_MODERATOR, + self::CO_MISCELLANEOUS => U_GROUP_MODERATOR + ), + self::MODE_FORUM_POST => array( + self::FO_ADVERTISING => U_GROUP_MODERATOR, + self::FO_AVATAR => true, + self::FO_INACCURATE => true, + self::FO_OUT_OF_DATE => U_GROUP_MODERATOR, + self::FO_SPAM => U_GROUP_MODERATOR, + self::FO_STICKY_REQUEST => U_GROUP_MODERATOR, + self::FO_INAPPROPRIATE => U_GROUP_MODERATOR + ), + self::MODE_SCREENSHOT => array( + self::SS_INACCURATE => true, + self::SS_OUT_OF_DATE => true, + self::SS_INAPPROPRIATE => U_GROUP_MODERATOR, + self::SS_MISCELLANEOUS => U_GROUP_MODERATOR + ), + self::MODE_CHARACTER => array( + self::PR_INACCURATE_DATA => true, + self::PR_MISCELLANEOUS => true + ), + self::MODE_VIDEO => array( + self::VI_INACCURATE => true, + self::VI_OUT_OF_DATE => true, + self::VI_INAPPROPRIATE => U_GROUP_MODERATOR, + self::VI_MISCELLANEOUS => U_GROUP_MODERATOR + ), + self::MODE_GUIDE => array( + self::AR_INACCURATE => true, + self::AR_OUT_OF_DATE => true, + self::AR_MISCELLANEOUS => true + ) + ); + + private const ERR_NONE = 0; // aka: success + private const ERR_INVALID_CAPTCHA = 1; // captcha not in use + private const ERR_DESC_TOO_LONG = 2; + private const ERR_NO_DESC = 3; + private const ERR_ALREADY_REPORTED = 7; + private const ERR_MISCELLANEOUS = -1; + + public const STATUS_OPEN = 0; + public const STATUS_ASSIGNED = 1; + public const STATUS_CLOSED_WONTFIX = 2; + public const STATUS_CLOSED_SOLVED = 3; + + private int $errorCode = self::ERR_NONE; + + + public function __construct(private int $mode, private int $reason, private ?int $subject = 0) + { + if ($mode < 0 || $reason <= 0) + { + trigger_error('Report - malformed contact request received', E_USER_ERROR); + $this->errorCode = self::ERR_MISCELLANEOUS; + return; + } + + if (!isset($this->context[$mode][$reason])) + { + trigger_error('Report - report has invalid context (mode:'.$mode.' / reason:'.$reason.')', E_USER_ERROR); + $this->errorCode = self::ERR_MISCELLANEOUS; + return; + } + + if (!User::isLoggedIn() && !User::$ip) + { + trigger_error('Report - could not determine IP for anonymous user', E_USER_ERROR); + $this->errorCode = self::ERR_MISCELLANEOUS; + return; + } + + $this->subject ??= 0; // 0 for utility, tools and misc pages? + } + + private function checkTargetContext(?string $url) : int + { + $where = array( + ['`mode` = %i ', $this->mode], + ['`reason`= %i ', $this->reason], + ['`subject` = %i', $this->subject], + ); + if (User::isLoggedIn()) // check already reported + $where[] = ['`userId` = %i', User::$id]; + else + $where[] = ['`ip` = %s', User::$ip]; + if ($url) + $where[] = ['`url` = %s', $url]; + + if (DB::Aowow()->selectCell('SELECT 1 FROM ::reports WHERE %and', $where)) + return self::ERR_ALREADY_REPORTED; + + // check targeted post/postOwner staff status + $ctxCheck = $this->context[$this->mode][$this->reason]; + if (is_int($ctxCheck)) + { + $roles = User::$groups; + if ($this->mode == self::MODE_COMMENT) + $roles = DB::Aowow()->selectCell('SELECT `roles` FROM ::comments WHERE `id` = %i', $this->subject); + // else if if ($this->mode == self::MODE_FORUM_POST) + // $roles = DB::Aowow()->selectCell('SELECT `roles` FROM ::forum_posts WHERE `id` = %i', $this->subject); + + return $roles & $ctxCheck ? self::ERR_NONE : self::ERR_MISCELLANEOUS; + } + else + return $ctxCheck ? self::ERR_NONE : self::ERR_MISCELLANEOUS; + + // Forum not in use, else: + // check post owner + // User::$id == post.op && !post.sticky; + // check user custom avatar + // g_users[post.user].avatar == 2 && (post.roles & U_GROUP_MODERATOR) == 0 + } + + public function create(string $desc, ?string $userAgent = null, ?string $appName = null, ?string $pageUrl = null, ?string $relUrl = null, ?string $email = null) : bool + { + if ($this->errorCode) + return false; + + if (!$desc) + { + $this->errorCode = self::ERR_NO_DESC; + return false; + } + + if (mb_strlen($desc) > 500) + { + $this->errorCode = self::ERR_DESC_TOO_LONG; + return false; + } + + // clean up src url: dont use anchors, clean up query + if ($pageUrl) + { + $urlParts = parse_url($pageUrl); + if (!empty($urlParts['query'])) + { + parse_str($urlParts['query'], $query); // kills redundant param declarations + unset($query['locale']); // locale param shouldn't be needed. more..? + $urlParts['query'] = http_build_query($query); + } + + $pageUrl = ''; + if (isset($urlParts['scheme'])) + $pageUrl .= $urlParts['scheme'].':'; + + $pageUrl .= '//'.($urlParts['host'] ?? '').($urlParts['path'] ?? ''); + + if (isset($urlParts['query'])) + $pageUrl .= '?'.$urlParts['query']; + } + + if ($err = $this->checkTargetContext($pageUrl)) + { + $this->errorCode = $err; + return false; + } + + $update = array( + 'userId' => User::$id, + 'createDate' => time(), + 'mode' => $this->mode, + 'reason' => $this->reason, + 'subject' => $this->subject, + 'ip' => User::$ip, + 'description' => $desc, + 'userAgent' => $userAgent ?: User::$agent, + 'appName' => $appName ?: (get_browser(null, true)['browser'] ?: '') + ); + + if ($pageUrl) + $update['url'] = $pageUrl; + + if ($relUrl) + $update['relatedurl'] = $relUrl; + + if ($email) + $update['email'] = $email; + + return DB::Aowow()->qry('INSERT INTO ::reports %v', $update); + } + + public function getSimilar(int ...$status) : array + { + if ($this->errorCode) + return []; + + foreach ($status as &$s) + if ($s < self::STATUS_OPEN || $s > self::STATUS_CLOSED_SOLVED) + unset($s); + + return DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, r.* FROM ::reports r WHERE %if', $status, '`status` IN %in AND', $status, '%end `mode` = %i AND `reason` = %i AND `subject` = %i', + $this->mode, $this->reason, $this->subject); + } + + public function close(int $closeStatus, bool $inclAssigned = false) : bool + { + if ($closeStatus != self::STATUS_CLOSED_SOLVED && $closeStatus != self::STATUS_CLOSED_WONTFIX) + return false; + + if (!User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU | U_GROUP_MOD)) + return false; + + $fromStatus = [self::STATUS_OPEN]; + if ($inclAssigned) + $fromStatus[] = self::STATUS_ASSIGNED; + + if ($reports = DB::Aowow()->selectCol('SELECT `id` AS ARRAY_KEY, `userId` FROM ::reports WHERE `status` IN %in AND `mode` = %i AND `reason` = %i AND `subject` = %i', + $fromStatus, $this->mode, $this->reason, $this->subject)) + { + DB::Aowow()->qry('UPDATE ::reports SET `status` = %i, `assigned` = 0 WHERE `id` IN %in', $closeStatus, array_keys($reports)); + + foreach ($reports as $rId => $uId) + Util::gainSiteReputation($uId, $closeStatus == self::STATUS_CLOSED_SOLVED ? SITEREP_ACTION_GOOD_REPORT : SITEREP_ACTION_BAD_REPORT, ['id' => $rId]); + + return true; + } + + return false; + } + + public function reopen(int $assignedTo = 0) : bool + { + // assignedTo = 0 ? status = STATUS_OPEN : status = STATUS_ASSIGNED, userId = assignedTo + return false; + } + + public function getError() : int + { + return $this->errorCode; + } +} + +?> diff --git a/includes/components/response/baseresponse.class.php b/includes/components/response/baseresponse.class.php new file mode 100644 index 00000000..29c0bbc2 --- /dev/null +++ b/includes/components/response/baseresponse.class.php @@ -0,0 +1,677 @@ + ACC_STATUS_CHANGE_PASS) + return Lang::main('intError'); + + // check if already processing + if ($_ = DB::Aowow()->selectCell('SELECT `statusTimer` - UNIX_TIMESTAMP() FROM ::account WHERE `email` = %s AND `status` > %i AND `statusTimer` > UNIX_TIMESTAMP()', $email, ACC_STATUS_NEW)) + return Lang::account('inputbox', 'error', 'isRecovering', [DateTime::formatTimeElapsed($_ * 1000)]); + + // create new token and write to db + $token = Util::createHash(); + if (!DB::Aowow()->qry('UPDATE ::account SET `token` = %s, `status` = %i, `statusTimer` = UNIX_TIMESTAMP() + %i WHERE `email` = %s', $token, $newStatus, Cfg::get('ACC_RECOVERY_DECAY'), $email)) + return Lang::main('intError'); + + // send recovery mail + if (!Util::sendMail($email, $mailTemplate, [$token], Cfg::get('ACC_RECOVERY_DECAY'))) + return Lang::main('intError2', ['send mail']); + + return ''; + } +} + +trait TrGetNext +{ + private function getNext(bool $forHeader = false) : string + { + $next = ''; + if (!empty($this->_get['next'])) + $next = $this->_get['next']; + else if (isset($_SERVER['HTTP_REFERER']) && strstr($_SERVER['HTTP_REFERER'], '?')) + $next = explode('?', $_SERVER['HTTP_REFERER'])[1]; + else if ($forHeader) + return '.'; + + return ($forHeader ? '?' : '').$next; + } +} + + +Interface ICache +{ + public function saveCache(string|Template\PageTemplate $toCache) : void; + public function loadCache(bool|string|Template\PageTemplate &$fromCache) : bool; + public function setOnCacheLoaded(callable $callback, mixed $params = null) : void; + public function getCacheKeyComponents() : array; + public function applyOnCacheLoaded(mixed &$data) : mixed; +} + +trait TrCache +{ + private const STORE_METHOD_OBJECT = 0; + private const STORE_METHOD_STRING = 1; + + private int $_cacheType = CACHE_TYPE_NONE; + private int $skipCache = 0x0; + private ?int $decay = null; + private string $cacheDir = 'cache/template/'; + private bool $cacheInited = false; + private ?\Memcached $memcached = null; + private array $onCacheLoaded = [null, null]; // post-load updater + + public static array $cacheStats = []; // load info for page footer + + // visible properties or given strings are cached + public function saveCache(string|object $toCache) : void + { + $this->initCache(); + + if ($this->_cacheType == CACHE_TYPE_NONE) + return; + + if (!Cfg::get('CACHE_MODE') /* || Cfg::get('DEBUG') */) + return; + + if (!$this->decay) + return; + + $cKey = $this->formatCacheKey(); + $method = is_object($toCache) ? self::STORE_METHOD_OBJECT : self::STORE_METHOD_STRING; + + if ($method == self::STORE_METHOD_OBJECT) + $toCache = serialize($toCache); + else + $toCache = (string)$toCache; + + if (is_callable($this->onCacheLoaded[0])) + $postCache = serialize($this->onCacheLoaded); + + if (Cfg::get('CACHE_MODE') & CACHE_MODE_MEMCACHED) + { + // on &refresh also clear related + if ($this->skipCache & CACHE_MODE_MEMCACHED) + $this->deleteCache(CACHE_MODE_MEMCACHED); + + $data = array( + 'timestamp' => time(), + 'lifetime' => $this->decay, + 'revision' => AOWOW_REVISION, + 'method' => $method, + 'postCache' => $postCache ?? null, + 'data' => $toCache + ); + + $this->memcached()?->set($cKey[2], $data); + } + + if (Cfg::get('CACHE_MODE') & CACHE_MODE_FILECACHE) + { + $data = time()." ".$this->decay." ".AOWOW_REVISION." ".$method."\n"; + $data .= ($postCache ?? '')."\n"; + $data .= gzcompress($toCache, 9); + + // on &refresh also clear related + if ($this->skipCache & CACHE_MODE_FILECACHE) + $this->deleteCache(CACHE_MODE_FILECACHE); + + if (Util::writeDir($this->cacheDir . implode(DIRECTORY_SEPARATOR, array_slice($cKey, 0, 2)))) + file_put_contents($this->cacheDir . implode(DIRECTORY_SEPARATOR, $cKey), $data); + } + } + + public function loadCache(mixed &$fromCache) : bool + { + $this->initCache(); + + if ($this->_cacheType == CACHE_TYPE_NONE) + return false; + + if (!Cfg::get('CACHE_MODE') /* || Cfg::get('DEBUG') */) + return false; + + $cKey = $this->formatCacheKey(); + $rev = $method = $data = $postCache = null; + + if ((Cfg::get('CACHE_MODE') & CACHE_MODE_MEMCACHED) && !($this->skipCache & CACHE_MODE_MEMCACHED)) + { + if ($cache = $this->memcached()?->get($cKey[2])) + { + $method = $cache['method']; + $data = $cache['data']; + $postCache = $cache['postCache']; + + if (($cache['timestamp'] + $cache['lifetime']) > time() && $cache['revision'] == AOWOW_REVISION) + self::$cacheStats = [CACHE_MODE_MEMCACHED, $cache['timestamp'], $cache['lifetime']]; + } + } + + if (!$data && (Cfg::get('CACHE_MODE') & CACHE_MODE_FILECACHE) && !($this->skipCache & CACHE_MODE_FILECACHE)) + { + $file = $this->cacheDir . implode(DIRECTORY_SEPARATOR, $cKey); + if (!file_exists($file)) + return false; + + $content = file_get_contents($file); + if (!$content) + return false; + + [$head, $postCache, $data] = explode("\n", $content, 3); + if (substr_count($head, ' ') != 3) + return false; + + [$time, $lifetime, $rev, $method] = explode(' ', $head); + + if (($time + $lifetime) < time() || $rev != AOWOW_REVISION) + return false; + + self::$cacheStats = [CACHE_MODE_FILECACHE, $time, $lifetime]; + $data = gzuncompress($data); + } + + if (!$data) + return false; + + if ($postCache) + $this->onCacheLoaded = unserialize($postCache); + + $fromCache = false; + if ($method == self::STORE_METHOD_OBJECT) + $fromCache = unserialize($data); + else if ($method == self::STORE_METHOD_STRING) + $fromCache = $data; + + return $fromCache !== false; + } + + public function deleteCache(int $modeMask = 0x3) : void + { + $this->initCache(); + + // type+typeId/catg+mode; 3+10+1 + $cKey = $this->formatCacheKey(); + $cKey[2] = substr($cKey[2], 0, 13); + + if ($modeMask & CACHE_MODE_MEMCACHED) + foreach ($this->memcached()?->getAllKeys() ?? [] as $k) + if (strpos($k, $cKey[2]) === 0) + $this->memcached()?->delete($k); + + if ($modeMask & CACHE_MODE_FILECACHE) + foreach (glob(implode(DIRECTORY_SEPARATOR, $cKey).'*') as $file) + unlink($file); + } + + private function memcached() : ?\Memcached + { + if (!class_exists('\Memcached')) + { + trigger_error('Memcached is enabled by us but not in php!', E_USER_ERROR); + return null; + } + + if (!$this->memcached && (Cfg::get('CACHE_MODE') & CACHE_MODE_MEMCACHED)) + { + $this->memcached = new \Memcached(); + $this->memcached->addServer('localhost', 11211); + } + + return $this->memcached; + } + + private function initCache() : void + { + // php's missing trait property conflict resolution is going to be the end of me + // also allow reevaluation even if inited. It may have changed in generate(), because of an error. + if (isset($this->cacheType)) + $this->_cacheType = $this->cacheType; + + if ($this->cacheInited) + return; + + // force refresh + if (isset($_GET['refresh']) && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU | U_GROUP_DEV)) + { + if ($_GET['refresh'] == 'filecache') + $this->skipCache = CACHE_MODE_FILECACHE; + else if ($_GET['refresh'] == 'memcached') + $this->skipCache = CACHE_MODE_MEMCACHED; + else if ($_GET['refresh'] == '') + $this->skipCache = CACHE_MODE_FILECACHE | CACHE_MODE_MEMCACHED; + } + + $this->decay ??= Cfg::get('CACHE_DECAY'); + + $cacheDir = Cfg::get('CACHE_DIR'); + if ($cacheDir && Util::writeDir($cacheDir)) + $this->cacheDir = mb_substr($cacheDir, -1) != '/' ? $cacheDir.'/' : $cacheDir; + + $this->cacheInited = true; + } + + // https://stackoverflow.com/questions/466521 + private function formatCacheKey() : array + { + [$dbType, $dbTypeIdOrCat, $staffMask, $miscInfo] = $this->getCacheKeyComponents(); + + $fileKey = ''; + // DBType: 3 + $fileKey .= str_pad(dechex($dbType & 0xFFF), 3, 0, STR_PAD_LEFT); + // DBTypeId: 6 / category: (2+4+4) + $fileKey .= str_pad(dechex($dbTypeIdOrCat & 0xFFFFFFFFFF), 2+4+4, 0, STR_PAD_LEFT); + // cacheType: 1 + $fileKey .= str_pad(dechex($this->_cacheType & 0xF), 1, 0, STR_PAD_LEFT); + // localeId: 2, + $fileKey .= str_pad(dechex(Lang::getLocale()->value & 0xFF), 2, 0, STR_PAD_LEFT); + // staff mask: 4 + $fileKey .= str_pad(dechex($staffMask & 0xFFFFFFFF), 4, 0, STR_PAD_LEFT); + // optional: miscInfo + if ($miscInfo) + $fileKey .= '-'.$miscInfo; + + // topDir, 2ndDir, file + return array( + str_pad(dechex($dbType & 0xFF), 2, 0, STR_PAD_LEFT), + str_pad(dechex(($dbTypeIdOrCat) & 0xFF), 2, 0, STR_PAD_LEFT), + $fileKey + ); + } + + public function setOnCacheLoaded(callable $callback, mixed $params = null) : void + { + $this->onCacheLoaded = [$callback, $params]; + } + + public function applyOnCacheLoaded(mixed &$data) : mixed + { + if (is_callable($this->onCacheLoaded[0])) + return $this->onCacheLoaded[0]($data, $this->onCacheLoaded[1]); + + return $data; + } + + public function setCacheDecay(int $seconds) : void + { + if ($seconds < 0) + return; + + $this->decay = $seconds; + } + + abstract public function getCacheKeyComponents() : array; +} + +trait TrSearch +{ + private string $query = ''; // sanitized search string + private int $searchMask = 0; // what to search for + private Search $searchObj; + + public function getCacheKeyComponents() : array + { + $misc = $this->query . // can be empty for upgrade search + serialize($this->_get['wt'] ?? null) . // extra &_GET not expected for normal and opensearch + serialize($this->_get['wtv'] ?? null) . + serialize($this->_get['type'] ?? null) . + serialize($this->_get['slots'] ?? null); + + return array( + -1, // DBType + $this->searchMask, // DBTypeId/category + User::$groups, // staff mask + md5($misc) // misc + ); + } +} + +Interface IProfilerList +{ + public function getRegions() : void; +} + +trait TrProfiler +{ + protected int $realmId = 0; + protected string $battlegroup = ''; // not implemented, since no pserver supports it + + public string $region = ''; + public string $realm = ''; + + private function getSubjectFromUrl(string $pageParam) : void + { + if (!$pageParam) + return; + + // cat[0] is always region + // cat[1] is realm or bGroup (must be realm if cat[2] is set) + // cat[2] is arena-team, guild or character + $cat = explode('.', mb_strtolower($pageParam), 3); + + $cat = array_map('urldecode', $cat); + + if (array_search($cat[0], Util::$regions) === false) + return; + + $this->region = $cat[0]; + + // if ($cat[1] == Profiler::urlize(Cfg::get('BATTLEGROUP'))) + // $this->battlegroup = Cfg::get('BATTLEGROUP'); + if (isset($cat[1])) + { + foreach (Profiler::getRealms() as $rId => $r) + { + if (Profiler::urlize($r['name'], true) == $cat[1]) + { + $this->realm = $r['name']; + $this->realmId = $rId; + if (isset($cat[2]) && mb_strlen($cat[2]) >= 2) + $this->subjectName = mb_strtolower($cat[2]); // cannot reconstruct original name from urlized form; match against special name field + + break; + } + } + } + } + + private function followBreadcrumbPath() : void + { + if ($this->region) + { + $this->breadcrumb[] = $this->region; + + if ($this->realm) + $this->breadcrumb[] = Profiler::urlize($this->realm, true); + // else + // $this->breadcrumb[] = Profiler::urlize(Cfg::get('BATTLEGROUP')); + } + } +} + +trait TrProfilerDetail +{ + use TrProfiler { TrProfiler::getSubjectFromUrl as _getSubjectFromUrl; } + + protected string $subjectName = ''; + + public int $typeId = 0; + public ?array $doResync = null; + + private function getSubjectFromUrl(string $pageParam) : void + { + if (!$pageParam) + return; + + if (Util::checkNumeric($pageParam, NUM_CAST_INT)) + $this->typeId = $pageParam; + else + $this->_getSubjectFromUrl($pageParam); + } + + private function handleIncompleteData(int $type, int $guid) : void + { + // queue full fetch + if ($newId = Profiler::scheduleResync($type, $this->realmId, $guid)) + { + $this->template = 'text-page-generic'; + $this->doResync = [Type::getFileString($type), $newId]; + $this->inputbox = ['inputbox-status', ['head' => Lang::profiler('firstUseTitle', [Util::ucFirst($this->subjectName), $this->realm])]]; + + return; + } + + // todo: base info should have been created in __construct .. why are we here..? + $this->forward('?'.Type::getFileString($type).'s='.$this->region.'.'.Profiler::urlize($this->realm, true).'&filter=na='.Util::ucFirst($this->subjectName).';ex=on'); + } +} + +trait TrProfilerList +{ + use TrProfiler; + + public array $regions = []; + + public function getRegions() : void + { + $usedRegions = array_column(Profiler::getRealms(), 'region'); + foreach (Util::$regions as $idx => $id) + if (in_array($id, $usedRegions)) + $this->regions[$id] = Lang::profiler('regions', $id); + } +} + + +abstract class BaseResponse +{ + protected const PATTERN_TEXT_LINE = '/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Cn}]/iu'; + protected const PATTERN_TEXT_BLOB = '/[\x00-\x09\x0B-\x1F\p{Cf}\p{Co}\p{Cs}\p{Cn}]/iu'; + + protected static array $sql = []; // debug: sql stats container + + protected array $expectedPOST = []; // fill with variables you that are going to be used; eg: + protected array $expectedGET = []; // 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdList'] + protected array $expectedCOOKIE = []; + + protected array $_post = []; // the filtered variable result + protected array $_get = []; + protected array $_cookie = []; + + protected int $requiredUserGroup = U_GROUP_NONE; // by default accessible to everone + protected bool $requiresLogin = false; // normal users and guests are both U_GROUP_NONE, soooo..... + protected mixed $result = null; + + public function __construct() + { + $this->initRequestData(); + + if (!User::isInGroup($this->requiredUserGroup)) + $this->onUserGroupMismatch(); + + if ($this->requiresLogin && !User::isLoggedIn()) + $this->onUserGroupMismatch(); + } + + public function process() : void + { + $fromCache = false; + + if ($this instanceof ICache) + $fromCache = $this->loadCache($this->result); + + if (!$this->result) + $this->generate(); + + $this->display(); + + if ($this instanceof ICache && !$fromCache) + $this->saveCache($this->result); + } + + private function initRequestData() : void + { + // php bug? If INPUT_X is empty, filter_input_array returns null/fails + // only really relevant for INPUT_POST + // manuall set everything null in this case + + if ($this->expectedPOST) + { + if ($_POST) + $this->_post = filter_input_array(INPUT_POST, $this->expectedPOST); + else + $this->_post = array_fill_keys(array_keys($this->expectedPOST), null); + } + + if ($this->expectedGET) + { + if ($_GET) + $this->_get = filter_input_array(INPUT_GET, $this->expectedGET); + else + $this->_get = array_fill_keys(array_keys($this->expectedGET), null); + } + + if ($this->expectedCOOKIE) + { + if ($_COOKIE) + $this->_cookie = filter_input_array(INPUT_COOKIE, $this->expectedCOOKIE); + else + $this->_cookie = array_fill_keys(array_keys($this->expectedCOOKIE), null); + } + } + + protected function forward(string $url = '') : never + { + $this->sendNoCacheHeader(); + header('Location: '.($url ?: '.'), true, 302); + exit; + } + + protected function forwardToSignIn(string $next = '') : never + { + $this->forward('?account=signin'.($next ? '&next='.$next : '')); + } + + protected function sumSQLStats() : void + { + self::$sql = array( + 'count' => \dibi::$numOfQueries, + 'time' => \dibi::$totalTime, + 'elapsed' => \dibi::$elapsedTime + ); + } + + protected function sendNoCacheHeader() + { + header('Expires: Sat, 01 Jan 2000 01:00:00 GMT'); + header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + } + + + /****************************/ + /* required Parameter tests */ + /****************************/ + + protected function assertPOST(string ...$keys) : bool + { + foreach ($keys as $k) // not sent by browser || empty text field sent || validation failed + if (!isset($this->_post[$k]) || $this->_post[$k] === null || $this->_post[$k] === '' || $this->_post[$k] === false) + return false; + + return true; + } + + protected function assertGET(string ...$keys) : bool + { + foreach ($keys as $k) + if (!isset($this->_get[$k]) || $this->_get[$k] === null || $this->_get[$k] === '' || $this->_get[$k] === false) + return false; + + return true; + } + + protected function assertCOOKIE(string ...$keys) : bool + { + foreach ($keys as $k) + if (!isset($this->_cookie[$k]) || $this->_cookie[$k] === null || $this->_cookie[$k] === '' || $this->_cookie[$k] === false) + return false; + + return true; + } + + + /*******************************/ + /* Parameter validation checks */ + /*******************************/ + + protected static function checkRememberMe(string $val) : bool + { + return $val === 'yes'; + } + + protected static function checkCheckbox(string $val) : bool + { + return $val === 'on'; + } + + protected static function checkEmptySet(string $val) : bool + { + return $val === ''; // parameter is set and expected to be empty + } + + protected static function checkIdList(string $val) : array + { + if (preg_match('/^-?\d+(,-?\d+)*$/', $val)) + return array_map('intVal', explode(',', $val)); + + return []; + } + + protected static function checkIntArray(string $val) : array + { + if (preg_match('/^-?\d+(:-?\d+)*$/', $val)) + return array_map('intVal', explode(':', $val)); + + return []; + } + + protected static function checkIdListUnsigned(string $val) : array + { + if (preg_match('/^\d+(,\d+)*$/', $val)) + return array_map('intVal', explode(',', $val)); + + return []; + } + + protected static function checkTextLine(string $val) : string + { + // remove invalid characters + $val = mb_convert_encoding(trim($val), 'utf-8', 'utf-8'); + // trim non-printable chars + return preg_replace(self::PATTERN_TEXT_LINE, '', $val); + } + + protected static function checkTextBlob(string $val) : string + { + $val = mb_convert_encoding(trim($val), 'utf-8', 'utf-8'); + // trim non-printable chars + excessive whitespaces (pattern includes \r) + $str = preg_replace(self::PATTERN_TEXT_BLOB, '', $val); + return preg_replace('/ +/', ' ', trim($str)); + } + + protected static function checkLocale(string $localeId) : ?Locale + { + if (Util::checkNumeric($localeId, NUM_CAST_INT)) + return Locale::tryFrom($localeId); + return null; + } + + + /********************/ + /* child implements */ + /********************/ + + // calc response + abstract protected function generate() : void; + + // send response + abstract protected function display() : void; + + // handling differs by medium + abstract protected function onUserGroupMismatch() : never; +} + +?> diff --git a/includes/components/response/templateresponse.class.php b/includes/components/response/templateresponse.class.php new file mode 100644 index 00000000..d2eb3c94 --- /dev/null +++ b/includes/components/response/templateresponse.class.php @@ -0,0 +1,703 @@ +type, // DBType + $this->typeId, // DBTypeId/category + User::$groups, // staff mask + '' // misc (here unused) + ); + } +} + + +trait TrListPage +{ + public string $subCat = ''; + public ?Filter $filter = null; + + public function getCacheKeyComponents() : array + { + // max. 3 catgs + // catg max 32767 - largest in use should be 11.197.26801 (Spells: Professions > Tailoring > Spellfire Tailoring) + if ($this->category) + { + $catg = 0x0; + for ($i = 0; $i < 3; $i++) + { + $catg <<= 4 * 4; + if (!isset($this->category[$i])) + continue; + + if ($this->category[$i]) + $catg |= ($this->category[$i] << 1) & 0xFFFF; + else + $catg |= 1; + } + } + + if ($get = $this->filter?->buildGETParam()) + $misc = md5($get); + + return array( + $this->type, // DBType + $catg ?? -1, // DBTypeId/category + User::$groups, // staff mask + $misc ?? '' // misc (here filter) + ); + } +} + + +trait TrGuideEditor +{ + public int $typeId = 0; + + public int $editCategory = 0; + public int $editClassId = 0; + public int $editSpecId = 0; + public int $editRev = 0; + public int $editStatus = GuideMgr::STATUS_DRAFT; + public string $editStatusColor = GuideMgr::STATUS_COLORS[GuideMgr::STATUS_DRAFT]; + public string $editTitle = ''; + public string $editName = ''; + public string $editDescription = ''; + public string $editText = ''; + public string $error = ''; + public Locale $editLocale = Locale::EN; + public bool $isDraft = false; +} + +class TemplateResponse extends BaseResponse +{ + final protected const /* int */ TAB_DATABASE = 0; + final protected const /* int */ TAB_TOOLS = 1; + final protected const /* int */ TAB_MORE = 2; + final protected const /* int */ TAB_COMMUNITY = 3; + final protected const /* int */ TAB_STAFF = 4; + final protected const /* int */ TAB_GUIDES = 6; + + private array $jsgBuffer = []; // throw any db type references in here to be processed later + private array $header = []; + private string $fullParams = ''; // effectively articleUrl + + protected string $template = ''; + protected array $breadcrumb = []; + protected ?int $activeTab = null; // [Database, Tools, More, Community, Staff, null, Guides] ?? none + protected string $pageName = ''; + protected array $category = []; + protected array $validCats = []; + protected ?string $articleUrl = null; + protected bool $filterError = false; // retroactively apply error notice to fixed filter result + + protected array $dataLoader = []; // ?data=x.y.z as javascript + protected array $scripts = array( + [SC_JS_FILE, 'js/jquery-3.7.0.min.js', SC_FLAG_NO_TIMESTAMP ], + [SC_JS_FILE, 'js/basic.js' ], + [SC_JS_FILE, 'widgets/power.js', SC_FLAG_NO_TIMESTAMP | SC_FLAG_APPEND_LOCALE], + [SC_JS_FILE, 'js/locale_%s.js', SC_FLAG_LOCALIZED ], + [SC_JS_FILE, 'js/global.js' ], + [SC_CSS_FILE, 'css/basic.css' ], + [SC_CSS_FILE, 'css/global.css' ], + [SC_CSS_FILE, 'css/aowow.css' ], + [SC_CSS_FILE, 'css/locale_%s.css', SC_FLAG_LOCALIZED ] + ); + + // debug: stats + protected static float $time = 0.0; + // protected static array $sql = []; + // protected static array $cacheStats = []; + public array $pageStats = []; // static properties carry the values, this is just for the PageTemplate to reference + + // send to template + public array $title = []; // head title components + public string $h1 = ''; // body title + public string $h1Link = ''; // + public ?string $headerLogo = null; // url to non-standard logo for events etc. + public string $search = ''; // prefilled search bar + public string $wowheadLink = 'https://wowhead.com/'; + public int $contribute = CONTRIBUTE_NONE; + public ?array $inputbox = null; + public ?string $rss = null; // link rel=alternate for rss auto-discovery + public ?string $tabsTitle = null; + public ?Markup $extraText = null; + public ?string $extraHTML = null; + public array $redButtons = []; // see template/redButtons.tpl.php + + // send to template, but it is js stuff + public array $gPageInfo = []; + public bool $gDataKey = false; // send g_DataKey to template or don't (stored in $_SESSION) + public ?Markup $article = null; + public ?Tabs $lvTabs = null; + public array $pageTemplate = []; // js PageTemplate object + public array $jsGlobals = []; // ready to be used in template + + public function __construct(string $rawParam = '') + { + $this->title[] = Cfg::get('NAME'); + self::$time = microtime(true); + + parent::__construct(); + + $this->fullParams = $this->pageName; + if ($this->category) + $this->fullParams .= '='.implode('.', $this->category); + else if (in_array(__NAMESPACE__.'\TrDetailPage', class_uses($this)) && ($id = intVal($rawParam))) + $this->fullParams .= '='.$id; + + // prep js+css includes + $parentVars = get_class_vars(__CLASS__); + if ($parentVars['scripts'] != $this->scripts) // additions set in child class + $this->scripts = array_merge($parentVars['scripts'], $this->scripts); + + if (User::isInGroup(U_GROUP_STAFF | U_GROUP_SCREENSHOT | U_GROUP_VIDEO)) + array_push($this->scripts, [SC_CSS_FILE, 'css/staff.css'], [SC_JS_FILE, 'js/staff.js']); + + // get alt header logo + if ($ahl = DB::Aowow()->selectCell('SELECT `altHeaderLogo` FROM ::home_featuredbox WHERE %i BETWEEN `startDate` AND `endDate` ORDER BY `id` DESC', time())) + $this->headerLogo = Util::defStatic($ahl); + + if ($this->pageName) + { + $this->wowheadLink = sprintf(WOWHEAD_LINK, Lang::getLocale()->domain(), $this->fullParams, ''); + $this->pageTemplate['pageName'] = $this->pageName; + } + + if (!is_null($this->activeTab)) + $this->pageTemplate['activeTab'] = $this->activeTab; + + if (!$this->isValidPage()) + $this->onInvalidCategory(); + + if (Cfg::get('MAINTENANCE') && !User::isInGroup(U_GROUP_EMPLOYEE)) + $this->generateMaintenance(); + else if (Cfg::get('MAINTENANCE') && User::isInGroup(U_GROUP_EMPLOYEE)) + Util::addNote('Maintenance mode enabled!'); + } + + // by default goto login page + protected function onUserGroupMismatch() : never + { + if (User::isLoggedIn()) + $this->generateError(); + + $this->forwardToSignIn($_SERVER['QUERY_STRING'] ?? ''); + } + + // by default show error page + protected function onInvalidCategory() : never + { + $this->generateError(); + } + + // just pass through + protected function addScript(array ...$scriptDefs) : void + { + if (!$this->result) + $this->scripts = array_merge($this->scripts, $scriptDefs); + else + foreach ($scriptDefs as $s) + $this->result->addScript(...$s); + } + + protected function addDataLoader(string ...$dataFiles) : void + { + if (!$this->result) + $this->dataLoader = array_merge($this->dataLoader, $dataFiles); + else + $this->result->addDataLoader(...$dataFiles); + } + + public static function pageStatsHook(Template\PageTemplate &$pt, array &$stats) : void + { + if (User::isInGroup(U_GROUP_EMPLOYEE)) + { + $stats['time'] = DateTime::formatTimeElapsed((microtime(true) - self::$time) * 1000); + $stats['sql'] = ['count' => parent::$sql['count'], 'time' => DateTime::formatTimeElapsed(parent::$sql['time'] * 1000)]; + $stats['cache'] = !empty(static::$cacheStats) ? [static::$cacheStats[0], (new DateTime())->formatDate(static::$cacheStats[1])] : null; + } + else + $stats = []; + } + + protected function getCategoryFromUrl(string $pageParam) : void + { + $arr = explode('.', $pageParam); + foreach ($arr as $v) + { + if (!is_numeric($v)) + break; + + $this->category[] = (int)$v; + } + } + + // functionally this should be in PageTemplate but inaccessible there + protected function fmtStaffTip(?string $text, string $tip) : string + { + if (!$text || !User::isInGroup(U_GROUP_EMPLOYEE)) + return $text ?? ''; + else + return sprintf(Util::$dfnString, $tip, $text); + } + + + /**********************/ + /* Prepare js-Globals */ + /**********************/ + + // add typeIds that should be displayed as jsGlobal on the page + public function extendGlobalIds(int $type, int ...$ids) : void + { + if (!$type || !$ids) + return; + + if (!isset($this->jsgBuffer[$type])) + $this->jsgBuffer[$type] = []; + + foreach ($ids as $id) + $this->jsgBuffer[$type][] = $id; + } + + // add jsGlobals or typeIds (can be mixed in one array: TYPE => [mixeddata]) to display on the page + public function extendGlobalData(array $data, ?array $extra = null) : void + { + foreach ($data as $type => $globals) + { + if (!is_array($globals) || !$globals) + continue; + + $this->initJSGlobal($type); + + // can be id => data + // or idx => id + // and may be mixed + foreach ($globals as $k => $v) + { + if (is_array($v)) + { + // localize name fields .. except for icons .. icons are special + if ($type != Type::ICON) + { + foreach (['name', 'namefemale'] as $n) + { + if (!isset($v[$n])) + continue; + + $v[$n . '_'.Lang::getLocale()->json()] = $v[$n]; + unset($v[$n]); + } + } + + $this->jsGlobals[$type][1][$k] = $v; + } + else if (is_numeric($v)) + $this->extendGlobalIds($type, $v); + } + } + + if ($extra) + { + $namedExtra = []; + foreach ($extra as $typeId => $data) + foreach ($data as $k => $v) + $namedExtra[$typeId][$k.'_'.Lang::getLocale()->json()] = $v; + + $this->jsGlobals[$type][2] = $namedExtra; + } + } + + // init store for type + private function initJSGlobal(int $type) : void + { + $jsg = &$this->jsGlobals; // shortcut + + if (isset($jsg[$type])) + return; + + if ($tpl = Type::getJSGlobalTemplate($type)) + $jsg[$type] = $tpl; + } + + // lookup jsGlobals from collected typeIds + private function applyGlobals() : void + { + foreach ($this->jsgBuffer as $type => $ids) + { + foreach ($ids as $k => $id) // filter already generated data, maybe we can save a lookup or two + if (isset($this->jsGlobals[$type][1][$id])) + unset($ids[$k]); + + if (!$ids) + continue; + + $this->initJSGlobal($type); + + $obj = Type::newList($type, [['id', array_unique($ids, SORT_NUMERIC)]]); + if (!$obj) + continue; + + $this->extendGlobalData($obj->getJSGlobals(GLOBALINFO_SELF)); + + // delete processed ids + $this->jsgBuffer[$type] = []; + } + } + + + /************************/ + /* Generic Page Content */ + /************************/ + + // get announcements and notes for user + private function addAnnouncements(bool $onlyGenerics = false) : void + { + $announcements = []; + + // display occured notices + $notes = $_SESSION['notes'] ?? []; + unset($_SESSION['notes']); + + $notes[] = [...Util::getNotes(), 'One or more issues occured during page generation']; + + foreach ($notes as $i => [$messages, $logLevel, $head]) + { + if (!$messages) + continue; + + array_unshift($messages, $head); + + $colors = array( // [border, text] + LOG_LEVEL_ERROR => ['C50F1F', 'E51223'], + LOG_LEVEL_WARN => ['C19C00', 'E5B700'], + LOG_LEVEL_INFO => ['3A96DD', '42ADFF'] + ); + + $text = new LocString(['name_loc' . Lang::getLocale()->value => '[span]'.implode("[br]", $messages).'[/span]'], callback: Util::defStatic(...)); + $style = 'color: #'.($colors[$logLevel][1] ?? 'fff').'; font-weight: bold; font-size: 14px; padding-left: 40px; background-image: url('.Cfg::get('STATIC_URL').'/images/announcements/warn-small.png); background-size: 15px 15px; background-position: 12px center; border: dashed 2px #'.($colors[$logLevel][0] ?? 'fff').';'; + + $announcements[] = new Announcement(-$i, 'internal error', $text, style: $style); + } + + // fetch announcements + $fromDB = DB::Aowow()->selectAssoc( + 'SELECT `id`, `mode`, `status`, `name`, `style`, `text_loc0`, `text_loc2`, `text_loc3`, `text_loc4`, `text_loc6`, `text_loc8` + FROM ::announcements + WHERE (`page` = "*" %if', !$onlyGenerics && $this->pageName, 'OR `page` = %s', $this->pageName, '%end) AND + `status` IN (%i) AND (`groupMask` = 0 OR `groupMask` & %i)', + User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU) ? [Announcement::STATUS_ENABLED, Announcement::STATUS_DISABLED] : [Announcement::STATUS_ENABLED], + User::$groups + ); + + foreach ($fromDB as $a) + if (($ann = new Announcement($a['id'], $a['name'], new LocString($a, 'text', Util::defStatic(...)), $a['mode'], $a['status'], Util::defStatic($a['style'])))->status != Announcement::STATUS_DELETED) + $announcements[] = $ann; + + $this->result->announcements = $announcements; + } + + // get article & static infobox (run before processing jsGlobals) + private function addArticle() : void + { + if ($this->article) + return; + + $article = []; + if (isset($this->guideRevision)) + $article = DB::Aowow()->selectRow('SELECT `article`, `locale`, `editAccess` FROM ::articles WHERE `type` = %i AND `typeId` = %i AND `rev` = %i', + Type::GUIDE, $this->typeId, $this->guideRevision); + if (!$article && !empty($this->gPageInfo['articleUrl'])) + $article = DB::Aowow()->selectRow('SELECT `article`, `locale`, `editAccess` FROM ::articles WHERE `url` = %s AND `locale` IN %in ORDER BY `locale` DESC, `rev` DESC LIMIT 1', + $this->gPageInfo['articleUrl'], [Lang::getLocale()->value, Locale::EN->value]); + if (!$article && !empty($this->type) && isset($this->typeId)) + $article = DB::Aowow()->selectRow('SELECT `article`, `locale`, `editAccess` FROM ::articles WHERE `type` = %i AND `typeId` = %i AND `locale` IN (%i) ORDER BY `locale` DESC, `rev` DESC LIMIT 1', + $this->type, $this->typeId, [Lang::getLocale()->value, Locale::EN->value]); + + if (!$article) + return; + + $text = Util::defStatic($article['article']); + $opts = []; + + // convert U_GROUP_* to MARKUP.CLASS_* (as seen in js-object Markup) + if ($article['editAccess'] & (U_GROUP_ADMIN | U_GROUP_VIP | U_GROUP_DEV)) + $opts['allow'] = Markup::CLASS_ADMIN; + else if ($article['editAccess'] & U_GROUP_STAFF) + $opts['allow'] = Markup::CLASS_STAFF; + else if ($article['editAccess'] & U_GROUP_PREMIUM) + $opts['allow'] = Markup::CLASS_PREMIUM; + else if ($article['editAccess'] & U_GROUP_PENDING) + $opts['allow'] = Markup::CLASS_PENDING; + else + $opts['allow'] = Markup::CLASS_USER; + + if (!empty($this->type) && isset($this->typeId)) + $opts['dbpage'] = 1; + + if ($article['locale'] != Lang::getLocale()->value) + $opts['prepend'] = '
'.Lang::main('langOnly', [Lang::lang($article['locale'])]).'
'; + + $this->article = new Markup($text, $opts); + + if ($jsg = $this->article->getJsGlobals()) + $this->extendGlobalData($jsg); + + $this->gPageInfo['editAccess'] = $article['editAccess']; + + if (method_exists($this, 'postArticle')) // e.g. update variables in article + $this->postArticle($this->article['text']); + } + + private function addCommunityContent() : void + { + $community = array( + 'coError' => $_SESSION['error']['co'] ?? null, + 'ssError' => $_SESSION['error']['ss'] ?? null, + 'viError' => $_SESSION['error']['vi'] ?? null + ); + + // we cannot blanket NUMERIC_CHECK the data as usernames of deleted users are their id which does not support String.lower() + + if ($this->contribute & CONTRIBUTE_CO) + $community['co'] = Util::toJSON(CommunityContent::getComments($this->type, $this->typeId), JSON_UNESCAPED_UNICODE); + + if ($this->contribute & CONTRIBUTE_SS) + $community['ss'] = Util::toJSON(CommunityContent::getScreenshots($this->type, $this->typeId), JSON_UNESCAPED_UNICODE); + + if ($this->contribute & CONTRIBUTE_VI) + $community['vi'] = Util::toJSON(CommunityContent::getVideos($this->type, $this->typeId), JSON_UNESCAPED_UNICODE); + + unset($_SESSION['error']); + + // as comments are not cached, those globals cant be either + $this->extendGlobalData(CommunityContent::getJSGlobals()); + + $this->result->community = $community; + $this->applyGlobals(); + } + + + /**************/ + /* Generators */ + /**************/ + + protected function generate() : void + { + $this->result = new Template\PageTemplate($this->template, $this); + + foreach ($this->scripts as $s) + $this->result->addScript(...$s); + + $this->result->addDataLoader(...$this->dataLoader); + + // static::class so pageStatsHook defined here, can access cacheStats defined in the implementation + $this->result->registerDisplayHook('pageStats', [static::class, 'pageStatsHook']); + + // only adds edit links to the staff menu: precursor to guides? + if (!($this instanceof GuideBaseResponse)) + $this->gPageInfo += array( + 'articleUrl' => $this->articleUrl ?? $this->fullParams, // is actually be the url-param + 'editAccess' => (U_GROUP_ADMIN | U_GROUP_EDITOR | U_GROUP_BUREAU) + ); + + if ($this->breadcrumb) + $this->pageTemplate['breadcrumb'] = $this->breadcrumb; + + if (isset($this->filter)) + $this->pageTemplate['filter'] = $this->filter->query ? 1 : 0; + + $this->addArticle(); + + $this->applyGlobals(); + } + + // we admit this page exists and an error occured on it + public function generateError(?string $altPageName = null) : never + { + $this->result = new Template\PageTemplate('text-page-generic', $this); + + // only use own script defs + foreach (get_class_vars(self::class)['scripts'] as $s) + $this->result->addScript(...$s); + + if (User::isInGroup(U_GROUP_STAFF | U_GROUP_SCREENSHOT | U_GROUP_VIDEO)) + { + $this->result->addScript(SC_CSS_FILE, 'css/staff.css'); + $this->result->addScript(SC_JS_FILE, 'js/staff.js'); + } + + $this->result->registerDisplayHook('pageStats', [self::class, 'pageStatsHook']); + + $this->title[] = Lang::main('errPageTitle'); + $this->h1 = Lang::main('errPageTitle'); + $this->articleUrl = 'page-not-found'; + $this->gPageInfo += array( + 'articleUrl' => 'page-not-found', + 'editAccess' => (U_GROUP_ADMIN | U_GROUP_EDITOR | U_GROUP_BUREAU) + ); + + $this->pageTemplate['pageName'] ??= $altPageName ?? 'page-not-found'; + + $this->addArticle(); + + $this->sumSQLStats(); + + $this->header[] = ['HTTP/1.0 404 Not Found', true, 404]; + + $this->display(true); + exit; + } + + // we do not have this page + public function generateNotFound(string $title = '', string $msg = '') : never + { + $this->result = new Template\PageTemplate('text-page-generic', $this); + + // only use own script defs + foreach (get_class_vars(self::class)['scripts'] as $s) + $this->result->addScript(...$s); + + if (User::isInGroup(U_GROUP_STAFF | U_GROUP_SCREENSHOT | U_GROUP_VIDEO)) + { + $this->result->addScript(SC_CSS_FILE, 'css/staff.css'); + $this->result->addScript(SC_JS_FILE, 'js/staff.js'); + } + + $this->result->registerDisplayHook('pageStats', [self::class, 'pageStatsHook']); + + array_unshift($this->title, Lang::main('nfPageTitle')); + + $this->inputbox = ['inputbox-status', array( + 'head' => isset($this->typeId) ? Util::ucWords($title).' #'.$this->typeId : $title, + 'error' => !$msg && isset($this->typeId) ? Lang::main('pageNotFound', [$title]) : $msg + )]; + + $this->contribute = CONTRIBUTE_NONE; + + if (!empty($this->breadcrumb)) + $this->pageTemplate['breadcrumb'] = $this->breadcrumb; + + $this->sumSQLStats(); + + $this->header[] = ['HTTP/1.0 404 Not Found', true, 404]; + + $this->display(true); + exit; + } + + // display brb gnomes + public function generateMaintenance() : never + { + $this->result = new Template\PageTemplate('maintenance', $this); + + $this->header[] = ['HTTP/1.0 503 Service Temporarily Unavailable', true, 503]; + $this->header[] = ['Retry-After: '.(3 * HOUR)]; + + $this->display(true); + exit; + } + + protected function display(bool $withError = false) : void + { + $this->title = Util::htmlEscape($this->title); + $this->search = Util::htmlEscape($this->search); + // can't escape >h1 here, because CharTitles legitimately add HTML + + $this->addAnnouncements($withError); + if (!$withError) + $this->addCommunityContent(); + + // force jsGlobals from Announcements/CommunityContent into PageTemplate + // as this may be loaded from cache, it will be unlinked from its response + if ($ptJSG = $this->result->jsGlobals) + { + foreach ($this->jsGlobals as $type => [, $data, ]) + { + if (!isset($ptJSG[$type]) || $type == Type::USER) + $ptJSG[$type] = $this->jsGlobals[$type]; + else + { + $masterJSG = [$type => &$ptJSG[$type][1]]; + Util::mergeJsGlobals($masterJSG, [$type => $data]); + } + + unset($masterJSG); + } + + $this->result->jsGlobals = $ptJSG; + } + else if ($this->jsGlobals) + $this->result->jsGlobals = $this->jsGlobals; + + if ($this instanceof ICache) + $this->applyOnCacheLoaded($this->result); + + if ($this->result && $this->filterError) + $this->result->setListviewError(); + + $this->sumSQLStats(); + + // Heisenbug: IE11 and FF32 will sometimes (under unknown circumstances) cache 302 redirects and stop + // re-requesting them from the server but load them from local cache, thus breaking menu features. + $this->sendNoCacheHeader(); + foreach ($this->header as $h) + header(...$h); + + $this->result?->render(); + } + + + /**********/ + /* Checks */ + /**********/ + + // has a valid combination of categories + private function isValidPage() : bool + { + if (!$this->category) + return true; + + $c = $this->category; // shorthand + + switch (count($c)) + { + case 1: // null is valid || value in a 1-dim-array || (key for a n-dim-array && ( has more subcats || no further subCats )) + $filtered = array_filter($this->validCats, fn ($x) => is_int($x)); + return $c[0] === null || in_array($c[0], $filtered) || (!empty($this->validCats[$c[0]]) && (is_array($this->validCats[$c[0]]) || $this->validCats[$c[0]] === true)); + case 2: // first param has to be a key. otherwise invalid + if (!isset($this->validCats[$c[0]])) + return false; + + // check if the sub-array is n-imensional + if (is_array($this->validCats[$c[0]]) && count($this->validCats[$c[0]]) == count($this->validCats[$c[0]], COUNT_RECURSIVE)) + return in_array($c[1], $this->validCats[$c[0]]); // second param is value in second level array + else + return isset($this->validCats[$c[0]][$c[1]]); // check if params is key of another array + case 3: // 3 params MUST point to a specific value + return isset($this->validCats[$c[0]][$c[1]]) && in_array($c[2], $this->validCats[$c[0]][$c[1]]); + } + + return false; + } + +} + +?> diff --git a/includes/components/response/textresponse.class.php b/includes/components/response/textresponse.class.php new file mode 100644 index 00000000..1541d239 --- /dev/null +++ b/includes/components/response/textresponse.class.php @@ -0,0 +1,170 @@ +type, // DBType + $this->typeId, // DBTypeId/category + User::$groups, // staff mask + '' // misc (here tooltip) + ); + + if ($this->enhancedTT) + $key[3] = md5(serialize($this->enhancedTT)); + + return $key; + } +} + + +trait TrRss +{ + private array $feedData = []; + + protected function generateRSS(string $title, string $link) : string + { + $root = new SimpleXML(''); + $root->addAttribute('version', '2.0'); + + $channel = $root->addChild('channel'); + + $channel->addChild('title', Cfg::get('NAME_SHORT').' - '.$title); + $channel->addChild('link', Cfg::get('HOST_URL').'/?'.$link); + $channel->addChild('description', Cfg::get('NAME')); + $channel->addChild('language', implode('-', str_split(Lang::getLocale()->json(), 2))); + $channel->addChild('ttl', Cfg::get('TTL_RSS')); + $channel->addChild('lastBuildDate', date(DATE_RSS)); + + foreach ($this->feedData as $row) + { + $item = $channel->addChild('item'); + + foreach ($row as $key => [$isCData, $attrib, $text]) + { + if ($isCData && $text) + $child = $item->addChild($key)->addCData($text); + else + $child = $item->addChild($key, $text); + + foreach ($attrib as $k => $v) + $child->addAttribute($k, $v); + } + } + + // pretty print for debug + if (Cfg::get('DEBUG') >= LOG_LEVEL_INFO) + { + $dom = new \DOMDocument('1.0'); + $dom->formatOutput = true; + $dom->loadXML($root->asXML()); + return $dom->saveXML(); + } + + return $root->asXML(); + } +} + +trait TrCommunityHelper +{ + private function handleCaption(?string $caption) : string + { + if (!$caption) + return ''; + + // trim excessive whitespaces + $caption = trim(preg_replace('/\s{2,}/', ' ', $caption)); + + // shorten to fit db + return substr($caption, 0, 200); + } +} + +class TextResponse extends BaseResponse +{ + protected string $contentType = MIME_TYPE_JAVASCRIPT; + protected ?string $redirectTo = null; + protected array $params = []; + + /// generation stats + protected static float $time = 0.0; + + public function __construct(string $rawParam = '') + { + self::$time = microtime(true); + $this->params = explode('.', $rawParam); + // todo - validate params? + + parent::__construct(); + + if (Cfg::get('MAINTENANCE') && !User::isInGroup(U_GROUP_EMPLOYEE)) + $this->generate404(); + } + + // by default ajax has nothing to say + protected function onUserGroupMismatch() : never + { + trigger_error('TextResponse::onUserGroupMismatch - loggedIn: '.($this->requiresLogin ? 'yes' : 'no').'; expected: '.Util::asHex($this->requiredUserGroup).'; is: '.Util::asHex(User::$groups), E_USER_WARNING); + + $this->generate403(); + } + + public function generate404(?string $out = null) : never + { + header('HTTP/1.0 404 Not Found', true, 404); + header($this->contentType); + exit($out); + } + + public function generate403(?string $out = null) : never + { + header('HTTP/1.0 403 Forbidden', true, 403); + header($this->contentType); + exit($out); + } + + protected function display() : void + { + if ($this->redirectTo) + $this->forward($this->redirectTo); + + $out = ($this instanceof ICache) ? $this->applyOnCacheLoaded($this->result) : $this->result; + + $this->sendNoCacheHeader(); + header($this->contentType); + + // NOTE - this may fuck up some javascripts that say they expect ajax, but use the whole string anyway + // so it's limited to tooltips + if (Cfg::get('DEBUG') && User::isInGroup(U_GROUP_STAFF) && $this->result instanceof Tooltip) + { + $this->sumSQLStats(); + + echo "/*\n"; + echo " * generated in ".DateTime::formatTimeElapsedFloat((microtime(true) - self::$time) * 1000)."\n"; + echo " * " . parent::$sql['count'] . " SQL queries in " . DateTime::formatTimeElapsedFloat(parent::$sql['time'] * 1000) . "\n"; + if ($this instanceof ICache && static::$cacheStats) + { + [$mode, $set, $lifetime] = static::$cacheStats; + echo " * stored in " . ($mode == CACHE_MODE_MEMCACHED ? 'Memcached' : 'filecache') . ":\n"; + echo " * + ".date('c', $set) . ' - ' . DateTime::formatTimeElapsedFloat((time() - $set) * 1000) . " ago\n"; + echo " * - ".date('c', $set + $lifetime) . ' - in '.DateTime::formatTimeElapsedFloat(($set + $lifetime - time()) * 1000) . "\n"; + } + echo " */\n\n"; + } + + echo $out; + } + + protected function generate() : void {} +} + +?> diff --git a/includes/components/screenshotmgr.class.php b/includes/components/screenshotmgr.class.php new file mode 100644 index 00000000..be782877 --- /dev/null +++ b/includes/components/screenshotmgr.class.php @@ -0,0 +1,216 @@ + self::MAX_W || $is[1] > self::MAX_H) + self::$error = Lang::screenshot('error', 'selectSS'); + } + else + self::$error = Lang::screenshot('error', 'selectSS'); + + if (!self::$error) + return true; + + self::$fileName = ''; + return false; + } + + public static function createThumbnail(string $fileName) : bool + { + if (!self::$img) + return false; + + return static::resizeAndWrite(self::DIMS_THUMB[0], self::DIMS_THUMB[1], self::PATH_THUMB, $fileName); + } + + public static function createResized(string $fileName) : bool + { + if (!self::$img) + return false; + + return self::resizeAndWrite(self::DIMS_RESIZED[0], self::DIMS_RESIZED[1], self::PATH_RESIZED, $fileName); + } + + + /*************/ + /* Admin Mgr */ + /*************/ + + public static function getScreenshots(int $type = 0, int $typeId = 0, $userId = 0, ?int &$nFound = 0) : array + { + if ($userId) + $where = [['s.`userIdOwner` = %i', $userId]]; + else + $where = [['s.`type` = %i', $type], ['s.`typeId` = %i', $typeId]]; + + $screenshots = DB::Aowow()->selectAssoc( + 'SELECT s.`id`, a.`username` AS "user", s.`date`, s.`width`, s.`height`, s.`type`, s.`typeId`, s.`caption`, s.`status`, s.`status` AS "flags" + FROM ::screenshots s + LEFT JOIN ::account a ON s.`userIdOwner` = a.`id` + WHERE %and + %lmt', + $where, $userId || $type ? PHP_INT_MAX : 100 + ); + + $num = []; + foreach ($screenshots as $s) + $num[$s['type']][$s['typeId']] = ($num[$s['type']][$s['typeId']] ?? 0) + 1; + + $nFound = 0; + + // format data to meet requirements of the js + foreach ($screenshots as $i => &$s) + { + $nFound++; + + $s['date'] = date(Util::$dateFormatInternal, $s['date']); + + $s['name'] = "Screenshot #".$s['id']; // what should we REALLY name it? + + if ($i > 0) + $s['prev'] = $i - 1; + + if (($i + 1) < count($screenshots)) + $s['next'] = $i + 1; + + // order gives priority for 'status' + if (!($s['flags'] & CC_FLAG_APPROVED)) + { + $s['pending'] = 1; + $s['status'] = self::STATUS_PENDING; + } + else + $s['status'] = self::STATUS_APPROVED; + + if ($s['flags'] & CC_FLAG_STICKY) + { + $s['sticky'] = 1; + $s['status'] = self::STATUS_STICKY; + } + + if ($s['flags'] & CC_FLAG_DELETED) + { + $s['deleted'] = 1; + $s['status'] = self::STATUS_DELETED; + } + + // something todo with massSelect .. am i doing this right? + if ($num[$s['type']][$s['typeId']] == 1) + $s['unique'] = 1; + + if (!$s['user']) + unset($s['user']); + } + + return $screenshots; + } + + public static function getPages(?bool $all, ?int &$nFound) : array + { + // i GUESS .. ss_getALL ? everything : pending + $nFound = 0; + if ($pages = DB::Aowow()->selectAssoc('SELECT `type`, `typeId`, COUNT(1) AS "count", MIN(`date`) AS "date" FROM ::screenshots %if', !$all, 'WHERE (`status` & %i) = 0', CC_FLAG_APPROVED | CC_FLAG_DELETED, '%end GROUP BY `type`, `typeId`')) + { + // limit to one actually existing type each + foreach (array_unique(array_column($pages, 'type')) as $t) + { + $ids = []; + foreach ($pages as $row) + if ($row['type'] == $t) + $ids[] = $row['typeId']; + + if (!$ids) + continue; + + $obj = Type::newList($t, [['id', $ids]]); + if (!$obj || $obj->error) + continue; + + foreach ($pages as &$p) + if ($p['type'] == $t) + if ($obj->getEntry($p['typeId'])) + $p['name'] = $obj->getField('name', true); + } + + foreach ($pages as &$p) + { + if (empty($p['name'])) + { + trigger_error('ScreenshotMgr::getPages - screenshot linked to nonexistent type/typeId combination: '.$p['type'].'/'.$p['typeId'], E_USER_NOTICE); + unset($p); + } + else + { + $nFound += $p['count']; + $p['date'] = date(Util::$dateFormatInternal, $p['date']); + } + } + } + + return $pages; + } +} + +?> diff --git a/includes/components/search.class.php b/includes/components/search.class.php new file mode 100644 index 00000000..5587071c --- /dev/null +++ b/includes/components/search.class.php @@ -0,0 +1,1628 @@ + '_searchCharClass', + self::MOD_RACE => '_searchCharRace', + self::MOD_TITLE => '_searchTitle', + self::MOD_WORLDEVENT => '_searchWorldEvent', + self::MOD_CURRENCY => '_searchCurrency', + self::MOD_ITEMSET => '_searchItemset', + self::MOD_ITEM => '_searchItem', + self::MOD_ABILITY => '_searchAbility', + self::MOD_TALENT => '_searchTalent', + self::MOD_GLYPH => '_searchGlyph', + self::MOD_PROFICIENCY => '_searchProficiency', + self::MOD_PROFESSION => '_searchProfession', + self::MOD_COMPANION => '_searchCompanion', + self::MOD_MOUNT => '_searchMount', + self::MOD_CREATURE => '_searchCreature', + self::MOD_QUEST => '_searchQuest', + self::MOD_ACHIEVEMENT => '_searchAchievement', + self::MOD_STATISTIC => '_searchStatistic', + self::MOD_ZONE => '_searchZone', + self::MOD_OBJECT => '_searchObject', + self::MOD_FACTION => '_searchFaction', + self::MOD_SKILL => '_searchSkill', + self::MOD_PET => '_searchPet', + self::MOD_CREATURE_ABILITY => '_searchCreatureAbility', + self::MOD_SPELL => '_searchSpell', + self::MOD_EMOTE => '_searchEmote', + self::MOD_ENCHANTMENT => '_searchEnchantment', + self::MOD_SOUND => '_searchSound' + ); + + private array $jsgStore = []; + private array $resultStore = []; + private array $included = []; + private array $excluded = []; + private array $fulltext = []; + private array $cndBase = [DB::AND]; + private bool $idSearch = false; + + public array $invalid = []; + + public function __construct(private string $query, private int $moduleMask = -1, private array $extraCnd = [], private array $extraOpts = [], private int $maxResults = self::DEFAULT_MAX_RESULTS) + { + $this->tokenizeQuery(); + + $this->cndBase[] = $this->maxResults; + + // Exclude internal wow stuff + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + $this->cndBase[] = [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0]; + } + + private function tokenizeQuery() : void + { + if (!$this->query) + return; + + if (Util::checkNumeric($this->query, NUM_CAST_INT)) + { + $this->idSearch = true; + $this->included[] = $this->query; + return; + } + + $allowShort = Lang::getLocale()->isLogographic(); + + foreach (explode(' ', $this->query) as $raw) + { + if ([$like, $fulltext, $ex] = Filter::transformToken($raw, $allowShort)) + { + $this->{$ex ? 'excluded' : 'included'}[] = $like; + + // note: a fulltext search purely from exclude tokens will return no result + foreach ($fulltext as $ft) + $this->fulltext[] = ($ex ? '-' : '+') . '(' . $ft . '* ' . Util::strrev($ft) . '*)'; + } + else + $this->invalid[] = $raw; + } + } + + private function createLikeLookup(array $fields = []) : array + { + if ($this->idSearch && $this->included) + return ['id', $this->included]; + + if (!$this->included && !$this->excluded) + return []; + + // default to name-field + if (!$fields) + $fields[] = 'name_loc'.Lang::getLocale()->value; + + $qry = []; + foreach ($fields as $f) + { + $sub = []; + $sub = array_merge($sub, array_map(fn($x) => [$f, $x, 'LIKE'], $this->included)); + $sub = array_merge($sub, array_map(fn($x) => [$f, $x, 'NOT LIKE'], $this->excluded)); + + // single cnd? + if (count($sub) > 1) + array_unshift($sub, DB::AND); + else + $sub = $sub[0]; + + $qry[] = $sub; + } + + // single cnd? + if (count($qry) > 1) + array_unshift($qry, DB::OR); + else + $qry = $qry[0]; + + return $qry; + } + + private function createMatchLookup() : array + { + if ($this->idSearch && $this->included) + return ['id', $this->included]; + + if (Lang::getLocale()->isLogographic() && !Cfg::get('LOGOGRAPHIC_FT_SEARCH')) + return $this->createLikeLookup(); + + if ($this->fulltext) + return ['nml.nName', $this->fulltext, 'MATCH']; + else if ($strBak = trim($this->query)) + if (mb_strlen($strBak) > 2 || Lang::getLocale()->isLogographic()) + return ['name_loc'.Lang::getLocale()->value, $strBak]; + + return []; + } + + public function canPerform() : bool + { + // has valid search terms or weights and selected modules + return (($this->included || $this->extraOpts)) && $this->moduleMask; + } + + public function perform() : \Generator + { + $shared = []; + foreach (self::MODULES as $idx => $ref) + { + if (!($this->moduleMask & (1 << $idx))) + continue; + + $this->resultStore[$idx] ??= $this->$ref($shared); + + if (!$this->resultStore[$idx]) + continue; + + yield $idx => $this->resultStore[$idx]; + } + } + + public function getJSGlobals() : array + { + return $this->jsgStore; + } + + + /******************/ + /* Search Modules */ + /******************/ + + private function _searchCharClass() : ?array // 0 Classes: $moduleMask & 0x00000001 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $classes = new CharClassList($cnd, ['calcTotal' => true]); + + $data = $classes->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = ['data' => $data]; + + if ($classes->getMatches() > $this->maxResults) + { + // $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_', $classes->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, CharClassList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::CHR_CLASS, $classes->getMatches(), [], [], 'Class']; + + foreach ($classes->iterate() as $id => $__) + { + $result[$id] = $classes->getField('name', true); + $osInfo[2][$id] = 'class_'.strToLower($classes->getField('fileString')); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchCharRace() : ?array // 1 Races: $moduleMask & 0x00000002 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $races = new CharRaceList($cnd, ['calcTotal' => true]); + + $data = $races->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = ['data' => $data]; + + if ($races->getMatches() > $this->maxResults) + { + // $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_', $races->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, CharRaceList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::CHR_RACE, $races->getMatches(), [], [], 'Race']; + + foreach ($races->iterate() as $id => $__) + { + $result[$id] = $races->getField('name', true); + $osInfo[2][$id] = 'race_'.strToLower($races->getField('fileString')).'_male'; + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchTitle() : ?array // 2 Titles: $moduleMask & 0x00000004 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup(['male_loc'.Lang::getLocale()->value, 'female_loc'.Lang::getLocale()->value])]); + $titles = new TitleList($cnd, ['calcTotal' => true]); + + $data = $titles->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = ['data' => $data]; + + if ($titles->getMatches() > $this->maxResults) + { + // $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_', $titles->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, TitleList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::TITLE, $titles->getMatches(), [], [], 'Title']; + + foreach ($titles->iterate() as $id => $__) + { + $result[$id] = $titles->getField('male', true); + $osInfo[2][$id] = $titles->getField('side'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchWorldEvent() : ?array // 3 World Events: $moduleMask & 0x00000008 + { + $cnd = array_merge($this->cndBase, array( + array( + DB::OR, + $this->createLikeLookup(['h.name_loc'.Lang::getLocale()->value]), + [DB::AND, $this->createLikeLookup(['e.description']), ['e.holidayId', 0]] + ) + )); + $wEvents = new WorldEventList($cnd, ['calcTotal' => true]); + + $data = $wEvents->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $wEvents->getJSGlobals()); + + // as allways: dates are updated in postCache-step + $lvData = ['data' => $data]; + + if ($wEvents->getMatches() > $this->maxResults) + { + // $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_', $wEvents->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, WorldEventList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::WORLDEVENT, $wEvents->getMatches(), [], [], 'World Event']; + + foreach ($wEvents->iterate() as $id => $__) + $result[$id] = $wEvents->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchCurrency() : ?array // 4 Currencies $moduleMask & 0x0000010 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $money = new CurrencyList($cnd, ['calcTotal' => true]); + + $data = $money->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = ['data' => $data]; + + if ($money->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_currenciesfound', $money->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, CurrencyList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::CURRENCY, $money->getMatches(), [], [], 'Currency']; + + foreach ($money->iterate() as $id => $__) + { + $result[$id] = $money->getField('name', true); + $osInfo[2][$id] = $money->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchItemset(array &$shared) : ?array// 5 Itemsets $moduleMask & 0x0000020 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $sets = new ItemsetList($cnd, ['calcTotal' => true]); + + $data = $sets->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $sets->getJSGlobals(GLOBALINFO_SELF)); + + $lvData = ['data' => $data]; + + if ($sets->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_itemsetsfound', $sets->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?itemsets&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?itemsets&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, ItemsetList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::ITEMSET, $sets->getMatches(), [], [], 'Item Set']; + + foreach ($sets->iterate() as $id => $__) + { + $result[$id] = $sets->getField('name', true); + $osInfo[3][$id] = $sets->getField('quality'); + } + + return [$result, ...$osInfo]; + } + + if ($this->moduleMask & self::TYPE_JSON) + { + $shared['pcsToSet'] = $sets->pieceToSet; + + foreach ($data as &$d) + unset($d['quality'], $d['heroic']); + + return array_values($data); + } + + return null; + } + + private function _searchItem(array &$shared) : ?array // 6 Items $moduleMask & 0x0000040 + { + $miscData = ['calcTotal' => true]; + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + if ($this->moduleMask & self::TYPE_JSON) + { + if (!empty($shared['pcsToSet'])) + { + $cnd = [['i.id', array_keys($shared['pcsToSet'])]]; + $miscData = ['pcsToSet' => $shared['pcsToSet']]; + } + else + { + $cnd = $this->cndBase; + $cnd[] = ['i.class', [ITEM_CLASS_WEAPON, ITEM_CLASS_GEM, ITEM_CLASS_ARMOR]]; + $cnd[] = $lookup; + + if ($this->extraOpts) + $miscData['extraOpts'] = $this->extraOpts; + if ($this->extraCnd) + $cnd = array_merge($cnd, $this->extraCnd); + } + } + else + $cnd = array_merge($this->cndBase, [$lookup]); + + $items = new ItemList($cnd, $miscData); + + $data = $items->getListviewData($this->moduleMask & self::TYPE_JSON ? (ITEMINFO_SUBITEMS | ITEMINFO_JSON) : 0); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $items->getJSGlobals()); + + $lvData = ['data' => $data]; + + if ($items->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_itemsfound', $items->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?items&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?items&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, ItemList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::ITEM, $items->getMatches(), [], [], 'Item']; + + foreach ($items->iterate() as $id => $__) + { + $result[$id] = $items->getField('name', true); + $osInfo[2][$id] = $items->getField('iconString'); + $osInfo[3][$id] = $items->getField('quality'); + } + + return [$result, ...$osInfo]; + } + + if ($this->moduleMask & self::TYPE_JSON) + { + foreach ($data as &$d) + if (!empty($d['subitems'])) + foreach ($d['subitems'] as &$si) + $si['enchantment'] = implode(', ', $si['enchantment']); + + return array_values($data); + } + + return null; + } + + private function _searchAbility() : ?array // 7 Abilities (Player + Pet) $moduleMask & 0x0000080 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, array( // hmm, inclued classMounts..? + ['s.typeCat', [7, -2, -3, -4]], + [['s.cuFlags', (SPELL_CU_TRIGGERED | SPELL_CU_TALENT), '&'], 0], + [['s.attributes0', 0x80, '&'], 0], + $lookup + )); + $abilities = new SpellList($cnd, ['calcTotal' => true]); + + $data = $abilities->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $abilities->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $vis = ['level', 'schools']; + if ($abilities->hasSetFields('reagent1', 'reagent2', 'reagent3', 'reagent4', 'reagent5', 'reagent6', 'reagent7', 'reagent8')) + $vis[] = 'reagents'; + + if ($abilities->hasSetFields('reqclass')) + $vis[] = 'classes'; // i'd love to set 'singleclass', but do i want to walk through all abilities to see if each mask contains at most 1 class? + + $lvData = array( + 'data' => $data, + 'id' => 'abilities', + 'name' => '$LANG.tab_abilities', + 'visibleCols' => $vis + ); + + if ($abilities->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_abilitiesfound', $abilities->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=7&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=7&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $abilities->getMatches(), [], [], 'Ability']; + + foreach ($abilities->iterate() as $id => $__) + { + $result[$id] = $abilities->getField('name', true); + $osInfo[2][$id] = $abilities->getField('iconString'); + $osInfo[3][$id] = $abilities->ranks[$id]; + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchTalent() : ?array // 8 Talents (Player + Pet) $moduleMask & 0x0000100 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', [-7, -2]], $lookup]); + $talents = new SpellList($cnd, ['calcTotal' => true]); + + $data = $talents->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $talents->getJSGlobals()); + + $vis = ['level', 'singleclass', 'schools']; + if ($talents->hasSetFields('reagent1', 'reagent2', 'reagent3', 'reagent4', 'reagent5', 'reagent6', 'reagent7', 'reagent8')) + $vis[] = 'reagents'; + + $lvData = array( + 'data' => $data, + 'id' => 'talents', + 'name' => '$LANG.tab_talents', + 'visibleCols' => $vis + ); + + if ($talents->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_talentsfound', $talents->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=-2&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=-2&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $talents->getMatches(), [], [], 'Talent']; + + foreach ($talents->iterate() as $id => $__) + { + $result[$id] = $talents->getField('name', true); + $osInfo[2][$id] = $talents->getField('iconString'); + $osInfo[3][$id] = $talents->ranks[$id]; + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchGlyph() : ?array // 9 Glyphs $moduleMask & 0x0000200 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', -13], $lookup]); + $glyphs = new SpellList($cnd, ['calcTotal' => true]); + + $data = $glyphs->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $glyphs->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'id' => 'glyphs', + 'name' => '$LANG.tab_glyphs', + 'visibleCols' => ['singleclass', 'glyphtype'] + ); + + if ($glyphs->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_glyphsfound', $glyphs->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=-13&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=-13&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $glyphs->getMatches(), [], [], 'Glyph']; + + foreach ($glyphs->iterate() as $id => $__) + { + $result[$id] = $glyphs->getField('name', true); + $osInfo[2][$id] = $glyphs->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchProficiency() : ?array // 10 Proficiencies $moduleMask & 0x0000400 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', -11], $lookup]); + $prof = new SpellList($cnd, ['calcTotal' => true]); + + $data = $prof->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $prof->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'id' => 'proficiencies', + 'name' => '$LANG.tab_proficiencies', + 'visibleCols' => ['classes'] + ); + + if ($prof->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_spellsfound', $prof->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=-11&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=-11&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $prof->getMatches(), [], [], 'Proficiency']; + + foreach ($prof->iterate() as $id => $__) + { + $result[$id] = $prof->getField('name', true); + $osInfo[2][$id] = $prof->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchProfession() : ?array // 11 Professions (Primary + Secondary) $moduleMask & 0x0000800 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', [9, 11]], $lookup]); + $prof = new SpellList($cnd, ['calcTotal' => true]); + + $data = $prof->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $prof->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $lvData = array( + 'data' => $data, + 'id' => 'professions', + 'name' => '$LANG.tab_professions', + 'visibleCols' => ['source', 'reagents'] + ); + + if ($prof->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_professionfound', $prof->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=11&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=11&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $prof->getMatches(), [], [], 'Profession']; + + foreach ($prof->iterate() as $id => $__) + { + $result[$id] = $prof->getField('name', true); + $osInfo[2][$id] = $prof->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchCompanion() : ?array // 12 Companions $moduleMask & 0x0001000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', -6], $lookup]); + $vPets = new SpellList($cnd, ['calcTotal' => true]); + + $data = $vPets->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $vPets->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'id' => 'companions', + 'name' => '$LANG.tab_companions', + 'visibleCols' => ['reagents'] + ); + + if ($vPets->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_companionsfound', $vPets->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=-6&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=-6&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $vPets->getMatches(), [], [], 'Companion']; + + foreach ($vPets->iterate() as $id => $__) + { + $result[$id] = $vPets->getField('name', true); + $osInfo[2][$id] = $vPets->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchMount() : ?array // 13 Mounts $moduleMask & 0x0002000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', -5], $lookup]); + $mounts = new SpellList($cnd, ['calcTotal' => true]); + + $data = $mounts->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $mounts->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'id' => 'mounts', + 'name' => '$LANG.tab_mounts', + ); + + if ($mounts->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_mountsfound', $mounts->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=-5&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=-5&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $mounts->getMatches(), [], [], 'Mount']; + + foreach ($mounts->iterate() as $id => $__) + { + $result[$id] = $mounts->getField('name', true); + $osInfo[2][$id] = $mounts->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchCreature() : ?array // 14 NPCs $moduleMask & 0x0004000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, array( + [['flagsExtra', 0x80], 0], // exclude trigger creatures + [['cuFlags', NPC_CU_DIFFICULTY_DUMMY, '&'], 0], // exclude difficulty entries + $lookup + )); + $npcs = new CreatureList($cnd, ['calcTotal' => true]); + + $data = $npcs->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = array( + 'data' => $data, + 'id' => 'npcs', + 'name' => '$LANG.tab_npcs', + ); + + if ($npcs->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_npcsfound', $npcs->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?npcs&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?npcs&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, CreatureList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::NPC, $npcs->getMatches(), [], [], 'NPC']; + + foreach ($npcs->iterate() as $id => $__) + { + $result[$id] = $npcs->getField('name', true); + if ($npcs->isBoss()) + $osInfo[2][$id] = 1; + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchQuest() : ?array // 15 Quests $moduleMask & 0x0008000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, array( + [['flags', CUSTOM_UNAVAILABLE | CUSTOM_DISABLED, '&'], 0], + $lookup + )); + $quests = new QuestList($cnd, ['calcTotal' => true]); + + $data = $quests->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $quests->getJSGlobals()); + + $lvData = ['data' => $data]; + + if ($quests->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_questsfound', $quests->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?quests&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?quests&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, QuestList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::QUEST, $quests->getMatches(), [], [], 'Quest']; + + foreach ($quests->iterate() as $id => $__) + { + $result[$id] = $quests->getField('name', true); + $osInfo[2][$id] = $data[$id]['side']; // why recalculate if already set + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchAchievement() : ?array // 16 Achievements $moduleMask & 0x0010000 + { + $cnd = array_merge($this->cndBase, array( + [['flags', ACHIEVEMENT_FLAG_COUNTER, '&'], 0], // not a statistic + $this->createLikeLookup() + )); + $acvs = new AchievementList($cnd, ['calcTotal' => true]); + + $data = $acvs->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $acvs->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'visibleCols' => ['category'] + ); + + if ($acvs->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_achievementsfound', $acvs->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?achievements&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?achievements&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, AchievementList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::ACHIEVEMENT, $acvs->getMatches(), [], [], 'Achievement']; + + foreach ($acvs->iterate() as $id => $__) + { + $result[$id] = $acvs->getField('name', true); + $osInfo[2][$id] = $acvs->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchStatistic() : ?array // 17 Statistics $moduleMask & 0x0020000 + { + $cnd = array_merge($this->cndBase, array( + ['flags', ACHIEVEMENT_FLAG_COUNTER, '&'], // is a statistic + $this->createLikeLookup() + )); + $stats = new AchievementList($cnd, ['calcTotal' => true]); + + $data = $stats->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $stats->getJSGlobals(GLOBALINFO_SELF)); + + $lvData = array( + 'data' => $data, + 'visibleCols' => ['category'], + 'hiddenCols' => ['side', 'points', 'rewards'], + 'name' => '$LANG.tab_statistics', + 'id' => 'statistics' + ); + + if ($stats->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_statisticsfound', $stats->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?achievements=1&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?achievements=1&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, AchievementList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::ACHIEVEMENT, $stats->getMatches(), [], [], 'Statistic']; + + foreach ($stats->iterate() as $id => $__) + { + $result[$id] = $stats->getField('name', true); + $osInfo[2][$id] = $stats->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchZone() : ?array // 18 Zones $moduleMask & 0x0040000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $zones = new ZoneList($cnd, ['calcTotal' => true]); + + $data = $zones->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $zones->getJSGlobals()); + + $lvData = ['data' => $data]; + + if ($zones->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_zonesfound', $zones->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?achievements&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?achievements&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, ZoneList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::ZONE, $zones->getMatches(), [], [], 'Zone']; + + foreach ($zones->iterate() as $id => $__) + $result[$id] = $zones->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchObject() : ?array // 19 Objects $moduleMask & 0x0080000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [$lookup]); + $objects = new GameObjectList($cnd, ['calcTotal' => true]); + + $data = $objects->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $objects->getJSGlobals()); + + $lvData = ['data' => $data]; + + if ($objects->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_objectsfound', $objects->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?objects&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?objects&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, GameObjectList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::OBJECT, $objects->getMatches(), [], [], 'Object']; + + foreach ($objects->iterate() as $id => $__) + $result[$id] = $objects->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchFaction() : ?array // 20 Factions $moduleMask & 0x0100000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $factions = new FactionList($cnd, ['calcTotal' => true]); + + $data = $factions->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = ['data' => $data]; + + if ($factions->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_factionsfound', $factions->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, FactionList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::FACTION, $factions->getMatches(), [], [], 'Faction']; + + foreach ($factions->iterate() as $id => $__) + $result[$id] = $factions->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchSkill() : ?array // 21 Skills $moduleMask & 0x0200000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $skills = new SkillList($cnd, ['calcTotal' => true]); + + $data = $skills->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = ['data' => $data]; + + if ($skills->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_skillsfound', $skills->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, SkillList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SKILL, $skills->getMatches(), [], [], 'Skill']; + + foreach ($skills->iterate() as $id => $__) + { + $result[$id] = $skills->getField('name', true); + $osInfo[2][$id] = $skills->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchPet() : ?array // 22 Pets $moduleMask & 0x0400000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup()]); + $pets = new PetList($cnd, ['calcTotal' => true]); + + $data = $pets->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + $lvData = array( + 'data' => $data, + 'computeDataFunc' => '$_' + ); + + if ($pets->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_petsfound', $pets->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, PetList::$brickFile, 'petFoodCol']; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::PET, $pets->getMatches(), [], [], 'Pet']; + + foreach ($pets->iterate() as $id => $__) + { + $result[$id] = $pets->getField('name', true); + $osInfo[2][$id] = $pets->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchCreatureAbility() : ?array // 23 NPCAbilities $moduleMask & 0x0800000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, [['s.typeCat', -8], $lookup]); + $npcAbilities = new SpellList($cnd, ['calcTotal' => true]); + + $data = $npcAbilities->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $npcAbilities->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'id' => 'npc-abilities', + 'name' => '$LANG.tab_npcabilities', + 'visibleCols' => ['level'], + 'hiddenCols' => ['skill'] + ); + + if ($npcAbilities->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_spellsfound', $npcAbilities->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=-8&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=-8&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $npcAbilities->getMatches(), [], [], 'Spell']; + + foreach ($npcAbilities->iterate() as $id => $__) + { + $result[$id] = $npcAbilities->getField('name', true); + $osInfo[2][$id] = $npcAbilities->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchSpell() : ?array // 24 Spells (Misc + GM + triggered abilities) $moduleMask & 0x1000000 + { + $lookup = $this->createMatchLookup(); + if (!$lookup) + return null; + + $cnd = array_merge($this->cndBase, array( + ['s.typeCat', -8, '!'], + [ + DB::OR, + ['s.typeCat', [0, -9]], + ['s.cuFlags', SPELL_CU_TRIGGERED, '&'], + ['s.attributes0', 0x80, '&'] + ], + $lookup + )); + $misc = new SpellList($cnd, ['calcTotal' => true]); + + $data = $misc->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $misc->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $lvData = array( + 'data' => $data, + 'name' => '$LANG.tab_uncategorizedspells', + 'visibleCols' => ['level'], + 'hiddenCols' => ['skill'] + ); + + if ($misc->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_spellsfound', $misc->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + if (isset($lvData['note'])) + $lvData['note'] .= ' + LANG.dash + $WH.sprintf(LANG.lvnote_filterresults, \'?spells=0&filter=na='.urlencode($this->query).'\')'; + else + $lvData['note'] = '$$WH.sprintf(LANG.lvnote_filterresults, \'?spells=0&filter=na='.urlencode($this->query).'\')'; + + return [$lvData, SpellList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SPELL, $misc->getMatches(), [], [], 'Spell']; + + foreach ($misc->iterate() as $id => $__) + { + $result[$id] = $misc->getField('name', true); + $osInfo[2][$id] = $misc->getField('iconString'); + } + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchEmote() : ?array // 25 Emotes $moduleMask & 0x2000000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup(['cmd', 'meToExt_loc'.Lang::getLocale()->value, 'meToNone_loc'.Lang::getLocale()->value, 'extToMe_loc'.Lang::getLocale()->value, 'extToExt_loc'.Lang::getLocale()->value, 'extToNone_loc'.Lang::getLocale()->value])]); + $emote = new EmoteList($cnd, ['calcTotal' => true]); + + $data = $emote->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $emote->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'name' => Util::ucFirst(Lang::game('emotes')) + ); + + if ($emote->getMatches() > $this->maxResults) + { + // $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_emotesfound', $emote->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, EmoteList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::EMOTE, $emote->getMatches(), [], [], 'Emote']; + + foreach ($emote->iterate() as $id => $__) + $result[$id] = $emote->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchEnchantment() : ?array // 26 Enchantments $moduleMask & 0x4000000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup(['name_loc'.Lang::getLocale()->value])]); + $enchantment = new EnchantmentList($cnd, ['calcTotal' => true]); + + $data = $enchantment->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $enchantment->getJSGlobals()); + + $lvData = array( + 'data' => $data, + 'name' => Util::ucFirst(Lang::game('enchantments')) + ); + + if (array_filter(array_column($data, 'spells'))) + $lvData['visibleCols'] = ['trigger']; + + if (!$enchantment->hasSetFields('skillLine')) + $lvData['hiddenCols'] = ['skill']; + + if ($enchantment->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_enchantmentsfound', $enchantment->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, EnchantmentList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::ENCHANTMENT, $enchantment->getMatches(), [], [], 'Enchantment']; + + foreach ($enchantment->iterate() as $id => $__) + $result[$id] = $enchantment->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } + + private function _searchSound() : ?array // 27 Sounds $moduleMask & 0x8000000 + { + $cnd = array_merge($this->cndBase, [$this->createLikeLookup(['name'])]); + $sounds = new SoundList($cnd, ['calcTotal' => true]); + + $data = $sounds->getListviewData(); + if (!$data) + return []; + + if ($this->moduleMask & self::TYPE_REGULAR) + { + Util::mergeJsGlobals($this->jsgStore, $sounds->getJSGlobals()); + + $lvData = array( + 'data' => $data, + ); + + if ($sounds->getMatches() > $this->maxResults) + { + $lvData['note'] = sprintf(Util::$tryNarrowingString, 'LANG.lvnote_soundsfound', $sounds->getMatches(), $this->maxResults); + $lvData['_truncated'] = 1; + } + + return [$lvData, SoundList::$brickFile]; + } + + if ($this->moduleMask & self::TYPE_OPEN) + { + $result = []; + $osInfo = [Type::SOUND, $sounds->getMatches(), [], [], 'Sound']; + + foreach ($sounds->iterate() as $id => $__) + $result[$id] = $sounds->getField('name', true); + + return [$result, ...$osInfo]; + } + + return null; + } +} diff --git a/includes/components/sitemap.class.php b/includes/components/sitemap.class.php new file mode 100644 index 00000000..f7d5b4e2 --- /dev/null +++ b/includes/components/sitemap.class.php @@ -0,0 +1,181 @@ + [Type::NPC, '::creature', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.4)'], + 'object' => [Type::OBJECT, '::objects', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.4)'], + 'item' => [Type::ITEM, '::items', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(src.`typeId` IS NULL, 0.5, 0.7))'], + 'itemset' => [Type::ITEMSET, '::itemset', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.7)'], + 'quest' => [Type::QUEST, '::quests', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(src.`typeId` IS NULL, 0.3, 0.5))'], + 'spell' => [Type::SPELL, '::spell', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(src.`typeId` IS NULL, 0.5, 0.8))'], + 'zone' => [Type::ZONE, '::zones', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.4)'], + 'faction' => [Type::FACTION, '::factions', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.4)'], + 'pet' => [Type::PET, '::pet', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.4)'], + 'achievement' => [Type::ACHIEVEMENT, '::achievement', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(x.`category` = 81, 0.6, IF(x.`category` IN (1, 122, 133, 141, 134, 14807, 131, 130, 128, 132, 21, 124, 135, 126, 154, 125, 140, 145, 147, 136, 127, 152, 153, 191, 123, 14822, 14821, 14823, 137, 178, 173, 14963, 15021, 15062), 0.3, 0.4)))'], + 'title' => [Type::TITLE, '::titles', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(src.`typeId` IS NULL, 0.3, 0.4))'], + 'event' => [Type::WORLDEVENT, '::events', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(x.`holidayId` = 0, 0.2, 0.4))'], + 'class' => [Type::CHR_CLASS, '::classes', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.7)'], + 'race' => [Type::CHR_RACE, '::races', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.7)'], + 'skill' => [Type::SKILL, '::skillline', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(x.`typeCat` IN(11, 9), 0.5, IF(x.`typeCat` IN (8, 6), 0.4, 0.3)))'], + 'currency' => [Type::CURRENCY, '::currencies', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(x.`category` = 3, 0.2, IF(x.`description_loc0`, 0.4, 0.3)))'], + 'sound' => [Type::SOUND, '::sounds', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.3)'], + 'icon' => [Type::ICON, '::icons', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.3)'], + 'emote' => [Type::EMOTE, '::emotes', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.3)'], + 'enchantment' => [Type::ENCHANTMENT, '::itemenchantment', 'IF(x.`cuFlags` & 0x40000000, 0.1, IF(x.`type1` IN (1, 7) OR x.`type2` IN (1, 7) OR x.`type3` IN (1, 7), 0.4, 0.3))'], + 'areatrigger' => [Type::AREATRIGGER, '::areatrigger', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.3)'], + 'mail' => [Type::MAIL, '::mails', 'IF(x.`cuFlags` & 0x40000000, 0.1, 0.3)'] + // 'guide' => [Type::GUIDE, '::guides', ''] super low prio .. need a way to filter for publicly visible guides + ); + + public static function generate(string $page, int $offset) : ?string + { + self::$page = $page; + self::$offset = $offset; + + if (!self::$page) + return self::getIndex(); + else if (self::$page == 'special') + return self::getSpecial(); + else if (isset(self::$validPages[self::$page][1])) + return self::getPage(); + + // whoops! + return null; + } + + private static function getIndex() : ?string + { + $root = new SimpleXML(''); + $root->addAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9'); + + $root->addChild('sitemap')->addChild('loc', Cfg::get('HOST_URL').'/?sitemap=special'); + + foreach (self::$validPages as $page => [, $table, ]) + { + $n = DB::Aowow()->selectCell('SELECT CEIL(COUNT(*) / %i) FROM %n', self::MAX_ENTRIES, $table); + for ($i = 1; $i <= $n; $i++) + $root->addChild('sitemap')->addChild('loc', Cfg::get('HOST_URL').'/?sitemap='.$page.'&page='.$i); + } + + return $root->asXML() ?: null; + } + + private static function getSpecial() : ?string + { + if (self::$offset != 1) + { + self::$maxPage = 1; + return null; + } + + $root = new SimpleXML(''); + $root->addAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9'); + + // home + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL')); + $url->addChild('priority', 1); + $url->addChild('changefreq', 'monthly'); + + // talent calc + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL').'/?talent'); + $url->addChild('priority', 1); + $url->addChild('changefreq', 'yearly'); + + // pet calc + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL').'/?petcalc'); + $url->addChild('priority', 0.8); + $url->addChild('changefreq', 'yearly'); + + // item compare + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL').'/?compare'); + $url->addChild('priority', 0.9); + $url->addChild('changefreq', 'yearly'); + + // profiler + if (Cfg::get('PROFILER_ENABLE')) + { + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL').'/?profiler'); + $url->addChild('priority', 1); + $url->addChild('changefreq', 'yearly'); + } + + // maps + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL').'/?maps'); + $url->addChild('priority', 0.7); + $url->addChild('changefreq', 'yearly'); + + return $root->asXML(); + } + + private static function getPage() : ?string + { + [$type, $table, $prioString] = self::$validPages[self::$page]; + + $n = DB::Aowow()->selectCell('SELECT CEIL(COUNT(*) / %i) FROM %n', self::MAX_ENTRIES, $table); + if (self::$offset <= 0 || self::$offset > $n) + { + self::$maxPage = $n; + return null; + } + + $root = new SimpleXML(''); + $root->addAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9'); + + $rows = DB::Aowow()->selectAssoc( + 'SELECT x.`id` AS ARRAY_KEY, ('.$prioString.') AS "priority", GREATEST(IFNULL(MAX(ss.`date`), 0), IFNULL(MAX(vi.`date`), 0), IFNULL(MAX(co.`date`), 0)) AS "lastmod" FROM %n x + LEFT JOIN ::source src ON src.`type` = %i AND src.`typeId` = x.`id` + LEFT JOIN ::comments co ON co.`type` = %i AND co.`typeId` = x.`id` AND (co.`flags` & %i) = 0 + LEFT JOIN ::screenshots ss ON ss.`type` = %i AND ss.`typeId` = x.`id` AND (co.`flags` & %i) = 0 AND (co.`flags` & %i) > 0 + LEFT JOIN ::videos vi ON vi.`type` = %i AND vi.`typeId` = x.`id` AND (co.`flags` & %i) = 0 AND (co.`flags` & %i) > 0 + GROUP BY x.`id` LIMIT %i, %i', + $table, + $type, + $type, CC_FLAG_DELETED, + $type, CC_FLAG_DELETED, CC_FLAG_APPROVED, + $type, CC_FLAG_DELETED, CC_FLAG_APPROVED, + self::MAX_ENTRIES * (self::$offset - 1), self::MAX_ENTRIES + ); + + foreach ($rows as $id => $pair) + { + $url = $root->addChild('url'); + $url->addChild('loc', Cfg::get('HOST_URL').'/?'.self::$page.'='.$id); + $url->addChild('priority', $pair['priority']); + $url->addChild('lastmod', date('c', $pair['lastmod'] ?: self::LASTMOD_BASE)); + } + + return $root->asXML(); + } +} + +?> diff --git a/includes/components/videomgr.class.php b/includes/components/videomgr.class.php new file mode 100644 index 00000000..1ce894bd --- /dev/null +++ b/includes/components/videomgr.class.php @@ -0,0 +1,220 @@ +id . PHP_EOL); + fwrite($tmpFile, $videoInfo->title . PHP_EOL); + fwrite($tmpFile, $videoInfo->thumbnail_url . PHP_EOL); + fwrite($tmpFile, $videoInfo->thumbnail_height . PHP_EOL); + fwrite($tmpFile, $videoInfo->thumbnail_width . PHP_EOL); + + return fclose($tmpFile); + } + + public static function loadSuggestion(?\stdClass &$videoInfo, int $destType, int $destTypeId, ?string $uid) : bool + { + self::$tmpFile = sprintf(self::PATH_TEMP, User::$username.'-'.$destType.'-'.$destTypeId.'-'.$uid); + + if (!file_exists(self::$tmpFile)) + return false; + + if ($info = file(self::$tmpFile, FILE_IGNORE_NEW_LINES)) + { + $videoInfo = new \stdClass; + $videoInfo->id = $info[0]; + $videoInfo->title = $info[1]; + $videoInfo->thumbnail_url = $info[2]; + $videoInfo->thumbnail_height = (int)$info[3]; + $videoInfo->thumbnail_width = (int)$info[4]; + + return true; + } + + return false; + } + + public static function dropTempFile() + { + if (!self::$tmpFile || !file_exists(self::$tmpFile)) + return; + + unlink(self::$tmpFile); + } + + + /*************/ + /* Admin Mgr */ + /*************/ + + public static function getVideos(int $type = 0, int $typeId = 0, $userId = 0, ?int &$nFound = 0) : array + { + /* VideoData + * caption: caption + * date: isodate + * height: ytPreviewImgHeight? + * width: ytPreviewImgWidth? + * id: id + * next: idx || null + * prev: idx || null + * name: ytTitle? + * pending: bool + * status: statusCode + * type: dbType + * typeId: typeId + * user: userName + * url: ytPreviewImg? + * videoType: always 1 + * videoId: videoId + * unique: bool || null + */ + + if ($userId) + $where = [['v.`userIdOwner` = %i', $userId]]; + else + $where = [['v.`type` = %i', $type], ['v.`typeId` = %i', $typeId]]; + + $videos = DB::Aowow()->selectAssoc( + 'SELECT v.`id`, a.`username` AS "user", v.`date`, v.`videoId`, v.`type`, v.`typeId`, v.`caption`, v.`status` AS "flags", v.`url`, v.`name` + FROM ::videos v + LEFT JOIN ::account a ON v.`userIdOwner` = a.`id` + WHERE %and + %lmt + ORDER BY `type`, `typeId`, `pos` ASC', + $where, $userId || $type ? PHP_INT_MAX : 100 + ); + + $num = []; + foreach ($videos as $v) + { + if (empty($num[$v['type']][$v['typeId']])) + $num[$v['type']][$v['typeId']] = 1; + else + $num[$v['type']][$v['typeId']]++; + } + + $nFound = 0; + + // format data to meet requirements of the js + foreach ($videos as $i => &$v) + { + $nFound++; + + $v['date'] = date(Util::$dateFormatInternal, $v['date']); + $v['videoType'] = self::TYPE_YOUTUBE; + + if ($i > 0) + $v['prev'] = $i - 1; + + if (($i + 1) < count($videos)) + $v['next'] = $i + 1; + + // order gives priority for 'status' + if (!($v['flags'] & CC_FLAG_APPROVED)) + { + $v['pending'] = 1; + $v['status'] = self::STATUS_PENDING; + } + else + $v['status'] = self::STATUS_APPROVED; + + if ($v['flags'] & CC_FLAG_STICKY) + { + $v['sticky'] = 1; + $v['status'] = self::STATUS_STICKY; + } + + if ($v['flags'] & CC_FLAG_DELETED) + { + $v['deleted'] = 1; + $v['status'] = self::STATUS_DELETED; + } + + // something todo with massSelect .. am i doing this right? + if ($num[$v['type']][$v['typeId']] == 1) + $v['unique'] = 1; + + if (!$v['user']) + unset($v['user']); + } + + return $videos; + } + + public static function getPages(?bool $all, ?int &$nFound) : array + { + // i GUESS .. vi_getALL ? everything : pending + $nFound = 0; + if ($pages = DB::Aowow()->selectAssoc('SELECT `type`, `typeId`, COUNT(1) AS "count", MIN(`date`) AS "date" FROM ::videos %if', !$all, 'WHERE (`status` & %i) = 0', CC_FLAG_APPROVED | CC_FLAG_DELETED, '%end GROUP BY `type`, `typeId`')) + { + // limit to one actually existing type each + foreach (array_unique(array_column($pages, 'type')) as $t) + { + $ids = []; + foreach ($pages as $row) + if ($row['type'] == $t) + $ids[] = $row['typeId']; + + if (!$ids) + continue; + + $obj = Type::newList($t, [['id', $ids]]); + if (!$obj || $obj->error) + continue; + + foreach ($pages as &$p) + if ($p['type'] == $t) + if ($obj->getEntry($p['typeId'])) + $p['name'] = $obj->getField('name', true); + } + + foreach ($pages as &$p) + { + if (empty($p['name'])) + { + trigger_error('VideoMgr::getPages - video linked to nonexistent type/typeId combination: '.$p['type'].'/'.$p['typeId'], E_USER_NOTICE); + unset($p); + } + else + { + $nFound += $p['count']; + $p['date'] = date(Util::$dateFormatInternal, $p['date']); + } + } + } + + return $pages; + } +} + +?> diff --git a/includes/database.class.php b/includes/database.class.php deleted file mode 100644 index 1d289d6a..00000000 --- a/includes/database.class.php +++ /dev/null @@ -1,179 +0,0 @@ -error) - die('Failed to connect to database on index #'.$idx.".\n"); - - $interface->setErrorHandler(['DB', 'errorHandler']); - $interface->query('SET NAMES ?', 'utf8mb4'); - if ($options['prefix']) - $interface->setIdentPrefix($options['prefix']); - - // disable STRICT_TRANS_TABLES and STRICT_ALL_TABLES off. It prevents usage of implicit default values. - if ($idx == DB_AOWOW) - $interface->query("SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'"); - // disable ONLY_FULL_GROUP_BY (Allows for non-aggregated selects in a group-by query) - else - $interface->query("SET SESSION sql_mode = ''"); - - self::$interfaceCache[$idx] = &$interface; - self::$connectionCache[$idx] = true; - } - - public static function test(array $options, ?string &$err = '') : bool - { - $defPort = ini_get('mysqli.default_port'); - $port = 0; - if (strstr($options['host'], ':')) - [$options['host'], $port] = explode(':', $options['host']); - - try { - $link = @mysqli_connect($options['host'], $options['user'], $options['pass'], $options['db'], $port ?: $defPort); - mysqli_close($link); - } - catch (Exception $e) - { - $err = '['.mysqli_connect_errno().'] '.mysqli_connect_error(); - return false; - } - return true; - } - - public static function errorHandler($message, $data) - { - if (!error_reporting()) - return; - - $error = "DB ERROR:

\n\n
".print_r($data, true)."
"; - - echo CLI ? strip_tags($error) : $error; - exit; - } - - public static function logger($self, $query, $trace) - { - if ($trace) // actual query - self::$logs[] = [substr(str_replace("\n", ' ', $query), 0, 200)]; - else // the statistics - { - end(self::$logs); - self::$logs[key(self::$logs)][] = substr(explode(';', $query)[0], 5); - } - } - - public static function getLogs() - { - $out = '
';
-        foreach (self::$logs as $i => [$l, $t])
-        {
-            $c = 'inherit';
-            preg_match('/(\d+)/', $t, $m);
-            if ($m[1] > 100)
-                $c = '#FFA0A0';
-            else if ($m[1] > 20)
-                $c = '#FFFFA0';
-
-            $out .= '';
-        }
-
-        return Util::jsEscape($out).'
TimeQuery
'.$i.'.'.$t.''.$l.'
'; - } - - public static function getDB($idx) - { - return self::$interfaceCache[$idx]; - } - - public static function isConnected($idx) - { - return isset(self::$connectionCache[$idx]); - } - - public static function isConnectable($idx) - { - return isset(self::$optionsCache[$idx]); - } - - private static function safeGetDB($idx) - { - if (!self::isConnected($idx)) - self::connect($idx); - - return self::getDB($idx); - } - - /** - * @static - * @return DbSimple_Mysql - */ - public static function Characters($realm) - { - if (!isset(self::$optionsCache[DB_CHARACTERS.$realm])) - die('Connection info not found for live database of realm #'.$realm.'. Aborted.'); - - return self::safeGetDB(DB_CHARACTERS.$realm); - } - - /** - * @static - * @return DbSimple_Mysql - */ - public static function Auth() - { - return self::safeGetDB(DB_AUTH); - } - - /** - * @static - * @return DbSimple_Mysql - */ - public static function World() - { - return self::safeGetDB(DB_WORLD); - } - - /** - * @static - * @return DbSimple_Mysql - */ - public static function Aowow() - { - return self::safeGetDB(DB_AOWOW); - } - - public static function load($idx, $config) - { - self::$optionsCache[$idx] = $config; - } -} - -?> diff --git a/includes/database.php b/includes/database.php new file mode 100644 index 00000000..f0df4f51 --- /dev/null +++ b/includes/database.php @@ -0,0 +1,332 @@ +query($args)->fetch(); + } + catch (\Exception $e) {} // logged via \Dibi\Event in errorLogger + + return null; + } + + /** + * Executes SQL query and fetch first column - shortcut for query() & fetchSingle(). + */ + public function selectCell(mixed ...$args) : mixed + { + try + { + $x = $this->query($args)->fetchSingle(); + return is_array($x) ? array_pop($x) : $x; + } + catch (\Exception $e) {} // logged via \Dibi\Event in errorLogger + + return null; + } + + /** + * Executes SQL query and fetch first column - shortcut for query() & fetchSingle(). + */ + public function selectCol(mixed ...$args) : ?array + { + try + { + $result = $this->query($args); + if (strpos($args[0], 'ARRAY_KEY2')) + $data = $result->fetchAssoc('ARRAY_KEY|ARRAY_KEY2'); + else if (strpos($args[0], 'ARRAY_KEY')) + $data = $result->fetchAssoc('ARRAY_KEY'); + else + $data = $result->fetchAll(); + + $result->free(); + + // convert Dibi/Row to array + // remove array keys from result set and set result to next cell + array_walk_recursive($data, function(&$row) { + if (get_debug_type($row) == 'Dibi\Row') + $row = (array)$row; + + unset($row['ARRAY_KEY'], $row['ARRAY_KEY2']); + $row = array_pop($row); + }); + return $data; + } + catch (\Exception $e) {} // logged via \Dibi\Event in errorLogger + + return null; + } + + /** + * Executes SQL query and fetch ass associative array + */ + public function selectAssoc(mixed ...$args) : ?array + { + try + { + $result = $this->query($args); + if (strpos($args[0], 'ARRAY_KEY2')) + $data = $result->fetchAssoc('ARRAY_KEY|ARRAY_KEY2'); + else if (strpos($args[0], 'ARRAY_KEY')) + $data = $result->fetchAssoc('ARRAY_KEY'); + else + $data = $result->fetchAll(); + + $result->free(); + + // convert Dibi/Row to array + // remove array keys from result set + array_walk_recursive($data, function(&$row) { + if (get_debug_type($row) == 'Dibi\Row') + $row = (array)$row; + + unset($row['ARRAY_KEY'], $row['ARRAY_KEY2']); + }); + return $data; + } + catch (\Exception $e) {} // logged via \Dibi\Event in errorLogger + + return null; + } + + /** + * Executes SQL query and fetch pairs - shortcut for query() & fetchPairs(). + */ + public function selectPairs(mixed ...$args): ?array + { + try + { + return $this->query($args)->fetchPairs(); + } + catch (\Exception $e) {} // logged via \Dibi\Event in errorLogger + + return null; + } + + /** + * Executes SQL query and returns new insertId or num affected rows. + */ + public function qry(mixed ...$args) : ?int + { + try + { + $this->nativeQuery($this->translate(...$args)); + if (strstr($args[0], 'INSERT')) + return $this->getDriver()?->getResource()?->insert_id; + else + return $this->getAffectedRows(); + } + catch (\Exception $e) {} // logged via \Dibi\Event in errorLogger + + return null; + } +} + +class DB +{ + public const /* string */ AND = '%and'; + public const /* string */ OR = '%or'; + + private static array $interfaceCache = []; + private static array $interfaceTimes = []; + private static array $optionsCache = []; + private static array $logs = []; + + public static function connect(int $idx) : bool + { + if (self::isConnected($idx)) + { + self::$interfaceCache[$idx]->disconnect(); + self::$interfaceCache[$idx] = null; + } + + $config = self::$optionsCache[$idx] + array( + 'charset' => 'utf8mb4', // executes: SET NAMES $charset + 'substitutes' => array( + '' => self::$optionsCache[$idx]['prefix'] // old: ?_ - new: :: + ) + ); + + // alias old DBSimple format + if (empty($config['database']) && !empty($config['db'])) + $config['database'] = &$config['db']; + + try + { + $interface = new DibiConnection($config); + } + catch (\Exception $e) + { + return false; + } + + // disable STRICT_TRANS_TABLES and STRICT_ALL_TABLES. It prevents usage of implicit default values. + // disable ONLY_FULL_GROUP_BY (Allows for non-aggregated selects in a group-by query) + $extraModes = ['STRICT_TRANS_TABLES', 'STRICT_ALL_TABLES', 'ONLY_FULL_GROUP_BY', 'NO_ZERO_DATE', 'NO_ZERO_IN_DATE', 'ERROR_FOR_DIVISION_BY_ZERO']; + $oldModes = explode(',', $interface->fetchSingle('SELECT @@sql_mode')); + $newModes = array_diff($oldModes, $extraModes); + if ($oldModes != $newModes) + $interface->query("SET SESSION sql_mode = %s", implode(',', $newModes)); + + $interface->onEvent[] = self::errorLogger(...); + $interface->onEvent[] = self::profiler(...); + + self::$interfaceCache[$idx] = &$interface; + return true; + } + + public static function test(array $options, ?string &$err = '') : bool + { + $defPort = ini_get('mysqli.default_port'); + $port = 0; + if (strstr($options['host'], ':')) + [$options['host'], $port] = explode(':', $options['host']); + + if ($link = mysqli_connect($options['host'], $options['user'], $options['pass'], $options['db'], $port ?: $defPort)) + { + mysqli_close($link); + return true; + } + + $err = '['.mysqli_connect_errno().'] '.mysqli_connect_error(); + return false; + } + + public static function errorLogger(\Dibi\Event $evt/* string $message, array $data */) : void + { + if (!error_reporting()) + return; + + if (!$evt->result instanceof \Exception) + return; + + $msg = <<result->getCode()} + message: {$evt->result->getMessage()} + query: {$evt->sql} + context: {$evt->source[0]} line {$evt->source[1]} + MSG; + + if (CLI) + fwrite(STDERR, $msg); + else if (User::isInGroup(U_GROUP_ADMIN) && Cfg::get('DEBUG') >= LOG_LEVEL_INFO) + echo PHP_EOL . '
' . $msg . '
' . PHP_EOL; + + trigger_error($evt->result->getMessage(), E_USER_ERROR); + } + + public static function profiler(\Dibi\Event $evt/* mixed $self, string $query, mixed $trace */) : void + { + $query = \dibi::$sql; + $time = \dibi::$elapsedTime; + + self::$logs[] = [str_replace("\n", ' ', $query), $time]; + } + + public static function getProfiles() : string + { + $out = '
';
+        foreach (self::$logs as $i => [$l, $t])
+        {
+            // t in seconds
+            $c = 'inherit';
+            if ($t > (100 / 1000))
+                $c = '#FFA0A0';
+            else if ($t > (20 / 1000))
+                $c = '#FFFFA0';
+
+            $out .= '';
+        }
+
+        $out .= '';
+
+        return Util::jsEscape($out).'
TimeQuery
'.++$i.'.'.round($t * 1000, 2).'ms'.$l.'
∑t:' . round(array_sum(array_column(self::$logs, 1)) * 1000, 2) . 'ms
'; + } + + public static function load(int $idx, array $config, int $keepAlive = 1 * HOUR) : void + { + self::$optionsCache[$idx] = $config; + if (self::connect($idx)) + self::$interfaceTimes[$idx] = [time() + $keepAlive, $keepAlive]; + } + + public static function isConnected(int $idx) : bool + { + return isset(self::$interfaceCache[$idx]) && self::$interfaceCache[$idx]->isConnected(); + } + + public static function isConnectable(int $idx) : bool + { + return isset(self::$optionsCache[$idx]); + } + + /** + * @static + * @return DibiConnection + */ + public static function Characters(int $realmId) : ?DibiConnection + { + if (!isset(self::$optionsCache[DB_CHARACTERS.$realmId])) + die('Connection info not found for live database of realm #'.$realmId.'. Aborted.'); + + return self::getDB(DB_CHARACTERS.$realmId); + } + + /** + * @static + * @return DibiConnection + */ + public static function Auth() : ?DibiConnection + { + return self::getDB(DB_AUTH); + } + + /** + * @static + * @return DibiConnection + */ + public static function World() : ?DibiConnection + { + return self::getDB(DB_WORLD); + } + + /** + * @static + * @return DibiConnection + */ + public static function Aowow() : ?DibiConnection + { + return self::getDB(DB_AOWOW); + } + + private static function getDB(int $idx) : ?DibiConnection + { + if (self::$interfaceTimes[$idx][0] < time()) + { + self::$interfaceCache[$idx]->disconnect(); + if (!self::connect($idx)) + return null; + + self::$interfaceTimes[$idx][0] = time() + self::$interfaceTimes[$idx][1]; + } + + return self::$interfaceCache[$idx]; + } +} + +?> diff --git a/includes/types/achievement.class.php b/includes/dbtypes/achievement.class.php similarity index 55% rename from includes/types/achievement.class.php rename to includes/dbtypes/achievement.class.php index b33d4b11..5a665136 100644 --- a/includes/types/achievement.class.php +++ b/includes/dbtypes/achievement.class.php @@ -1,31 +1,28 @@ [['ic'], 'o' => 'orderInGroup ASC'], - 'ic' => ['j' => ['?_icons ic ON ic.id = a.iconId', true], 's' => ', ic.name AS iconString'], - 'ac' => ['j' => ['?_achievementcriteria AS `ac` ON `ac`.`refAchievementId` = `a`.`id`', true], 'g' => '`a`.`id`'] + protected string $queryBase = 'SELECT `a`.*, `a`.`id` AS ARRAY_KEY FROM ::achievement a'; + protected array $queryOpts = array( + 'a' => [['ic'], 'o' => 'orderInGroup ASC'], + 'ic' => ['j' => ['::icons ic ON ic.id = a.iconId', true], 's' => ', ic.name AS iconString'], + 'ac' => ['j' => ['::achievementcriteria AS `ac` ON `ac`.`refAchievementId` = `a`.`id`', true], 'g' => '`a`.`id`'] ); - /* - todo: evaluate TC custom-data-tables: a*_criteria_data should be merged on installation - */ - - public function __construct($conditions = [], $miscData = null) + public function __construct(array $conditions = [], array $miscData = []) { parent::__construct($conditions, $miscData); @@ -33,25 +30,17 @@ class AchievementList extends BaseType return; // post processing - $rewards = DB::World()->select(' - SELECT - ar.ID AS ARRAY_KEY, ar.TitleA, ar.TitleH, ar.ItemID, ar.Sender AS sender, ar.MailTemplateID, - ar.Subject AS subject_loc0, IFNULL(arl2.Subject, "") AS subject_loc2, IFNULL(arl3.Subject, "") AS subject_loc3, IFNULL(arl4.Subject, "") AS subject_loc4, IFNULL(arl6.Subject, "") AS subject_loc6, IFNULL(arl8.Subject, "") AS subject_loc8, - ar.Body AS text_loc0, IFNULL(arl2.Body, "") AS text_loc2, IFNULL(arl3.Body, "") AS text_loc3, IFNULL(arl4.Body, "") AS text_loc4, IFNULL(arl6.Body, "") AS text_loc6, IFNULL(arl8.Body, "") AS text_loc8 - FROM - achievement_reward ar - LEFT JOIN - achievement_reward_locale arl2 ON arl2.ID = ar.ID AND arl2.Locale = "frFR" - LEFT JOIN - achievement_reward_locale arl3 ON arl3.ID = ar.ID AND arl3.Locale = "deDE" - LEFT JOIN - achievement_reward_locale arl4 ON arl4.ID = ar.ID AND arl4.Locale = "zhCN" - LEFT JOIN - achievement_reward_locale arl6 ON arl6.ID = ar.ID AND arl6.Locale = "esES" - LEFT JOIN - achievement_reward_locale arl8 ON arl8.ID = ar.ID AND arl8.Locale = "ruRU" - WHERE - ar.ID IN (?a)', + $rewards = DB::World()->selectAssoc( + 'SELECT ar.`ID` AS ARRAY_KEY, ar.`TitleA`, ar.`TitleH`, ar.`ItemID`, ar.`Sender` AS "sender", ar.`MailTemplateID`, + ar.`Subject` AS "subject_loc0", IFNULL(arl2.`Subject`, "") AS "subject_loc2", IFNULL(arl3.`Subject`, "") AS "subject_loc3", IFNULL(arl4.`Subject`, "") AS "subject_loc4", IFNULL(arl6.`Subject`, "") AS "subject_loc6", IFNULL(arl8.`Subject`, "") AS "subject_loc8", + ar.`Body` AS "text_loc0", IFNULL(arl2.`Body`, "") AS "text_loc2", IFNULL(arl3.`Body`, "") AS "text_loc3", IFNULL(arl4.`Body`, "") AS "text_loc4", IFNULL(arl6.`Body`, "") AS "text_loc6", IFNULL(arl8.`Body`, "") AS "text_loc8" + FROM achievement_reward ar + LEFT JOIN achievement_reward_locale arl2 ON arl2.`ID` = ar.`ID` AND arl2.`Locale` = "frFR" + LEFT JOIN achievement_reward_locale arl3 ON arl3.`ID` = ar.`ID` AND arl3.`Locale` = "deDE" + LEFT JOIN achievement_reward_locale arl4 ON arl4.`ID` = ar.`ID` AND arl4.`Locale` = "zhCN" + LEFT JOIN achievement_reward_locale arl6 ON arl6.`ID` = ar.`ID` AND arl6.`Locale` = "esES" + LEFT JOIN achievement_reward_locale arl8 ON arl8.`ID` = ar.`ID` AND arl8.`Locale` = "ruRU" + WHERE ar.`ID` IN %in', $this->getFoundIDs() ); @@ -68,13 +57,13 @@ class AchievementList extends BaseType if ($rewards[$_id]['MailTemplateID']) { // using class Loot creates an inifinite loop cirling between Loot, ItemList and SpellList or something - // $mailSrc = new Loot(); - // $mailSrc->getByContainer(LOOT_MAIL, $rewards[$_id]['MailTemplateID']); + // $mailSrc = new LootByContainer(); + // $mailSrc->getByContainer(Loot::MAIL, $rewards[$_id]['MailTemplateID']); // foreach ($mailSrc->iterate() as $loot) // $_curTpl['rewards'][] = [Type::ITEM, $loot['id']]; // lets just assume for now, that mailRewards for achievements do not contain references - $mailRew = DB::World()->selectCol('SELECT Item FROM mail_loot_template WHERE Reference <= 0 AND entry = ?d', $rewards[$_id]['MailTemplateID']); + $mailRew = DB::World()->selectCol('SELECT `Item` FROM mail_loot_template WHERE `Reference` <= 0 AND `entry` = %i', $rewards[$_id]['MailTemplateID']); foreach ($mailRew AS $mr) $_curTpl['rewards'][] = [Type::ITEM, $mr]; } @@ -96,7 +85,7 @@ class AchievementList extends BaseType } } - public function getJSGlobals($addMask = GLOBALINFO_ANY) + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array { $data = []; @@ -113,7 +102,7 @@ class AchievementList extends BaseType return $data; } - public function getListviewData($addInfoMask = 0x0) + public function getListviewData(int $addInfoMask = 0x0) : array { $data = []; @@ -146,12 +135,12 @@ class AchievementList extends BaseType } // only for current template - public function getCriteria() + public function getCriteria() : array { if (isset($this->criteria[$this->id])) return $this->criteria[$this->id]; - $result = DB::Aowow()->Select('SELECT * FROM ?_achievementcriteria WHERE `refAchievementId` = ?d ORDER BY `order` ASC', $this->curTpl['refAchievement'] ?: $this->id); + $result = DB::Aowow()->selectAssoc('SELECT * FROM ::achievementcriteria WHERE `refAchievementId` = %i ORDER BY `order` ASC', $this->curTpl['refAchievement'] ?: $this->id); if (!$result) return []; @@ -160,7 +149,7 @@ class AchievementList extends BaseType return $this->criteria[$this->id]; } - public function renderTooltip() + public function renderTooltip() : ?string { $criteria = $this->getCriteria(); $tmp = []; @@ -187,16 +176,12 @@ class AchievementList extends BaseType $qty = (int)$crt['value2']; // we could show them, but the tooltips are cluttered - if (($crt['completionFlags'] & ACHIEVEMENT_CRITERIA_FLAG_HIDDEN) && User::$perms <= 0) + if (($crt['completionFlags'] & ACHIEVEMENT_CRITERIA_FLAG_HIDDEN) && User::isInGroup(U_GROUP_STAFF)) continue; $crtName = Util::localizedString($crt, 'name'); switch ($crt['type']) { - // link to title - todo (low): crosslink - case ACHIEVEMENT_CRITERIA_TYPE_EARNED_PVP_TITLE: - $crtName = Util::ucFirst(Lang::game('title')).Lang::main('colon').$crtName; - break; // link to quest case ACHIEVEMENT_CRITERIA_TYPE_COMPLETE_QUEST: if (!$crtName) @@ -257,12 +242,15 @@ class AchievementList extends BaseType return $x; } - public function getSourceData() + public function getSourceData(int $id = 0) : array { $data = []; foreach ($this->iterate() as $__) { + if ($id && $id != $this->id) + continue; + $data[$this->id] = array( "n" => $this->getField('name', true), "s" => $this->curTpl['faction'], @@ -278,11 +266,12 @@ class AchievementList extends BaseType class AchievementListFilter extends Filter { - - protected $enums = array( + protected string $type = 'achievements'; + protected static array $enums = array( + 4 => parent::ENUM_ZONE, // location 11 => array( 327 => 160, // Lunar Festival - 335 => 187, // Love is in the Air + 423 => 187, // Love is in the Air 181 => 159, // Noblegarden 201 => 163, // Children's Week 341 => 161, // Midsummer Fire Festival @@ -292,8 +281,8 @@ class AchievementListFilter extends Filter 141 => 156, // Feast of Winter Veil 409 => -3456, // Day of the Dead 398 => -3457, // Pirates' Day - FILTER_ENUM_ANY => true, - FILTER_ENUM_NONE => false, + parent::ENUM_ANY => true, + parent::ENUM_NONE => false, 283 => -1, // valid events without achievements 285 => -1, 353 => -1, 420 => -1, 400 => -1, 284 => -1, 374 => -1, @@ -301,116 +290,100 @@ class AchievementListFilter extends Filter ) ); - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 2 => [FILTER_CR_BOOLEAN, 'reward_loc0', true ], // givesreward - 3 => [FILTER_CR_STRING, 'reward', STR_LOCALIZED ], // rewardtext - 4 => [FILTER_CR_NYI_PH, null, 1, ], // location [enum] - 5 => [FILTER_CR_CALLBACK, 'cbSeries', ACHIEVEMENT_CU_FIRST_SERIES, null], // first in series [yn] - 6 => [FILTER_CR_CALLBACK, 'cbSeries', ACHIEVEMENT_CU_LAST_SERIES, null], // last in series [yn] - 7 => [FILTER_CR_BOOLEAN, 'chainId', ], // partseries - 9 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true], // id - 10 => [FILTER_CR_STRING, 'ic.name', ], // icon - 11 => [FILTER_CR_CALLBACK, 'cbRelEvent', null, null], // related event [enum] - 14 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 15 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 16 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 18 => [FILTER_CR_STAFFFLAG, 'flags', ] // flags + protected static array $genericFilter = array( + 2 => [parent::CR_BOOLEAN, 'reward_loc0', true ], // givesreward + 3 => [parent::CR_STRING, 'reward', STR_LOCALIZED ], // rewardtext + 4 => [parent::CR_NYI_PH, null, 1, ], // location [enum] + 5 => [parent::CR_CALLBACK, 'cbSeries', ACHIEVEMENT_CU_FIRST_SERIES, null], // first in series [yn] + 6 => [parent::CR_CALLBACK, 'cbSeries', ACHIEVEMENT_CU_LAST_SERIES, null], // last in series [yn] + 7 => [parent::CR_BOOLEAN, 'chainId', ], // partseries + 9 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true], // id + 10 => [parent::CR_STRING, 'ic.name', ], // icon + 11 => [parent::CR_CALLBACK, 'cbRelEvent', null, null], // related event [enum] + 14 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 15 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 16 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 18 => [parent::CR_STAFFFLAG, 'flags', ] // flags ); - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_RANGE, [2, 18], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 99999]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/[\p{C};:%\\\\]/ui', true ], // criteria values - only printable chars, no delimiters - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name / description - only printable chars, no delimiter - 'ex' => [FILTER_V_EQUAL, 'on', false], // extended name search - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'si' => [FILTER_V_LIST, [1, 2, 3, -1, -2], false], // side - 'minpt' => [FILTER_V_RANGE, [1, 99], false], // required level min - 'maxpt' => [FILTER_V_RANGE, [1, 99], false] // required level max + protected static array $inputFields = array( + 'cr' => [parent::V_RANGE, [2, 18], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 99999]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_CRV, true ], // criteria values - only printable chars, no delimiters + 'na' => [parent::V_NAME, false, false], // name / description - only printable chars, no delimiter + 'ex' => [parent::V_EQUAL, 'on', false], // extended name search + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'si' => [parent::V_LIST, [SIDE_ALLIANCE, SIDE_HORDE, SIDE_BOTH, -SIDE_ALLIANCE, -SIDE_HORDE], false], // side + 'minpt' => [parent::V_RANGE, [1, 99], false], // required level min + 'maxpt' => [parent::V_RANGE, [1, 99], false] // required level max ); - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() + protected function createSQLForValues() : array { $parts = []; - $_v = &$this->fiData['v']; + $_v = &$this->values; // name ex: +description, +rewards - if (isset($_v['na'])) + if ($_v['na']) { $_ = []; - if (isset($_v['ex']) && $_v['ex'] == 'on') - $_ = $this->modularizeString(['name_loc'.User::$localeId, 'reward_loc'.User::$localeId, 'description_loc'.User::$localeId]); + if ($_v['ex'] == 'on') + $_ = $this->buildLikeLookup([['na', 'name_loc'.Lang::getLocale()->value], ['na', 'reward_loc'.Lang::getLocale()->value], ['na', 'description_loc'.Lang::getLocale()->value]]); else - $_ = $this->modularizeString(['name_loc'.User::$localeId]); + $_ = $this->buildLikeLookup([['na', 'name_loc'.Lang::getLocale()->value]]); if ($_) $parts[] = $_; } // points min - if (isset($_v['minpt'])) + if ($_v['minpt']) $parts[] = ['points', $_v['minpt'], '>=']; // points max - if (isset($_v['maxpt'])) + if ($_v['maxpt']) $parts[] = ['points', $_v['maxpt'], '<=']; // faction (side) - if (isset($_v['si'])) + if ($_v['si']) { - switch ($_v['si']) + $parts[] = match ($_v['si']) { - case -1: // faction, exclusive both - case -2: - $parts[] = ['faction', -$_v['si']]; - break; - case 1: // faction, inclusive both - case 2: - case 3: // both - $parts[] = ['faction', $_v['si'], '&']; - break; - } + -SIDE_ALLIANCE, // equals faction + -SIDE_HORDE => ['faction', -$_v['si']], + SIDE_ALLIANCE, // includes faction + SIDE_HORDE, + SIDE_BOTH => ['faction', $_v['si'], '&'] + }; } return $parts; } - protected function cbRelEvent($cr, $value) + protected function cbRelEvent(int $cr, int $crs, string $crv) : ?array { - if (!isset($this->enums[$cr[0]][$cr[1]])) - return false; + if (!isset(self::$enums[$cr][$crs])) + return null; - $_ = $this->enums[$cr[0]][$cr[1]]; + $_ = self::$enums[$cr][$crs]; if (is_int($_)) return ($_ > 0) ? ['category', $_] : ['id', abs($_)]; else { - $ids = array_filter($this->enums[$cr[0]], function($x) { return is_int($x) && $x > 0; }); + $ids = array_filter(self::$enums[$cr], fn($x) => is_int($x) && $x > 0); return ['category', $ids, $_ ? null : '!']; } - return false; + return null; } - protected function cbSeries($cr, $value) + protected function cbSeries(int $cr, int $crs, string $crv, int $seriesFlag) : ?array { - if ($this->int2Bool($cr[1])) - return $cr[1] ? ['AND', ['chainId', 0, '!'], ['cuFlags', $value, '&']] : ['AND', ['chainId', 0, '!'], [['cuFlags', $value, '&'], 0]]; + if ($this->int2Bool($crs)) + return $crs ? [DB::AND, ['chainId', 0, '!'], ['cuFlags', $seriesFlag, '&']] : [DB::AND, ['chainId', 0, '!'], [['cuFlags', $seriesFlag, '&'], 0]]; - return false; + return null; } } diff --git a/includes/dbtypes/areatrigger.class.php b/includes/dbtypes/areatrigger.class.php new file mode 100644 index 00000000..527afb25 --- /dev/null +++ b/includes/dbtypes/areatrigger.class.php @@ -0,0 +1,99 @@ + [['s']], // guid < 0 are teleporter targets, so exclude them here + 's' => ['j' => ['::spawns s ON s.`type` = 503 AND s.`typeId` = a.`id` AND s.`guid` > 0', true], 's' => ', GROUP_CONCAT(s.`areaId`) AS "areaId"', 'g' => 'a.`id`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + foreach ($this->iterate() as $id => &$_curTpl) + if (!$_curTpl['name']) + $_curTpl['name'] = Lang::areatrigger('unnamed', [$id]); + } + + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->SelectRow('SELECT `name` AS "name_loc0" FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n, callback: fn($x) => $x ?: Lang::areatrigger('unnamed', [$id])); + return null; + } + + public function getListviewData() : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->curTpl['id'], + 'type' => $this->curTpl['type'], + 'name' => $this->curTpl['name'], + ); + + if ($_ = $this->curTpl['areaId']) + $data[$this->id]['location'] = explode(',', $_); + } + + return $data; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array { return []; } + + public function renderTooltip() : ?string { return null; } +} + +class AreaTriggerListFilter extends Filter +{ + protected string $type = 'areatrigger'; + protected static array $genericFilter = array( + 2 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT] // id + ); + + // fieldId => [checkType, checkValue[, fieldIsArray]] + protected static array $inputFields = array( + 'cr' => [parent::V_LIST, [2], true ], // criteria ids + 'crs' => [parent::V_RANGE, [1, 6], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_INT, true ], // criteria values - all criteria are numeric here + 'na' => [parent::V_NAME, false, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'ty' => [parent::V_RANGE, [0, 5], true ] // types + ); + + protected function createSQLForValues() : array + { + $parts = []; + $_v = &$this->values; + + // name [str] + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'name']])) + $parts[] = $_; + + // type [list] + if ($_v['ty']) + $parts[] = ['type', $_v['ty']]; + + return $parts; + } +} + +?> diff --git a/includes/dbtypes/arenateam.class.php b/includes/dbtypes/arenateam.class.php new file mode 100644 index 00000000..9103da0c --- /dev/null +++ b/includes/dbtypes/arenateam.class.php @@ -0,0 +1,368 @@ +iterate() as $__) + { + $data[$this->id] = array( + 'name' => $this->curTpl['name'], + 'realm' => Profiler::urlize($this->curTpl['realmName'], true), + 'realmname' => $this->curTpl['realmName'], + // 'battlegroup' => Profiler::urlize($this->curTpl['battlegroup']), // was renamed to subregion somewhere around cata release + // 'battlegroupname' => $this->curTpl['battlegroup'], + 'region' => Profiler::urlize($this->curTpl['region']), + 'faction' => $this->curTpl['faction'], + 'size' => $this->curTpl['type'], + 'rank' => $this->curTpl['rank'], + 'wins' => $this->curTpl['seasonWins'], + 'games' => $this->curTpl['seasonGames'], + 'rating' => $this->curTpl['rating'], + 'members' => $this->curTpl['members'] + ); + } + + return $data; + } + + // plz dont.. + public static function getName(int|string $id) : ?LocString { return null; } + + public function renderTooltip() : ?string { return null; } + public function getJSGlobals(int $addMask = 0) : array { return []; } +} + + +class ArenaTeamListFilter extends Filter +{ + use TrProfilerFilter; + + protected string $type = 'arenateams'; + protected static array $genericFilter = []; + protected static array $inputFields = array( + 'ex' => [parent::V_EQUAL, 'on', false], // only match exact - must be defined before 'na' as it's test relies on 'ex's value + 'na' => [parent::V_NAME, true, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'si' => [parent::V_LIST, [1, 2], false], // side + 'sz' => [parent::V_LIST, [2, 3, 5], false], // tema size + 'rg' => [parent::V_CALLBACK, 'cbRegionCheck', false], // region + 'bg' => [parent::V_EQUAL, null, false], // battlegroup - unsued here, but var expected by template + 'sv' => [parent::V_CALLBACK, 'cbServerCheck', false] // server + ); + + public array $extraOpts = []; + + protected function createSQLForValues() : array + { + $parts = []; + $_v = $this->values; + + // region (rg), battlegroup (bg) and server (sv) are passed to ArenaTeamList as miscData and handled there + + // name [str] + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'at.name']], $_v['ex'] == 'on')) + $parts[] = $_; + + // side [list] + if ($_v['si'] == SIDE_ALLIANCE) + $parts[] = ['c.race', ChrRace::fromMask(ChrRace::MASK_ALLIANCE)]; + else if ($_v['si'] == SIDE_HORDE) + $parts[] = ['c.race', ChrRace::fromMask(ChrRace::MASK_HORDE)]; + + // size [int] + if ($_v['sz']) + $parts[] = ['at.type', $_v['sz']]; + + return $parts; + } +} + + +class RemoteArenaTeamList extends ArenaTeamList +{ + protected string $queryBase = 'SELECT `at`.*, `at`.`arenaTeamId` AS ARRAY_KEY FROM arena_team at'; + protected array $queryOpts = array( + 'at' => [['atm', 'c'], 'g' => 'ARRAY_KEY', 'o' => 'rating DESC'], + 'atm' => ['j' => 'arena_team_member atm ON atm.`arenaTeamId` = at.`arenaTeamId`'], + 'c' => ['j' => 'characters c ON c.`guid` = atm.`guid` AND c.`deleteInfos_Account` IS NULL AND c.`level` <= 80 AND (c.`extra_flags` & '.Profiler::CHAR_GMFLAGS.') = 0', 's' => ', BIT_OR(IF(c.`race` IN (1, 3, 4, 7, 11), 1, 2)) - 1 AS "faction"'] + ); + + private array $members = []; + private array $rankOrder = []; + + public function __construct(array $conditions = [], array $miscData = []) + { + // select DB by realm + if (!$this->selectRealms($miscData)) + { + trigger_error('RemoteArenaTeamList::__construct - cannot access any realm.', E_USER_WARNING); + return; + } + + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + // ranks in DB are inaccurate. recalculate from rating (fetched as DESC from DB) + foreach ($this->dbNames as $rId => $__) + foreach ([2, 3, 5] as $type) + $this->rankOrder[$rId][$type] = DB::Characters($rId)->selectCol('SELECT `arenaTeamId` FROM arena_team WHERE `type` = %i ORDER BY `rating` DESC', $type); + + reset($this->dbNames); // only use when querying single realm + $realms = Profiler::getRealms(); + $distrib = []; + + // post processing + foreach ($this->iterate() as $guid => &$curTpl) + { + // battlegroup + $curTpl['battlegroup'] = Cfg::get('BATTLEGROUP'); + + // realm, rank + $r = explode(':', $guid); + if (!empty($realms[$r[0]])) + { + $curTpl['realm'] = $r[0]; + $curTpl['realmName'] = $realms[$r[0]]['name']; + $curTpl['region'] = $realms[$r[0]]['region']; + $curTpl['rank'] = array_search($curTpl['arenaTeamId'], $this->rankOrder[$r[0]][$curTpl['type']]) + 1; + } + else + { + trigger_error('arena team #'.$guid.' belongs to nonexistent realm #'.$r, E_USER_WARNING); + unset($this->templates[$guid]); + continue; + } + + // empty name + if (!$curTpl['name']) + { + trigger_error('arena team #'.$guid.' on realm #'.$r.' has empty name.', E_USER_WARNING); + unset($this->templates[$guid]); + continue; + } + + // team members + $this->members[$r[0]][$r[1]] = $r[1]; + + // equalize distribution + if (empty($distrib[$curTpl['realm']])) + $distrib[$curTpl['realm']] = 1; + else + $distrib[$curTpl['realm']]++; + } + + // get team members + foreach ($this->members as $realmId => &$teams) + $teams = DB::Characters($realmId)->selectAssoc( + 'SELECT at.`arenaTeamId` AS ARRAY_KEY, c.`guid` AS ARRAY_KEY2, c.`name` AS "0", c.`class` AS "1", IF(at.`captainguid` = c.`guid`, 1, 0) AS "2" + FROM arena_team at + JOIN arena_team_member atm ON atm.`arenaTeamId` = at.`arenaTeamId` JOIN characters c ON c.`guid` = atm.`guid` + WHERE at.`arenaTeamId` IN %in AND c.`deleteInfos_Account` IS NULL AND c.`level` <= %i AND (c.`extra_flags` & %i) = 0', + $teams, MAX_LEVEL, Profiler::CHAR_GMFLAGS + ); + + // equalize subject distribution across realms + $limit = 0; + foreach ($conditions as $c) + if (is_numeric($c)) + $limit = max(0, (int)$c); + + if (!$limit) // int:0 means unlimited, so skip early + return; + + $total = array_sum($distrib); + foreach ($distrib as &$d) + $d = ceil($limit * $d / $total); + + foreach ($this->iterate() as $guid => &$curTpl) + { + if ($limit <= 0 || $distrib[$curTpl['realm']] <= 0) + { + unset($this->templates[$guid]); + continue; + } + + $r = explode(':', $guid); + if (isset($this->members[$r[0]][$r[1]])) + $curTpl['members'] = array_values($this->members[$r[0]][$r[1]]); // [name, classId, isCaptain] + + $distrib[$curTpl['realm']]--; + $limit--; + } + } + + public function initializeLocalEntries() : void + { + if (!$this->templates) + return; + + $profiles = []; + // init members for tooltips + foreach ($this->members as $realmId => $teams) + { + $gladiators = []; + foreach ($teams as $team) + $gladiators = array_merge($gladiators, array_keys($team)); + + $profiles[$realmId] = new RemoteProfileList(array(['c.guid', $gladiators]), ['sv' => $realmId]); + + if (!$profiles[$realmId]->error) + $profiles[$realmId]->initializeLocalEntries(); + } + + $data = []; + foreach ($this->iterate() as $guid => $__) + { + $data['realm'][$guid] = $this->getField('realm'); + $data['realmGUID'][$guid] = $this->getField('arenaTeamId'); + $data['name'][$guid] = $this->getField('name'); + $data['nameUrl'][$guid] = Profiler::urlize($this->getField('name')); + $data['type'][$guid] = $this->getField('type'); + $data['rating'][$guid] = $this->getField('rating'); + $data['stub'][$guid] = 1; + } + + // basic arena team data + DB::Aowow()->qry('INSERT INTO ::profiler_arena_team %m ON DUPLICATE KEY UPDATE `id` = `id`', $data); + + // merge back local ids + $localIds = DB::Aowow()->selectCol('SELECT CONCAT(`realm`, ":", `realmGUID`) AS ARRAY_KEY, `id` FROM ::profiler_arena_team WHERE `realm` IN %in AND `realmGUID` IN %in', + $data['realm'], $data['realmGUID'] + ); + + foreach ($this->iterate() as $guid => &$_curTpl) + if (isset($localIds[$guid])) + $_curTpl['id'] = $localIds[$guid]; + + + // profiler_arena_team_member requires profiles and arena teams to be filled + foreach ($this->members as $realmId => $teams) + { + if (empty($profiles[$realmId])) + continue; + + $memberData = []; + foreach ($teams as $teamId => $team) + { + $clearMembers = []; + foreach ($team as $memberId => $member) + { + $clearMembers[] = $profiles[$realmId]->getEntry($realmId.':'.$memberId)['id']; + + $memberData['arenaTeamId'][] = $localIds[$realmId.':'.$teamId]; + $memberData['profileId'][] = $profiles[$realmId]->getEntry($realmId.':'.$memberId)['id']; + $memberData['captain'][] = $member[2]; + } + + // Delete members from other teams of the same type + DB::Aowow()->qry( + 'DELETE atm + FROM ::profiler_arena_team_member atm + JOIN ::profiler_arena_team at ON atm.`arenaTeamId` = at.`id` AND at.`type` = %i + WHERE atm.`profileId` IN %in', + $data['type'][$realmId.':'.$teamId] ?? 0, + $clearMembers + ); + } + + DB::Aowow()->qry('INSERT INTO ::profiler_arena_team_member %m ON DUPLICATE KEY UPDATE `profileId` = `profileId`', $memberData); + } + } +} + + +class LocalArenaTeamList extends ArenaTeamList +{ + protected string $queryBase = 'SELECT at.*, at.id AS ARRAY_KEY FROM ::profiler_arena_team at'; + protected array $queryOpts = array( + 'at' => [['atm', 'c'], 'g' => 'ARRAY_KEY', 'o' => 'rating DESC'], + 'atm' => ['j' => '::profiler_arena_team_member atm ON atm.`arenaTeamId` = at.`id`'], + 'c' => ['j' => '::profiler_profiles c ON c.`id` = atm.`profileId`', 's' => ', BIT_OR(IF(c.`race` IN (1, 3, 4, 7, 11), 1, 2)) - 1 AS "faction"'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + $realms = Profiler::getRealms(); + + // graft realm selection from miscData onto conditions + if (isset($miscData['sv'])) + $realms = array_filter($realms, fn($x) => Profiler::urlize($x['name']) == Profiler::urlize($miscData['sv'])); + + if (isset($miscData['rg'])) + $realms = array_filter($realms, fn($x) => $x['region'] == $miscData['rg']); + + if (!$realms) + { + trigger_error('LocalArenaTeamList::__construct - cannot access any realm.', E_USER_WARNING); + return; + } + + if ($conditions) + { + array_unshift($conditions, DB::AND); + $conditions = [DB::AND, ['realm', array_keys($realms)], $conditions]; + } + else + $conditions = [['realm', array_keys($realms)]]; + + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + // post processing + $members = DB::Aowow()->selectAssoc( + 'SELECT `arenaTeamId` AS ARRAY_KEY, p.`id` AS ARRAY_KEY2, p.`name` AS "0", p.`class` AS "1", atm.`captain` AS "2" + FROM ::profiler_arena_team_member atm + JOIN ::profiler_profiles p ON p.`id` = atm.`profileId` + WHERE `arenaTeamId` IN %in', + $this->getFoundIDs() + ); + + foreach ($this->iterate() as $id => &$curTpl) + { + if ($curTpl['realm'] && !isset($realms[$curTpl['realm']])) + continue; + + if (isset($realms[$curTpl['realm']])) + { + $curTpl['realmName'] = $realms[$curTpl['realm']]['name']; + $curTpl['region'] = $realms[$curTpl['realm']]['region']; + } + + // battlegroup + $curTpl['battlegroup'] = Cfg::get('BATTLEGROUP'); + + $curTpl['members'] = array_values($members[$id]); + } + } + + public function getProfileUrl() : string + { + $url = '?arena-team='; + + return $url.implode('.', array( + $this->getField('region'), + Profiler::urlize($this->getField('realmName'), true), + Profiler::urlize($this->getField('name')) + )); + } +} + + +?> diff --git a/includes/types/charclass.class.php b/includes/dbtypes/charclass.class.php similarity index 58% rename from includes/types/charclass.class.php rename to includes/dbtypes/charclass.class.php index de908059..5c6db072 100644 --- a/includes/types/charclass.class.php +++ b/includes/dbtypes/charclass.class.php @@ -1,26 +1,32 @@ [['ic']], + 'ic' => ['j' => ['::icons ic ON ic.`id` = c.`iconId`', true], 's' => ', ic.`name` AS "iconString"'] + ); - public function __construct($conditions = []) + public function __construct($conditions = [], array $miscData = []) { - parent::__construct($conditions); + parent::__construct($conditions, $miscData); foreach ($this->iterate() as $k => &$_curTpl) $_curTpl['skills'] = explode(' ', $_curTpl['skills']); } - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -46,7 +52,7 @@ class CharClassList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -56,8 +62,7 @@ class CharClassList extends BaseType return $data; } - public function addRewardsToJScript(&$ref) { } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } ?> diff --git a/includes/types/charrace.class.php b/includes/dbtypes/charrace.class.php similarity index 52% rename from includes/types/charrace.class.php rename to includes/dbtypes/charrace.class.php index 1b654197..c790aafe 100644 --- a/includes/types/charrace.class.php +++ b/includes/dbtypes/charrace.class.php @@ -1,18 +1,25 @@ [['ic0', 'ic1']], + 'ic0' => ['j' => ['::icons ic0 ON ic0.`id` = r.`iconId0`', true], 's' => ', ic0.`name` AS "iconStringMale"'], + 'ic1' => ['j' => ['::icons ic1 ON ic1.`id` = r.`iconId1`', true], 's' => ', ic1.`name` AS "iconStringFemale"'] + ); - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -35,7 +42,7 @@ class CharRaceList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -45,8 +52,7 @@ class CharRaceList extends BaseType return $data; } - public function addRewardsToJScript(&$ref) { } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } ?> diff --git a/includes/dbtypes/creature.class.php b/includes/dbtypes/creature.class.php new file mode 100644 index 00000000..6c259153 --- /dev/null +++ b/includes/dbtypes/creature.class.php @@ -0,0 +1,578 @@ + [['ft', 'qse', 'dct1', 'dct2', 'dct3'], 's' => ', IFNULL(dct1.`id`, IFNULL(dct2.`id`, IFNULL(dct3.`id`, 0))) AS "parentId", IFNULL(dct1.`name_loc0`, IFNULL(dct2.`name_loc0`, IFNULL(dct3.`name_loc0`, ""))) AS "parent_loc0", IFNULL(dct1.`name_loc2`, IFNULL(dct2.`name_loc2`, IFNULL(dct3.`name_loc2`, ""))) AS "parent_loc2", IFNULL(dct1.`name_loc3`, IFNULL(dct2.`name_loc3`, IFNULL(dct3.`name_loc3`, ""))) AS "parent_loc3", IFNULL(dct1.`name_loc4`, IFNULL(dct2.`name_loc4`, IFNULL(dct3.`name_loc4`, ""))) AS "parent_loc4", IFNULL(dct1.`name_loc6`, IFNULL(dct2.`name_loc6`, IFNULL(dct3.`name_loc6`, ""))) AS "parent_loc6", IFNULL(dct1.name_loc8, IFNULL(dct2.`name_loc8`, IFNULL(dct3.`name_loc8`, ""))) AS "parent_loc8", IF(dct1.`difficultyEntry1` = ct.`id`, 1, IF(dct2.`difficultyEntry2` = ct.`id`, 2, IF(dct3.`difficultyEntry3` = ct.`id`, 3, 0))) AS "difficultyMode"'], + 'nml' => ['j' => ['::creature_search nml ON nml.`id` = ct.`id` AND nml.`locale` = DB_LOC_I']], + 'dct1' => ['j' => ['::creature dct1 ON ct.`cuFlags` & 0x02 AND dct1.`difficultyEntry1` = ct.`id`', true]], + 'dct2' => ['j' => ['::creature dct2 ON ct.`cuFlags` & 0x02 AND dct2.`difficultyEntry2` = ct.`id`', true]], + 'dct3' => ['j' => ['::creature dct3 ON ct.`cuFlags` & 0x02 AND dct3.`difficultyEntry3` = ct.`id`', true]], + 'ft' => ['j' => '::factiontemplate ft ON ft.`id` = ct.`faction`', 's' => ', ft.`factionId`, IFNULL(ft.`A`, 0) AS "A", IFNULL(ft.`H`, 0) AS "H"'], + 'qse' => ['j' => ['::quests_startend qse ON qse.`type` = 1 AND qse.`typeId` = ct.id', true], 's' => ', IF(MIN(qse.`method`) = 1 OR MAX(qse.`method`) = 3, 1, 0) AS "startsQuests", IF(MIN(qse.`method`) = 2 OR MAX(qse.`method`) = 3, 1, 0) AS "endsQuests"', 'g' => 'ct.`id`'], + 'qt' => ['j' => '::quests qt ON qse.`questId` = qt.`id`'], + 's' => ['j' => ['::spawns s ON s.`type` = 1 AND s.`typeId` = ct.`id`', true]] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + // post processing + foreach ($this->iterate() as $_id => &$curTpl) + { + // check for attackspeeds + if (!$curTpl['atkSpeed']) + $curTpl['atkSpeed'] = 2.0; + else + $curTpl['atkSpeed'] /= 1000; + + if (!$curTpl['rngAtkSpeed']) + $curTpl['rngAtkSpeed'] = 2.0; + else + $curTpl['rngAtkSpeed'] /= 1000; + } + } + + public function renderTooltip() : ?string + { + if (!$this->curTpl) + return null; + + $level = '??'; + $type = $this->curTpl['type']; + $row3 = [Lang::game('level')]; + $fam = $this->curTpl['family']; + + if (!($this->curTpl['typeFlags'] & NPC_TYPEFLAG_BOSS_MOB)) + { + $level = $this->curTpl['minLevel']; + if ($level != $this->curTpl['maxLevel']) + $level .= ' - '.$this->curTpl['maxLevel']; + } + else + $level = '??'; + + $row3[] = $level; + + if ($type) + $row3[] = Lang::game('ct', $type); + + if ($_ = Lang::npc('rank', $this->curTpl['rank'])) + $row3[] = '('.$_.')'; + + $x = ''; + $x .= ''; + + if ($sn = $this->getField('subname', true)) + $x .= ''; + + $x .= ''; + + if ($type == 1 && $fam) // 1: Beast + $x .= ''; + + $fac = new FactionList(array([['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0], ['id', (int)$this->getField('factionId')])); + if (!$fac->error) + $x .= ''; + + $x .= '
'.Util::htmlEscape($this->getField('name', true)).'
'.Util::htmlEscape($sn).'
'.implode(' ', $row3).'
'.Lang::game('fa', $fam).'
'.$fac->getField('name', true).'
'; + + return $x; + } + + public function getRandomModelId() : int + { + // dwarf?? [null, 30754, 30753, 30755, 30736] + // totems use hardcoded models, tauren model is base + $totems = [null, 4589, 4588, 4587, 4590]; // slot => modelId + $data = []; + + for ($i = 1; $i < 5; $i++) + if ($_ = $this->curTpl['displayId'.$i]) + $data[] = $_; + + if (count($data) == 1 && ($slotId = array_search($data[0], $totems))) + $data = DB::World()->selectCol('SELECT `DisplayId` FROM player_totem_model WHERE `TotemSlot` = %i', $slotId); + + return !$data ? 0 : $data[array_rand($data)]; + } + + public function getBaseStats(string $type) : array + { + // i'm aware of the BaseVariance/RangedVariance fields ... i'm just totaly unsure about the whole damage calculation + switch ($type) + { + case 'health': + $hMin = $this->getField('healthMin'); + $hMax = $this->getField('healthMax'); + return [$hMin, $hMax]; + case 'power': + $mMin = $this->getField('manaMin'); + $mMax = $this->getField('manaMax'); + return [$mMin, $mMax]; + case 'armor': + $aMin = $this->getField('armorMin'); + $aMax = $this->getField('armorMax'); + return [$aMin, $aMax]; + case 'melee': + $mleMin = ($this->getField('dmgMin') + ($this->getField('mleAtkPwrMin') / 14)) * $this->getField('dmgMultiplier') * $this->getField('atkSpeed'); + $mleMax = ($this->getField('dmgMax') * 1.5 + ($this->getField('mleAtkPwrMax') / 14)) * $this->getField('dmgMultiplier') * $this->getField('atkSpeed'); + return [$mleMin, $mleMax]; + case 'ranged': + $rngMin = ($this->getField('dmgMin') + ($this->getField('rngAtkPwrMin') / 14)) * $this->getField('dmgMultiplier') * $this->getField('rngAtkSpeed'); + $rngMax = ($this->getField('dmgMax') * 1.5 + ($this->getField('rngAtkPwrMax') / 14)) * $this->getField('dmgMultiplier') * $this->getField('rngAtkSpeed'); + return [$rngMin, $rngMax]; + case 'resistance': + $r = []; + for ($i = SPELL_SCHOOL_HOLY; $i < SPELL_SCHOOL_ARCANE+1; $i++) + $r[$i] = $this->getField('resistance'.$i); + + return $r; + default: + return []; + } + } + + public function isBoss() : bool + { + return ($this->curTpl['cuFlags'] & NPC_CU_INSTANCE_BOSS) || ($this->curTpl['typeFlags'] & NPC_TYPEFLAG_BOSS_MOB && $this->curTpl['rank']); + } + + public function isMineable() : bool + { + return $this->curTpl['skinLootId'] && ($this->curTpl['typeFlags'] & NPC_TYPEFLAG_SKIN_WITH_MINING); + } + + public function isGatherable() : bool + { + return $this->curTpl['skinLootId'] && ($this->curTpl['typeFlags'] & NPC_TYPEFLAG_SKIN_WITH_HERBALISM); + } + + public function isSalvageable() : bool + { + return $this->curTpl['skinLootId'] && ($this->curTpl['typeFlags'] & NPC_TYPEFLAG_SKIN_WITH_ENGINEERING); + } + + public function getListviewData(int $addInfoMask = 0x0) : array + { + /* looks like this data differs per occasion + * + * NPCINFO_TAMEABLE (0x1): include texture & react + * NPCINFO_MODEL (0x2): + * NPCINFO_REP (0x4): include repreward + */ + + $data = []; + $rewRep = []; + + if ($addInfoMask & NPCINFO_REP && $this->getFoundIDs()) + { + $rewRep = DB::World()->selectCol( + 'SELECT `creature_id` AS ARRAY_KEY, `RewOnKillRepFaction1` AS ARRAY_KEY2, `RewOnKillRepValue1` FROM creature_onkill_reputation WHERE `creature_id` IN %in AND `RewOnKillRepFaction1` > 0 UNION + SELECT `creature_id` AS ARRAY_KEY, `RewOnKillRepFaction2` AS ARRAY_KEY2, `RewOnKillRepValue2` FROM creature_onkill_reputation WHERE `creature_id` IN %in AND `RewOnKillRepFaction2` > 0', + $this->getFoundIDs(), + $this->getFoundIDs() + ); + } + + + foreach ($this->iterate() as $__) + { + if ($addInfoMask & NPCINFO_MODEL) + { + $texStr = strtolower($this->curTpl['textureString']); + + if (isset($data[$texStr])) + { + if ($data[$texStr]['minLevel'] > $this->curTpl['minLevel']) + $data[$texStr]['minLevel'] = $this->curTpl['minLevel']; + + if ($data[$texStr]['maxLevel'] < $this->curTpl['maxLevel']) + $data[$texStr]['maxLevel'] = $this->curTpl['maxLevel']; + + $data[$texStr]['count']++; + } + else + $data[$texStr] = array( + 'family' => $this->curTpl['family'], + 'minLevel' => $this->curTpl['minLevel'], + 'maxLevel' => $this->curTpl['maxLevel'], + 'modelId' => $this->curTpl['modelId'], + 'displayId' => $this->curTpl['displayId1'], + 'skin' => $texStr, + 'count' => 1 + ); + } + else + { + $data[$this->id] = array( + 'family' => $this->curTpl['family'], + 'minlevel' => $this->curTpl['minLevel'], + 'maxlevel' => $this->curTpl['maxLevel'], + 'id' => $this->id, + 'boss' => $this->isBoss() ? 1 : 0, + 'classification' => $this->curTpl['rank'], + 'location' => $this->getSpawns(SPAWNINFO_ZONES), + 'name' => $this->getField('name', true), + 'type' => $this->curTpl['type'], + 'react' => [$this->curTpl['A'], $this->curTpl['H']], + ); + + + if ($this->getField('startsQuests')) + $data[$this->id]['hasQuests'] = 1; + + if ($_ = $this->getField('subname', true)) + $data[$this->id]['tag'] = $_; + + if ($addInfoMask & NPCINFO_TAMEABLE) // only first skin of first model ... we're omitting potentially 11 skins here .. but the lv accepts only one .. w/e + $data[$this->id]['skin'] = $this->curTpl['textureString']; + + if ($addInfoMask & NPCINFO_REP) + { + $data[$this->id]['reprewards'] = []; + if ($rewRep[$this->id]) + foreach ($rewRep[$this->id] as $fac => $val) + $data[$this->id]['reprewards'][] = [$fac, $val]; + } + } + } + + ksort($data); + return $data; + } + + public function getJSGlobals(int $addMask = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + $data[Type::NPC][$this->id] = ['name' => $this->getField('name', true)]; + + return $data; + } + + public function getSourceData(int $id = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if ($id && $id != $this->id) + continue; + + $data[$this->id] = array( + 'n' => $this->getField('parentId') ? $this->getField('parent', true) : $this->getField('name', true), + 't' => Type::NPC, + 'ti' => $this->getField('parentId') ?: $this->id + ); + } + + return $data; + } +} + + +class CreatureListFilter extends Filter +{ + protected string $type = 'npcs'; + protected static array $enums = array( + 3 => parent::ENUM_FACTION, // faction + 6 => parent::ENUM_ZONE, // foundin + 42 => parent::ENUM_FACTION, // increasesrepwith + 43 => parent::ENUM_FACTION, // decreasesrepwith + 38 => parent::ENUM_EVENT // relatedevent + ); + + protected static array $genericFilter = array( + 1 => [parent::CR_CALLBACK, 'cbHealthMana', 'healthMax', 'healthMin'], // health [num] + 2 => [parent::CR_CALLBACK, 'cbHealthMana', 'manaMin', 'manaMax' ], // mana [num] + 3 => [parent::CR_CALLBACK, 'cbFaction', null, null ], // faction [enum] + 5 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_REPAIRER ], // canrepair + 6 => [parent::CR_ENUM, 's.areaId', false, true ], // foundin + 7 => [parent::CR_CALLBACK, 'cbQuestRelation', 'startsQuests', 0x1 ], // startsquest [enum] + 8 => [parent::CR_CALLBACK, 'cbQuestRelation', 'endsQuests', 0x2 ], // endsquest [enum] + 9 => [parent::CR_BOOLEAN, 'lootId', ], // lootable + 10 => [parent::CR_CALLBACK, 'cbRegularSkinLoot', NPC_TYPEFLAG_SPECIALLOOT ], // skinnable [yn] + 11 => [parent::CR_BOOLEAN, 'pickpocketLootId', ], // pickpocketable + 12 => [parent::CR_CALLBACK, 'cbMoneyDrop', null, null ], // averagemoneydropped [op] [int] + 15 => [parent::CR_CALLBACK, 'cbSpecialSkinLoot', NPC_TYPEFLAG_SKIN_WITH_HERBALISM, null ], // gatherable [yn] + 16 => [parent::CR_CALLBACK, 'cbSpecialSkinLoot', NPC_TYPEFLAG_SKIN_WITH_MINING, null ], // minable [yn] + 18 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_AUCTIONEER ], // auctioneer + 19 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_BANKER ], // banker + 20 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_BATTLEMASTER ], // battlemaster + 21 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_FLIGHT_MASTER ], // flightmaster + 22 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_GUILD_MASTER ], // guildmaster + 23 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_INNKEEPER ], // innkeeper + 24 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_CLASS_TRAINER ], // talentunlearner + 25 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_GUILD_MASTER ], // tabardvendor + 27 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_STABLE_MASTER ], // stablemaster + 28 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_TRAINER ], // trainer + 29 => [parent::CR_FLAG, 'npcflag', NPC_FLAG_VENDOR ], // vendor + 31 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 32 => [parent::CR_FLAG, 'cuFlags', NPC_CU_INSTANCE_BOSS ], // instanceboss + 33 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 34 => [parent::CR_CALLBACK, 'cbUseModel' ], // usemodel [str] + 35 => [parent::CR_STRING, 'textureString' ], // useskin [str] + 37 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true ], // id + 38 => [parent::CR_CALLBACK, 'cbRelEvent', null, null ], // relatedevent [enum] + 40 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 41 => [parent::CR_CALLBACK, 'cbHasLocation' ], // haslocation [yn] [staff] + 42 => [parent::CR_CALLBACK, 'cbReputation', '>', null ], // increasesrepwith [enum] + 43 => [parent::CR_CALLBACK, 'cbReputation', '<', null ], // decreasesrepwith [enum] + 44 => [parent::CR_CALLBACK, 'cbSpecialSkinLoot', NPC_TYPEFLAG_SKIN_WITH_ENGINEERING, null ] // salvageable [yn] + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_LIST, [[1, 3],[5, 12], 15, 16, [18, 25], [27, 29], [31, 35], 37, 38, [40, 44]], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 9999]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_CRV, true ], // criteria values - only printable chars, no delimiter + 'na' => [parent::V_NAME, false, false], // name / subname - only printable chars, no delimiter + 'ex' => [parent::V_EQUAL, 'on', false], // also match subname + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'fa' => [parent::V_CALLBACK, 'cbPetFamily', true ], // pet family [list] - cat[0] == 1 + 'minle' => [parent::V_RANGE, [0, 99], false], // min level [int] + 'maxle' => [parent::V_RANGE, [0, 99], false], // max level [int] + 'cl' => [parent::V_RANGE, [0, 4], true ], // classification [list] + 'ra' => [parent::V_LIST, [-1, 0, 1], false], // react alliance [int] + 'rh' => [parent::V_LIST, [-1, 0, 1], false] // react horde [int] + ); + + public array $extraOpts = []; + + protected function createSQLForValues() : array + { + $parts = []; + $_v = &$this->values; + + // name [str] + if ($_v['na']) + { + $f = [['na', ['nml.nName', 'nml.nSubname']]]; + if ($_v['ex'] != 'on') + $f = [['na', 'nml.nName']]; + + if ($_ = $this->buildMatchLookup($f)) + $parts[] = $_; + else + { + $f = [['na', 'name_loc'.Lang::getLocale()->value], ['na', 'subname_loc'.Lang::getLocale()->value]]; + if ($_v['ex'] != 'on') + $f = [$f[0]]; + + if ($_ = $this->buildLikeLookup($f)) + $parts[] = $_; + } + } + + // pet family [list] + if ($_v['fa']) + $parts[] = ['family', $_v['fa']]; + + // creatureLevel min [int] + if ($_v['minle']) + $parts[] = ['minLevel', $_v['minle'], '>=']; + + // creatureLevel max [int] + if ($_v['maxle']) + $parts[] = ['maxLevel', $_v['maxle'], '<=']; + + // classification [list] + if ($_v['cl']) + $parts[] = ['rank', $_v['cl']]; + + // react Alliance [int] + if (!is_null($_v['ra'])) + $parts[] = ['ft.A', $_v['ra']]; + + // react Horde [int] + if (!is_null($_v['rh'])) + $parts[] = ['ft.H', $_v['rh']]; + + return $parts; + } + + protected function cbPetFamily(string &$val) : bool + { + if (!$this->parentCats || $this->parentCats[0] != 1) + return false; + + if (!Util::checkNumeric($val, NUM_CAST_INT)) + return false; + + $type = parent::V_LIST; + $valid = [[1, 9], 11, 12, 20, 21, [24, 27], [30, 35], [37, 39], [41, 46]]; + + return $this->checkInput($type, $valid, $val); + } + + protected function cbRelEvent(int $cr, int $crs, string $crv) : ?array + { + if ($crs == parent::ENUM_ANY) + { + if ($eventIds = DB::Aowow()->selectCol('SELECT `id` FROM ::events WHERE `holidayId` <> 0')) + if ($cGuids = DB::World()->selectCol('SELECT DISTINCT `guid` FROM game_event_creature WHERE `eventEntry` IN %in', $eventIds)) + return ['s.guid', $cGuids]; + + return [0]; + } + else if ($crs == parent::ENUM_NONE) + { + if ($eventIds = DB::Aowow()->selectCol('SELECT `id` FROM ::events WHERE `holidayId` <> 0')) + if ($cGuids = DB::World()->selectCol('SELECT DISTINCT `guid` FROM game_event_creature WHERE `eventEntry` IN %in', $eventIds)) + return [DB::OR, ['s.guid', $cGuids, '!'], ['s.guid', null]]; + + return [0]; + } + else if (in_array($crs, self::$enums[$cr])) + { + if ($eventIds = DB::Aowow()->selectCol('SELECT `id` FROM ::events WHERE `holidayId` = %i', $crs)) + if ($cGuids = DB::World()->selectCol('SELECT DISTINCT `guid` FROM `game_event_creature` WHERE `eventEntry` IN %in', $eventIds)) + return ['s.guid', $cGuids]; + + return [0]; + } + + return null; + } + + protected function cbMoneyDrop(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + return [DB::AND, ['((minGold + maxGold) / 2)', $crv, $crs]]; + } + + protected function cbQuestRelation(int $cr, int $crs, string $crv, $field, $val) : ?array + { + switch ($crs) + { + case 1: // any + return [DB::AND, ['qse.method', $val, '&'], ['qse.questId', null, '!']]; + case 2: // alliance + return [DB::AND, ['qse.method', $val, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', ChrRace::MASK_HORDE, '&'], 0], ['qt.reqRaceMask', ChrRace::MASK_ALLIANCE, '&']]; + case 3: // horde + return [DB::AND, ['qse.method', $val, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], 0], ['qt.reqRaceMask', ChrRace::MASK_HORDE, '&']]; + case 4: // both + return [DB::AND, ['qse.method', $val, '&'], ['qse.questId', null, '!'], [DB::OR, [DB::AND, ['qt.reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], ['qt.reqRaceMask', ChrRace::MASK_HORDE, '&']], ['qt.reqRaceMask', 0]]]; + case 5: // none + $this->extraOpts['ct']['h'][] = $field.' = 0'; + return [1]; + } + + return null; + } + + protected function cbHealthMana(int $cr, int $crs, string $crv, $minField, $maxField) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + // remap OP for this special case + switch ($crs) + { + case '=': // min > max is totally possible + $this->extraOpts['ct']['h'][] = $minField.' = '.$maxField.' AND '.$minField.' = '.$crv; + break; + case '>': + case '>=': + case '<': + case '<=': + $this->extraOpts['ct']['h'][] = 'IF('.$minField.' > '.$maxField.', '.$maxField.', '.$minField.') '.$crs.' '.$crv; + break; + } + + + return [1]; // always true, use post-filter + } + + protected function cbSpecialSkinLoot(int $cr, int $crs, string $crv, $typeFlag) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + + if ($crs) + return [DB::AND, ['skinLootId', 0, '>'], ['typeFlags', $typeFlag, '&']]; + else + return [DB::OR, ['skinLootId', 0], [['typeFlags', $typeFlag, '&'], 0]]; + } + + protected function cbRegularSkinLoot(int $cr, int $crs, string $crv, $typeFlag) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [DB::AND, ['skinLootId', 0, '>'], [['typeFlags', $typeFlag, '&'], 0]]; + else + return [DB::OR, ['skinLootId', 0], ['typeFlags', $typeFlag, '&']]; + } + + protected function cbReputation(int $cr, int $crs, string $crv, $op) : ?array + { + if (!in_array($crs, self::$enums[$cr])) + return null; + + if ($_ = DB::Aowow()->selectRow('SELECT * FROM ::factions WHERE `id` = %i', $crs)) + $this->fiReputationCols[] = [$crs, Util::localizedString($_, 'name')]; + + if ($cIds = DB::World()->selectCol('SELECT `creature_id` FROM creature_onkill_reputation WHERE (`RewOnKillRepFaction1` = %i AND `RewOnKillRepValue1` '.$op.' 0) OR (`RewOnKillRepFaction2` = %i AND `RewOnKillRepValue2` '.$op.' 0)', $crs, $crs)) + return ['id', $cIds]; + else + return [0]; + } + + protected function cbFaction(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crs, NUM_CAST_INT)) + return null; + + if (!in_array($crs, self::$enums[$cr])) + return null; + + $facTpls = []; + $facs = new FactionList(array(DB::OR, ['parentFactionId', $crs], ['id', $crs])); + foreach ($facs->iterate() as $__) + $facTpls = array_merge($facTpls, $facs->getField('templateIds')); + + return $facTpls ? ['faction', $facTpls] : [0]; + } + + // input is string, so there is no prompt for an operator. But a CR_NUMERIC expects crs to not be 0 + protected function cbUseModel(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT)) + return null; + + return ['modelId', $crv]; + } + + protected function cbHasLocation(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + return ['s.typeId', null, $crs ? '!' : null]; + } +} + +?> diff --git a/includes/dbtypes/currency.class.php b/includes/dbtypes/currency.class.php new file mode 100644 index 00000000..79744097 --- /dev/null +++ b/includes/dbtypes/currency.class.php @@ -0,0 +1,88 @@ + [['ic']], + 'ic' => ['j' => ['::icons ic ON ic.`id` = c.`iconId`', true], 's' => ', ic.`name` AS "iconString"'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + foreach ($this->iterate() as &$_curTpl) + $_curTpl['iconString'] = $_curTpl['iconString'] ?: DEFAULT_ICON; + } + + + public function getListviewData() : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'category' => $this->curTpl['category'], + 'name' => $this->getField('name', true), + 'icon' => $this->curTpl['iconString'] + ); + } + + return $data; + } + + public function getJSGlobals(int $addMask = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + // todo (low): un-hardcode icon strings + $icon = match ($this->id) + { + CURRENCY_HONOR_POINTS => ['pvp-currency-alliance', 'pvp-currency-horde' ], + CURRENCY_ARENA_POINTS => ['pvp-arenapoints-icon', 'pvp-arenapoints-icon' ], + default => [$this->curTpl['iconString'], $this->curTpl['iconString']] + }; + + $data[Type::CURRENCY][$this->id] = ['name' => $this->getField('name', true), 'icon' => $icon]; + } + + return $data; + } + + public function renderTooltip() : ?string + { + if (!$this->curTpl) + return null; + + $x = '
'; + $x .= ''.$this->getField('name', true).'
'; + + // cata+ (or go fill it by hand) + if ($_ = $this->getField('description', true)) + $x .= '
'.$_.'
'; + + if ($_ = $this->getField('cap')) + $x .= '
'.Lang::currency('cap').''.Lang::nf($_).'
'; + + $x .= '
'; + + return $x; + } +} + +?> diff --git a/includes/dbtypes/emote.class.php b/includes/dbtypes/emote.class.php new file mode 100644 index 00000000..3510d687 --- /dev/null +++ b/includes/dbtypes/emote.class.php @@ -0,0 +1,65 @@ +iterate() as &$curTpl) + { + // remap for generic access + $curTpl['name'] = $curTpl['cmd']; + } + } + + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->SelectRow('SELECT `cmd` AS "name_loc0" FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n); + return null; + } + + public function getListviewData() : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->curTpl['id'], + 'name' => $this->curTpl['cmd'], + 'preview' => Util::parseHtmlText($this->getField('meToExt', true) ?: $this->getField('meToNone', true) ?: $this->getField('extToMe', true) ?: $this->getField('extToExt', true) ?: $this->getField('extToNone', true), true) + ); + } + + return $data; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array + { + $data = []; + + foreach ($this->iterate() as $__) + $data[Type::EMOTE][$this->id] = ['name' => $this->getField('cmd')]; + + return $data; + } + + public function renderTooltip() : ?string { return null; } +} + +?> diff --git a/includes/dbtypes/enchantment.class.php b/includes/dbtypes/enchantment.class.php new file mode 100644 index 00000000..28c170e4 --- /dev/null +++ b/includes/dbtypes/enchantment.class.php @@ -0,0 +1,263 @@ + Type::ENCHANTMENT + 'ie' => [['is']], + 'is' => ['j' => ['::item_stats `is` ON `is`.`type` = 502 AND `is`.`typeId` = `ie`.`id`', true], 's' => ', `is`.*'], + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + $relSpells = []; + + // post processing + foreach ($this->iterate() as &$curTpl) + { + $curTpl['spells'] = []; // [spellId, triggerType, charges, chanceOrPpm] + for ($i = 1; $i <=3; $i++) + { + if ($curTpl['object'.$i] <= 0) + continue; + + switch ($curTpl['type'.$i]) // SPELL_TRIGGER_* just reused for wording + { + case ENCHANTMENT_TYPE_COMBAT_SPELL: + $proc = -$this->getField('ppmRate') ?: ($this->getField('procChance') ?: $this->getField('amount'.$i)); + $curTpl['spells'][$i] = [$curTpl['object'.$i], SPELL_TRIGGER_HIT, $curTpl['charges'], $proc]; + $relSpells[] = $curTpl['object'.$i]; + break; + case ENCHANTMENT_TYPE_EQUIP_SPELL: + $curTpl['spells'][$i] = [$curTpl['object'.$i], SPELL_TRIGGER_EQUIP, $curTpl['charges'], 0]; + $relSpells[] = $curTpl['object'.$i]; + break; + case ENCHANTMENT_TYPE_USE_SPELL: + $curTpl['spells'][$i] = [$curTpl['object'.$i], SPELL_TRIGGER_USE, $curTpl['charges'], 0]; + $relSpells[] = $curTpl['object'.$i]; + break; + } + } + } + + if ($relSpells) + $this->relSpells = new SpellList(array(['id', $relSpells])); + + // issue with scaling stats enchantments + // stats are stored as NOT NULL to be usable by the search filters and such become indistinguishable from scaling enchantments that _actually_ use the value 0 + // so we can't rely on ::item_stats and always have to calc stats + foreach ($this->iterate() as $ench) + { + $relSpells = []; + foreach ($ench['spells'] as $s) + if ($_ = $this->relSpells->getEntry($s[0])) + $relSpells[$s[0]] = $_; + + $this->jsonStats[$this->id] = (new StatsContainer($relSpells))->fromEnchantment($ench); + } + } + + public function getListviewData(int $addInfoMask = 0x0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'name' => $this->getField('name', true), + 'spells' => [] + ); + + if ($this->curTpl['skillLine'] > 0) + $data[$this->id]['reqskill'] = $this->curTpl['skillLine']; + + if ($this->curTpl['skillLevel'] > 0) + $data[$this->id]['reqskillrank'] = $this->curTpl['skillLevel']; + + if ($this->curTpl['requiredLevel'] > 0) + $data[$this->id]['reqlevel'] = $this->curTpl['requiredLevel']; + + foreach ($this->curTpl['spells'] as [$spellId, $trigger, $charges, $procChance]) + { + // spell is procing + $trgSpell = 0; + if ($this->relSpells && $this->relSpells->getEntry($spellId) && ($_ = $this->relSpells->canTriggerSpell())) + { + foreach ($_ as $idx) + { + if ($trgSpell = $this->relSpells->getField('effect'.$idx.'TriggerSpell')) + { + $this->triggerIds[] = $trgSpell; + $data[$this->id]['spells'][$trgSpell] = $charges; + } + } + } + + // spell was not proccing + if (!$trgSpell) + $data[$this->id]['spells'][$spellId] = $charges; + } + + if (!$data[$this->id]['spells']) + unset($data[$this->id]['spells']); + + Util::arraySumByKey($data[$this->id], $this->jsonStats[$this->id]->toJson(includeEmpty: false)); + } + + return $data; + } + + public function getStatGainForCurrent() : array + { + return $this->jsonStats[$this->id]->toJson(includeEmpty: true); + } + + public function getRelSpell(int $id) : ?array + { + if ($this->relSpells) + return $this->relSpells->getEntry($id); + + return null; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array + { + $data = []; + + if ($addMask & GLOBALINFO_SELF) + foreach ($this->iterate() as $__) + $data[Type::ENCHANTMENT][$this->id] = ['name' => $this->getField('name', true)]; + + if ($addMask & GLOBALINFO_RELATED) + { + if ($this->relSpells) + $data = $this->relSpells->getJSGlobals(GLOBALINFO_SELF); + + foreach ($this->triggerIds as $tId) + if (empty($data[Type::SPELL][$tId])) + $data[Type::SPELL][$tId] = $tId; + } + + return $data; + } + + public function renderTooltip() : ?string { return null; } +} + + +class EnchantmentListFilter extends Filter +{ + protected string $type = 'enchantments'; + protected static array $enums = array( + 3 => parent::ENUM_PROFESSION // requiresprof + ); + + protected static array $genericFilter = array( + 2 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true], // id + 3 => [parent::CR_ENUM, 'skillLine' ], // requiresprof + 4 => [parent::CR_NUMERIC, 'skillLevel', NUM_CAST_INT ], // reqskillrank + 5 => [parent::CR_BOOLEAN, 'conditionId' ], // hascondition + 10 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 11 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 12 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 20 => [parent::CR_NUMERIC, 'is.str', NUM_CAST_INT, true], // str + 21 => [parent::CR_NUMERIC, 'is.agi', NUM_CAST_INT, true], // agi + 22 => [parent::CR_NUMERIC, 'is.sta', NUM_CAST_INT, true], // sta + 23 => [parent::CR_NUMERIC, 'is.int', NUM_CAST_INT, true], // int + 24 => [parent::CR_NUMERIC, 'is.spi', NUM_CAST_INT, true], // spi + 25 => [parent::CR_NUMERIC, 'is.arcres', NUM_CAST_INT, true], // arcres + 26 => [parent::CR_NUMERIC, 'is.firres', NUM_CAST_INT, true], // firres + 27 => [parent::CR_NUMERIC, 'is.natres', NUM_CAST_INT, true], // natres + 28 => [parent::CR_NUMERIC, 'is.frores', NUM_CAST_INT, true], // frores + 29 => [parent::CR_NUMERIC, 'is.shares', NUM_CAST_INT, true], // shares + 30 => [parent::CR_NUMERIC, 'is.holres', NUM_CAST_INT, true], // holres + 32 => [parent::CR_NUMERIC, 'is.dps', NUM_CAST_FLOAT, true], // dps + 34 => [parent::CR_NUMERIC, 'is.dmg', NUM_CAST_FLOAT, true], // dmg + 37 => [parent::CR_NUMERIC, 'is.mleatkpwr', NUM_CAST_INT, true], // mleatkpwr + 38 => [parent::CR_NUMERIC, 'is.rgdatkpwr', NUM_CAST_INT, true], // rgdatkpwr + 39 => [parent::CR_NUMERIC, 'is.rgdhitrtng', NUM_CAST_INT, true], // rgdhitrtng + 40 => [parent::CR_NUMERIC, 'is.rgdcritstrkrtng', NUM_CAST_INT, true], // rgdcritstrkrtng + 41 => [parent::CR_NUMERIC, 'is.armor', NUM_CAST_INT, true], // armor + 42 => [parent::CR_NUMERIC, 'is.defrtng', NUM_CAST_INT, true], // defrtng + 43 => [parent::CR_NUMERIC, 'is.block', NUM_CAST_INT, true], // block + 44 => [parent::CR_NUMERIC, 'is.blockrtng', NUM_CAST_INT, true], // blockrtng + 45 => [parent::CR_NUMERIC, 'is.dodgertng', NUM_CAST_INT, true], // dodgertng + 46 => [parent::CR_NUMERIC, 'is.parryrtng', NUM_CAST_INT, true], // parryrtng + 48 => [parent::CR_NUMERIC, 'is.splhitrtng', NUM_CAST_INT, true], // splhitrtng + 49 => [parent::CR_NUMERIC, 'is.splcritstrkrtng', NUM_CAST_INT, true], // splcritstrkrtng + 50 => [parent::CR_NUMERIC, 'is.splheal', NUM_CAST_INT, true], // splheal + 51 => [parent::CR_NUMERIC, 'is.spldmg', NUM_CAST_INT, true], // spldmg + 52 => [parent::CR_NUMERIC, 'is.arcsplpwr', NUM_CAST_INT, true], // arcsplpwr + 53 => [parent::CR_NUMERIC, 'is.firsplpwr', NUM_CAST_INT, true], // firsplpwr + 54 => [parent::CR_NUMERIC, 'is.frosplpwr', NUM_CAST_INT, true], // frosplpwr + 55 => [parent::CR_NUMERIC, 'is.holsplpwr', NUM_CAST_INT, true], // holsplpwr + 56 => [parent::CR_NUMERIC, 'is.natsplpwr', NUM_CAST_INT, true], // natsplpwr + 57 => [parent::CR_NUMERIC, 'is.shasplpwr', NUM_CAST_INT, true], // shasplpwr + 60 => [parent::CR_NUMERIC, 'is.healthrgn', NUM_CAST_INT, true], // healthrgn + 61 => [parent::CR_NUMERIC, 'is.manargn', NUM_CAST_INT, true], // manargn + 77 => [parent::CR_NUMERIC, 'is.atkpwr', NUM_CAST_INT, true], // atkpwr + 78 => [parent::CR_NUMERIC, 'is.mlehastertng', NUM_CAST_INT, true], // mlehastertng + 79 => [parent::CR_NUMERIC, 'is.resirtng', NUM_CAST_INT, true], // resirtng + 84 => [parent::CR_NUMERIC, 'is.mlecritstrkrtng', NUM_CAST_INT, true], // mlecritstrkrtng + 94 => [parent::CR_NUMERIC, 'is.splpen', NUM_CAST_INT, true], // splpen + 95 => [parent::CR_NUMERIC, 'is.mlehitrtng', NUM_CAST_INT, true], // mlehitrtng + 96 => [parent::CR_NUMERIC, 'is.critstrkrtng', NUM_CAST_INT, true], // critstrkrtng + 97 => [parent::CR_NUMERIC, 'is.feratkpwr', NUM_CAST_INT, true], // feratkpwr + 101 => [parent::CR_NUMERIC, 'is.rgdhastertng', NUM_CAST_INT, true], // rgdhastertng + 102 => [parent::CR_NUMERIC, 'is.splhastertng', NUM_CAST_INT, true], // splhastertng + 103 => [parent::CR_NUMERIC, 'is.hastertng', NUM_CAST_INT, true], // hastertng + 114 => [parent::CR_NUMERIC, 'is.armorpenrtng', NUM_CAST_INT, true], // armorpenrtng + 115 => [parent::CR_NUMERIC, 'is.health', NUM_CAST_INT, true], // health + 116 => [parent::CR_NUMERIC, 'is.mana', NUM_CAST_INT, true], // mana + 117 => [parent::CR_NUMERIC, 'is.exprtng', NUM_CAST_INT, true], // exprtng + 119 => [parent::CR_NUMERIC, 'is.hitrtng', NUM_CAST_INT, true], // hitrtng + 123 => [parent::CR_NUMERIC, 'is.splpwr', NUM_CAST_INT, true] // splpwr + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_RANGE, [2, 123], true ], // criteria ids + 'crs' => [parent::V_RANGE, [1, 15], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_INT, true ], // criteria values - only numerals + 'na' => [parent::V_NAME, false, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'ty' => [parent::V_RANGE, [1, 8], true ] // types + ); + + protected function createSQLForValues() : array + { + $parts = []; + $_v = &$this->values; + + //string + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'name_loc'.Lang::getLocale()->value]])) + $parts[] = $_; + + // type + if ($_v['ty']) + $parts[] = [DB::OR, ['type1', $_v['ty']], ['type2', $_v['ty']], ['type3', $_v['ty']]]; + + return $parts; + } +} + +?> diff --git a/includes/types/faction.class.php b/includes/dbtypes/faction.class.php similarity index 60% rename from includes/types/faction.class.php rename to includes/dbtypes/faction.class.php index 2ac9e042..cf76280d 100644 --- a/includes/types/faction.class.php +++ b/includes/dbtypes/faction.class.php @@ -1,25 +1,27 @@ [['f2']], - 'f2' => ['j' => ['?_factions f2 ON f.parentFactionId = f2.id', true], 's' => ', IFNULL(f2.parentFactionId, 0) AS cat2'], - 'ft' => ['j' => '?_factiontemplate ft ON ft.factionId = f.id'] + 'f2' => ['j' => ['::factions f2 ON f.`parentFactionId` = f2.`id`', true], 's' => ', IFNULL(f2.`parentFactionId`, 0) AS "cat2"'], + 'ft' => ['j' => '::factiontemplate ft ON ft.`factionId` = f.`id`'] ); - public function __construct($conditions = []) + public function __construct(array $conditions = [], array $miscData = []) { - parent::__construct($conditions); + parent::__construct($conditions, $miscData); if ($this->error) return; @@ -35,13 +37,7 @@ class FactionList extends BaseType } } - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_factions WHERE id = ?d', $id); - return Util::localizedString($n, 'name'); - } - - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -70,7 +66,7 @@ class FactionList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -80,7 +76,7 @@ class FactionList extends BaseType return $data; } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } diff --git a/includes/dbtypes/gameobject.class.php b/includes/dbtypes/gameobject.class.php new file mode 100644 index 00000000..7dcc92c1 --- /dev/null +++ b/includes/dbtypes/gameobject.class.php @@ -0,0 +1,253 @@ + [['ft', 'qse']], + 'nml' => ['j' => ['::objects_search nml ON nml.`id` = o.`id` AND nml.`locale` = DB_LOC_I']], + 'ft' => ['j' => ['::factiontemplate ft ON ft.`id` = o.`faction`', true], 's' => ', ft.`factionId`, IFNULL(ft.`A`, 0) AS "A", IFNULL(ft.`H`, 0) AS "H"'], + 'qse' => ['j' => ['::quests_startend qse ON qse.`type` = 2 AND qse.`typeId` = o.id', true], 's' => ', IF(MIN(qse.`method`) = 1 OR MAX(qse.`method`) = 3, 1, 0) AS "startsQuests", IF(MIN(qse.`method`) = 2 OR MAX(qse.`method`) = 3, 1, 0) AS "endsQuests"', 'g' => 'o.`id`'], + 'qt' => ['j' => '::quests qt ON qse.`questId` = qt.`id`'], + 's' => ['j' => '::spawns s ON s.`type` = 2 AND s.`typeId` = o.`id`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + // post processing + foreach ($this->iterate() as $_id => &$curTpl) + { + if (!$curTpl['name_loc'.Lang::getLocale()->value]) + $curTpl['name_loc'.Lang::getLocale()->value] = Lang::gameObject('unnamed', [$_id]); + + // unpack miscInfo + $curTpl['mStone'] = + $curTpl['capture'] = + $curTpl['lootStack'] = null; + $curTpl['spells'] = []; + + if (in_array($curTpl['type'], [OBJECT_GOOBER, OBJECT_RITUAL, OBJECT_SPELLCASTER, OBJECT_FLAGSTAND, OBJECT_FLAGDROP, OBJECT_AURA_GENERATOR, OBJECT_TRAP])) + $curTpl['spells'] = array_combine(['onUse', 'onSuccess', 'aura', 'triggered'], [$curTpl['onUseSpell'], $curTpl['onSuccessSpell'], $curTpl['auraSpell'], $curTpl['triggeredSpell']]); + + if (!$curTpl['miscInfo']) + continue; + + switch ($curTpl['type']) + { + case OBJECT_CHEST: + case OBJECT_FISHINGHOLE: + $curTpl['lootStack'] = explode(' ', $curTpl['miscInfo']); + break; + case OBJECT_CAPTURE_POINT: + $curTpl['capture'] = explode(' ', $curTpl['miscInfo']); + break; + case OBJECT_MEETINGSTONE: + $curTpl['mStone'] = explode(' ', $curTpl['miscInfo']); + break; + } + } + } + + public function getListviewData() : array + { + $data = []; + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'name' => Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_RAW), + 'type' => $this->getField('typeCat'), + 'location' => $this->getSpawns(SPAWNINFO_ZONES) + ); + + if (!empty($this->curTpl['reqSkill'])) + $data[$this->id]['skill'] = $this->curTpl['reqSkill']; + + if ($this->curTpl['startsQuests']) + $data[$this->id]['hasQuests'] = 1; + + } + + return $data; + } + + public function renderTooltip($interactive = false) : ?string + { + if (!$this->curTpl) + return null; + + $x = ''; + $x .= ''; + if ($this->curTpl['typeCat']) + if ($_ = Lang::gameObject('type', $this->curTpl['typeCat'])) + $x .= ''; + + if (isset($this->curTpl['lockId'])) + if ($locks = Lang::getLocks($this->curTpl['lockId'])) + foreach ($locks as $l) + $x .= ''; + + $x .= '
'.Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_HTML).'
'.$_.'
'.Lang::game('requires', [$l]).'
'; + + return $x; + } + + public function getJSGlobals(int $addMask = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + $data[Type::OBJECT][$this->id] = ['name' => Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_RAW)]; + + return $data; + } + + public function getSourceData(int $id = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if ($id && $id != $this->id) + continue; + + $data[$this->id] = array( + 'n' => $this->getField('name', true), + 't' => Type::OBJECT, + 'ti' => $this->id + ); + } + + return $data; + } +} + + +class GameObjectListFilter extends Filter +{ + protected string $type = 'objects'; + protected static array $enums = array( + 1 => parent::ENUM_ZONE, + 16 => parent::ENUM_EVENT, + 50 => [1, 2, 3, 4, 663, 883] + ); + + protected static array $genericFilter = array( + 1 => [parent::CR_ENUM, 's.areaId', false, true], // foundin + 2 => [parent::CR_CALLBACK, 'cbQuestRelation', 'startsQuests', 0x1 ], // startsquest [side] + 3 => [parent::CR_CALLBACK, 'cbQuestRelation', 'endsQuests', 0x2 ], // endsquest [side] + 4 => [parent::CR_CALLBACK, 'cbOpenable', null, null], // openable [yn] + 5 => [parent::CR_NYI_PH, null, 0 ], // averagemoneycontained [op] [int] - GOs don't contain money, match against 0 + 7 => [parent::CR_NUMERIC, 'reqSkill', NUM_CAST_INT ], // requiredskilllevel + 11 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 13 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 15 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT ], // id + 16 => [parent::CR_CALLBACK, 'cbRelEvent', null, null], // relatedevent (ignore removed by event) + 18 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 50 => [parent::CR_ENUM, 'spellFocusId', true, true], // spellfocus + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_LIST, [[1, 5], 7, 11, 13, 15, 16, 18, 50], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 5000]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_INT, true ], // criteria values - only numeric input values expected + 'na' => [parent::V_NAME, false, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false] // match any / all filter + ); + + public array $extraOpts = []; + + protected function createSQLForValues() : array + { + $parts = []; + $_v = $this->values; + + // name + if ($_v['na']) + { + if ($_ = $this->buildMatchLookup([['na', 'nml.nName']])) + $parts[] = $_; + else if ($_ = $this->buildLikeLookup([['na', 'name_loc'.Lang::getLocale()->value]])) + $parts[] = $_; + } + + return $parts; + } + + protected function cbOpenable(int $cr, int $crs, string $crv) : ?array + { + if ($this->int2Bool($crs)) + return $crs ? [DB::OR, ['flags', 0x2, '&'], ['type', 3]] : [DB::AND, [['flags', 0x2, '&'], 0], ['type', 3, '!']]; + + return null; + } + + protected function cbQuestRelation(int $cr, int $crs, string $crv, $field, $value) : ?array + { + switch ($crs) + { + case 1: // any + return [DB::AND, ['qse.method', $value, '&'], ['qse.questId', null, '!']]; + case 2: // alliance only + return [DB::AND, ['qse.method', $value, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', ChrRace::MASK_HORDE, '&'], 0], ['qt.reqRaceMask', ChrRace::MASK_ALLIANCE, '&']]; + case 3: // horde only + return [DB::AND, ['qse.method', $value, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], 0], ['qt.reqRaceMask', ChrRace::MASK_HORDE, '&']]; + case 4: // both + return [DB::AND, ['qse.method', $value, '&'], ['qse.questId', null, '!'], [DB::OR, [DB::AND, ['qt.reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], ['qt.reqRaceMask', ChrRace::MASK_HORDE, '&']], ['qt.reqRaceMask', 0]]]; + case 5: // none todo (low): broken, if entry starts and ends quests... + $this->extraOpts['o']['h'][] = $field.' = 0'; + return [1]; + } + + return null; + } + + protected function cbRelEvent(int $cr, int $crs, string $crv) : ?array + { + if ($crs == parent::ENUM_ANY) + { + if ($eventIds = DB::Aowow()->selectCol('SELECT `id` FROM ::events WHERE `holidayId` <> 0')) + if ($goGuids = DB::World()->selectCol('SELECT DISTINCT `guid` FROM game_event_gameobject WHERE `eventEntry` IN %in', $eventIds)) + return ['s.guid', $goGuids]; + + return [0]; + } + else if ($crs == parent::ENUM_NONE) + { + if ($eventIds = DB::Aowow()->selectCol('SELECT `id` FROM ::events WHERE `holidayId` <> 0')) + if ($goGuids = DB::World()->selectCol('SELECT DISTINCT `guid` FROM game_event_gameobject WHERE `eventEntry` IN %in', $eventIds)) + return [DB::OR, ['s.guid', $goGuids, '!'], ['s.guid', null]]; + + return [0]; + } + else if (in_array($crs, self::$enums[$cr])) + { + if ($eventIds = DB::Aowow()->selectCol('SELECT `id` FROM ::events WHERE `holidayId` = %i', $crs)) + if ($goGuids = DB::World()->selectCol('SELECT DISTINCT `guid` FROM game_event_gameobject WHERE `eventEntry` IN %in', $eventIds)) + return ['s.guid', $goGuids]; + + return [0]; + } + + return null; + } +} + +?> diff --git a/includes/dbtypes/guide.class.php b/includes/dbtypes/guide.class.php new file mode 100644 index 00000000..d1cc5f5c --- /dev/null +++ b/includes/dbtypes/guide.class.php @@ -0,0 +1,170 @@ + [['a', 'c', 'ar'], 'g' => 'g.`id`'], + 'a' => ['j' => ['::account a ON a.`id` = g.`userId`', true], 's' => ', IFNULL(a.`username`, "") AS "author"'], + 'c' => ['j' => ['::comments c ON c.`type` = '.Type::GUIDE.' AND c.`typeId` = g.`id` AND (c.`flags` & '.CC_FLAG_DELETED.') = 0', true], 's' => ', COUNT(c.`id`) AS "comments"'], + 'ar' => ['j' => ['::articles ar ON ar.`type` = 300 AND ar.`typeId` = g.`id`'], 's' => ', MAX(ar.`rev`) AS "latest"'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + $ratings = GuideMgr::getRatings($this->getFoundIDs()); + + // post processing + foreach ($this->iterate() as $id => &$_curTpl) + $_curTpl = array_merge($_curTpl, $ratings[$id]); + } + + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->SelectRow('SELECT `title` AS "name_loc0" FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n); + return null; + } + + public function getArticle(int $rev = -1) : string + { + if ($rev < -1) + $rev = -1; + + if (empty($this->article[$rev])) + { + $where = array( + [DB::OR, [[DB::AND, [['`type` = %i', Type::GUIDE], ['`typeId` = %i', $this->id]]]]] + ); + if ($url = $this->getField('url')) + $where[0][1][] = ['`url` = %s', $url]; + if ($rev >= 0) + $where[] = ['`rev`= %i', $rev]; + + $a = DB::Aowow()->selectRow('SELECT `article`, `rev` FROM ::articles WHERE %and ORDER BY `rev` DESC LIMIT 1', $where); + + $this->article[$a['rev']] = $a['article']; + if ($this->article[$a['rev']]) + { + Markup::parseTags($this->article[$a['rev']], $this->jsGlobals); + return $this->article[$a['rev']]; + } + else + trigger_error('GuideList::getArticle - linked article is missing'); + } + + return $this->article[$rev] ?? ''; + } + + public function getListviewData(bool $addDescription = false) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'category' => $this->getField('category'), + 'title' => $this->getField('title'), + 'description' => $this->getField('description'), + 'sticky' => !!($this->getField('cuFlags') & CC_FLAG_STICKY), + 'nvotes' => $this->getField('nvotes'), + 'url' => '?guide=' . ($this->getField('url') ?: $this->id), + 'status' => $this->getField('status'), + 'author' => $this->getField('author'), + 'authorroles' => $this->getField('roles'), + 'rating' => $this->getField('rating'), + 'views' => $this->getField('views'), + 'comments' => $this->getField('comments'), + // 'patch' => $this->getField(''), // 30305 - patch is pointless, use date instead + 'date' => $this->getField('date'), // ok + 'when' => date(Util::$dateFormatInternal, $this->getField('date')) + ); + + if ($this->getField('category') == 1) + { + $data[$this->id]['classs'] = $this->getField('classId'); + $data[$this->id]['spec'] = $this->getField('specId'); + } + } + + return $data; + } + + public function userCanView() : bool + { + // is owner || is staff + return $this->getField('userId') == User::$id || User::isInGroup(U_GROUP_STAFF); + } + + public function canBeViewed() : bool + { + // currently approved || has prev. approved version + return $this->getField('status') == GuideMgr::STATUS_APPROVED || $this->getField('rev') > 0; + } + + public function canBeReported() : bool + { + // not own guide && is not archived + return $this->getField('userId') != User::$id && $this->getField('status') != GuideMgr::STATUS_ARCHIVED; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array + { + return $this->jsGlobals; + } + + public function renderTooltip() : ?string + { + $specStr = ''; + + if ($this->getField('classId') && $this->getField('category') == 1) + { + if ($c = $this->getField('classId')) + { + $n = Lang::game('cl', $c); + $specStr .= '  –  %s'; + + if (($s = $this->getField('specId')) > -1) + { + $i = Game::$specIconStrings[$c][$s]; + $n = ''; + $specStr .= ''.Lang::game('classSpecs', $c, $s).''; + } + + $specStr = sprintf($specStr, $n); + } + } + + $tt = '
'.$this->getField('title').'
'; + $tt .= '
'.Lang::game('guide').''.Lang::guide('byAuthor', [$this->getField('author')]).'
'; + $tt .= '
'.Lang::guide('category', $this->getField('category')).$specStr.''.Lang::guide('patch').' 3.3.5
'; + $tt .= '
'.$this->getField('description').'
'; + $tt .= '
'; + + return $tt; + } +} + +?> diff --git a/includes/types/guild.class.php b/includes/dbtypes/guild.class.php similarity index 52% rename from includes/types/guild.class.php rename to includes/dbtypes/guild.class.php index c219e360..e31ea4cc 100644 --- a/includes/types/guild.class.php +++ b/includes/dbtypes/guild.class.php @@ -1,14 +1,18 @@ getGuildScores(); @@ -29,10 +33,10 @@ class GuildList extends BaseType ); } - return array_values($data); + return $data; } - private function getGuildScores() + private function getGuildScores() : void { /* Guild gear scores and achievement points are derived using a weighted average of all of the known characters in that guild. @@ -44,18 +48,16 @@ class GuildList extends BaseType if (!$guilds) return; - $stats = DB::Aowow()->select('SELECT guild AS ARRAY_KEY, id AS ARRAY_KEY2, level, gearscore, achievementpoints, IF(cuFlags & ?d, 0, 1) AS synced FROM ?_profiler_profiles WHERE guild IN (?a) ORDER BY gearscore DESC', PROFILER_CU_NEEDS_RESYNC, $guilds); + $stats = DB::Aowow()->selectAssoc('SELECT `guild` AS ARRAY_KEY, `id` AS ARRAY_KEY2, `level`, `gearscore`, `achievementpoints` FROM ::profiler_profiles WHERE `guild` IN %in AND `stub` = 0 ORDER BY `gearscore` DESC', $guilds); foreach ($this->iterate() as &$_curTpl) { $id = $_curTpl['id']; if (empty($stats[$id])) continue; - $guildStats = array_filter($stats[$id], function ($x) { return $x['synced']; } ); - if (!$guildStats) - continue; + $guildStats = $stats[$id]; - $nMaxLevel = count(array_filter($stats[$id], function ($x) { return $x['level'] >= MAX_LEVEL; } )); + $nMaxLevel = count(array_filter($stats[$id], fn($x) => $x['level'] >= MAX_LEVEL)); $levelMod = 1.0; if ($nMaxLevel < 25) @@ -78,96 +80,69 @@ class GuildList extends BaseType } } - public function renderTooltip() {} - public function getJSGlobals($addMask = 0) {} + public static function getName(int $id) : ?LocString { return null; } + + public function renderTooltip() : ?string { return null; } + public function getJSGlobals(int $addMask = 0) : array { return []; } } class GuildListFilter extends Filter { - public $extraOpts = []; - protected $genericFilter = []; + use TrProfilerFilter; - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'ex' => [FILTER_V_EQUAL, 'on', false], // only match exact - 'si' => [FILTER_V_LIST, [1, 2], false], // side - 'rg' => [FILTER_V_CALLBACK, 'cbRegionCheck', false], // region - 'sv' => [FILTER_V_CALLBACK, 'cbServerCheck', false], // server + protected string $type = 'guilds'; + protected static array $genericFilter = []; + protected static array $inputFields = array( + 'ex' => [parent::V_EQUAL, 'on', false], // only match exact - must be defined before 'na' as it's test relies on 'ex's value + 'na' => [parent::V_NAME, true, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'si' => [parent::V_LIST, [SIDE_ALLIANCE, SIDE_HORDE], false], // side + 'rg' => [parent::V_CALLBACK, 'cbRegionCheck', false], // region + 'bg' => [parent::V_EQUAL, null, false], // battlegroup - unsued here, but var expected by template + 'sv' => [parent::V_CALLBACK, 'cbServerCheck', false] // server ); - protected function createSQLForCriterium(&$cr) { } + public array $extraOpts = []; - protected function createSQLForValues() + protected function createSQLForValues() : array { $parts = []; - $_v = $this->fiData['v']; + $_v = $this->values; // region (rg), battlegroup (bg) and server (sv) are passed to GuildList as miscData and handled there // name [str] - if (!empty($_v['na'])) - if ($_ = $this->modularizeString(['g.name'], $_v['na'], !empty($_v['ex']) && $_v['ex'] == 'on')) + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'g.name']], $_v['ex'] == 'on')) $parts[] = $_; // side [list] - if (!empty($_v['si'])) - { - if ($_v['si'] == 1) - $parts[] = ['c.race', [1, 3, 4, 7, 11]]; - else if ($_v['si'] == 2) - $parts[] = ['c.race', [2, 5, 6, 8, 10]]; - } + if ($_v['si'] == SIDE_ALLIANCE) + $parts[] = ['c.race', ChrRace::fromMask(ChrRace::MASK_ALLIANCE)]; + else if ($_v['si'] == SIDE_HORDE) + $parts[] = ['c.race', ChrRace::fromMask(ChrRace::MASK_HORDE)]; return $parts; } - - protected function cbRegionCheck(&$v) - { - if (in_array($v, Util::$regions)) - { - $this->parentCats[0] = $v; // directly redirect onto this region - $v = ''; // remove from filter - - return true; - } - - return false; - } - - protected function cbServerCheck(&$v) - { - foreach (Profiler::getRealms() as $realm) - if ($realm['name'] == $v) - { - $this->parentCats[1] = Profiler::urlize($v);// directly redirect onto this server - $v = ''; // remove from filter - - return true; - } - - return false; - } } class RemoteGuildList extends GuildList { - protected $queryBase = 'SELECT `g`.*, `g`.`guildid` AS ARRAY_KEY FROM guild g'; - protected $queryOpts = array( + protected string $queryBase = 'SELECT `g`.*, `g`.`guildid` AS ARRAY_KEY FROM guild g'; + protected array $queryOpts = array( 'g' => [['gm', 'c'], 'g' => 'ARRAY_KEY'], - 'gm' => ['j' => 'guild_member gm ON gm.guildid = g.guildid', 's' => ', COUNT(1) AS members'], - 'c' => ['j' => 'characters c ON c.guid = gm.guid', 's' => ', BIT_OR(IF(c.race IN (1, 3, 4, 7, 11), 1, 2)) - 1 AS faction'] + 'gm' => ['j' => 'guild_member gm ON gm.`guildid` = g.`guildid`', 's' => ', COUNT(1) AS "members"'], + 'c' => ['j' => 'characters c ON c.`guid` = gm.`guid`', 's' => ', BIT_OR(IF(c.`race` IN (1, 3, 4, 7, 11), 1, 2)) - 1 AS "faction"'] ); - public function __construct($conditions = [], $miscData = null) + public function __construct(array $conditions = [], array $miscData = []) { // select DB by realm if (!$this->selectRealms($miscData)) { - trigger_error('no access to auth-db or table realmlist is empty', E_USER_WARNING); + trigger_error('RemoteGuildList::__construct - cannot access any realm.', E_USER_WARNING); return; } @@ -177,15 +152,14 @@ class RemoteGuildList extends GuildList return; reset($this->dbNames); // only use when querying single realm - $realmId = key($this->dbNames); - $realms = Profiler::getRealms(); - $distrib = []; + $realms = Profiler::getRealms(); + $distrib = []; // post processing foreach ($this->iterate() as $guid => &$curTpl) { // battlegroup - $curTpl['battlegroup'] = CFG_BATTLEGROUP; + $curTpl['battlegroup'] = Cfg::get('BATTLEGROUP'); $r = explode(':', $guid)[0]; if (!empty($realms[$r])) @@ -196,7 +170,15 @@ class RemoteGuildList extends GuildList } else { - trigger_error('character "'.$curTpl['name'].'" belongs to nonexistant realm #'.$r, E_USER_WARNING); + trigger_error('guild #'.$guid.' belongs to nonexistent realm #'.$r, E_USER_WARNING); + unset($this->templates[$guid]); + continue; + } + + // empty name + if (!$curTpl['name']) + { + trigger_error('guild #'.$guid.' on realm #'.$r.' has empty name.', E_USER_WARNING); unset($this->templates[$guid]); continue; } @@ -208,10 +190,14 @@ class RemoteGuildList extends GuildList $distrib[$curTpl['realm']]++; } - $limit = CFG_SQL_LIMIT_DEFAULT; + // equalize subject distribution across realms + $limit = 0; foreach ($conditions as $c) - if (is_int($c)) - $limit = $c; + if (is_numeric($c)) + $limit = max(0, (int)$c); + + if (!$limit) // int:0 means unlimited, so skip early + return; $total = array_sum($distrib); foreach ($distrib as &$d) @@ -230,29 +216,27 @@ class RemoteGuildList extends GuildList } } - public function initializeLocalEntries() + public function initializeLocalEntries() : void { + if (!$this->templates) + return; + $data = []; foreach ($this->iterate() as $guid => $__) { - $data[$guid] = array( - 'realm' => $this->getField('realm'), - 'realmGUID' => $this->getField('guildid'), - 'name' => $this->getField('name'), - 'nameUrl' => Profiler::urlize($this->getField('name')), - 'cuFlags' => PROFILER_CU_NEEDS_RESYNC - ); + $data['realm'][$guid] = $this->getField('realm'); + $data['realmGUID'][$guid] = $this->getField('guildid'); + $data['name'][$guid] = $this->getField('name'); + $data['nameUrl'][$guid] = Profiler::urlize($this->getField('name')); + $data['stub'][$guid] = 1; } // basic guild data - foreach (Util::createSqlBatchInsert($data) as $ins) - DB::Aowow()->query('INSERT IGNORE INTO ?_profiler_guild (?#) VALUES '.$ins, array_keys(reset($data))); + DB::Aowow()->qry('INSERT INTO ::profiler_guild %m ON DUPLICATE KEY UPDATE `id` = `id`', $data); // merge back local ids - $localIds = DB::Aowow()->selectCol( - 'SELECT CONCAT(realm, ":", realmGUID) AS ARRAY_KEY, id FROM ?_profiler_guild WHERE realm IN (?a) AND realmGUID IN (?a)', - array_column($data, 'realm'), - array_column($data, 'realmGUID') + $localIds = DB::Aowow()->selectCol('SELECT CONCAT(`realm`, ":", `realmGUID`) AS ARRAY_KEY, `id` FROM ::profiler_guild WHERE `realm` IN %in AND `realmGUID` IN %in', + $data['realm'], $data['realmGUID'] ); foreach ($this->iterate() as $guid => &$_curTpl) @@ -264,17 +248,38 @@ class RemoteGuildList extends GuildList class LocalGuildList extends GuildList { - protected $queryBase = 'SELECT g.*, g.id AS ARRAY_KEY FROM ?_profiler_guild g'; + protected string $queryBase = 'SELECT g.*, g.`id` AS ARRAY_KEY FROM ::profiler_guild g'; - public function __construct($conditions = [], $miscData = null) + public function __construct(array $conditions = [], array $miscData = []) { + $realms = Profiler::getRealms(); + + // graft realm selection from miscData onto conditions + if (isset($miscData['sv'])) + $realms = array_filter($realms, fn($x) => Profiler::urlize($x['name']) == Profiler::urlize($miscData['sv'])); + + if (isset($miscData['rg'])) + $realms = array_filter($realms, fn($x) => $x['region'] == $miscData['rg']); + + if (!$realms) + { + trigger_error('LocalGuildList::__construct - cannot access any realm.', E_USER_WARNING); + return; + } + + if ($conditions) + { + array_unshift($conditions, DB::AND); + $conditions = [DB::AND, ['realm', array_keys($realms)], $conditions]; + } + else + $conditions = [['realm', array_keys($realms)]]; + parent::__construct($conditions, $miscData); if ($this->error) return; - $realms = Profiler::getRealms(); - foreach ($this->iterate() as $id => &$curTpl) { if ($curTpl['realm'] && !isset($realms[$curTpl['realm']])) @@ -287,17 +292,17 @@ class LocalGuildList extends GuildList } // battlegroup - $curTpl['battlegroup'] = CFG_BATTLEGROUP; + $curTpl['battlegroup'] = Cfg::get('BATTLEGROUP'); } } - public function getProfileUrl() + public function getProfileUrl() : string { $url = '?guild='; return $url.implode('.', array( - Profiler::urlize($this->getField('region')), - Profiler::urlize($this->getField('realmName')), + $this->getField('region'), + Profiler::urlize($this->getField('realmName'), true), Profiler::urlize($this->getField('name')) )); } diff --git a/includes/dbtypes/icon.class.php b/includes/dbtypes/icon.class.php new file mode 100644 index 00000000..c3971a97 --- /dev/null +++ b/includes/dbtypes/icon.class.php @@ -0,0 +1,205 @@ + '::items', + 'nSpells' => '::spell', + 'nAchievements' => '::achievement', + 'nCurrencies' => '::currencies', + 'nPets' => '::pet' + ); + + protected string $queryBase = 'SELECT ic.*, ic.`id` AS ARRAY_KEY FROM ::icons ic'; + /* this works, but takes ~100x more time than i'm comfortable with .. kept as reference + protected array $queryOpts = array( // 29 => Type::ICON + 'ic' => [['s', 'i', 'a', 'c', 'p'], 'g' => 'ic.id'], + 'i' => ['j' => ['::items `i` ON `i`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `i`.`id`) AS "nItems"'], + 's' => ['j' => ['::spell `s` ON `s`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `s`.`id`) AS "nSpells"'], + 'a' => ['j' => ['::achievement `a` ON `a`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `a`.`id`) AS "nAchievements"'], + 'c' => ['j' => ['::currencies `c` ON `c`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `c`.`id`) AS "nCurrencies"'], + 'p' => ['j' => ['::pet `p` ON `p`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `p`.`id`) AS "nPets"'] + ); + */ + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + if (!$this->getFoundIDs()) + return; + + foreach ($this->pseudoJoin as $var => $tbl) + { + $res = DB::Aowow()->selectCol($this->pseudoQry, $tbl, $this->getFoundIDs()); + foreach ($res as $icon => $qty) + $this->templates[$icon][$var] = $qty; + } + } + + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->selectRow('SELECT `name` AS "name_loc0" FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n); + return null; + } + + public function getListviewData(int $addInfoMask = 0x0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'name' => $this->getField('name_source', true, true), + 'icon' => $this->getField('name', true, true), + 'itemcount' => (int)$this->getField('nItems'), + 'spellcount' => (int)$this->getField('nSpells'), + 'achievementcount' => (int)$this->getField('nAchievements'), + 'npccount' => 0, // UNUSED + 'petabilitycount' => 0, // UNUSED + 'currencycount' => (int)$this->getField('nCurrencies'), + 'missionabilitycount' => 0, // UNUSED + 'buildingcount' => 0, // UNUSED + 'petcount' => (int)$this->getField('nPets'), + 'threatcount' => 0, // UNUSED + 'classcount' => 0 // class icons are hardcoded and not referenced in dbc + ); + } + + return $data; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array + { + $data = []; + + foreach ($this->iterate() as $__) + $data[Type::ICON][$this->id] = ['name' => $this->getField('name', true, true), 'icon' => $this->getField('name', true, true)]; + + return $data; + } + + public function renderTooltip() : ?string { return null; } +} + + +class IconListFilter extends Filter +{ + private array $iconTotals = []; + private array $criterion2field = array( + 1 => '::items', // items [num] + 2 => '::spell', // spells [num] + 3 => '::achievement', // achievements [num] + // 4 => '', // battlepets [num] + // 5 => '', // battlepetabilities [num] + 6 => '::currencies', // currencies [num] + // 7 => '', // garrisonabilities [num] + // 8 => '', // garrisonbuildings [num] + 9 => '::pet', // hunterpets [num] + // 10 => '', // garrisonmissionthreats [num] + 11 => '::classes', // classes [num] + 13 => '' // used [num] + ); + + protected string $type = 'icons'; + protected static array $genericFilter = array( + 1 => [parent::CR_CALLBACK, 'cbUsedBy' ], // items [num] + 2 => [parent::CR_CALLBACK, 'cbUsedBy' ], // spells [num] + 3 => [parent::CR_CALLBACK, 'cbUsedBy' ], // achievements [num] + 6 => [parent::CR_CALLBACK, 'cbUsedBy' ], // currencies [num] + 9 => [parent::CR_CALLBACK, 'cbUsedBy' ], // hunterpets [num] + 11 => [parent::CR_CALLBACK, 'cbUsedBy' ], // classes [num] + 13 => [parent::CR_CALLBACK, 'cbUsedBy', true] // used [num] + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_LIST, [1, 2, 3, 6, 9, 11, 13], true ], // criteria ids + 'crs' => [parent::V_RANGE, [1, 6], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_INT, true ], // criteria values - all criteria are numeric here + 'na' => [parent::V_NAME, false, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false] // match any / all filter + ); + + public array $extraOpts = []; + + protected function createSQLForValues() : array + { + $parts = []; + $_v = &$this->values; + + //string + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'name']])) + $parts[] = $_; + + return $parts; + } + + protected function cbUsedBy(int $cr, int $crs, string $crv, ?bool $all = false) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || ![$filter, $negate] = $this->int2Filter($crs, $crv)) + return null; + + $total = $this->prepareIconTotals($all ? 0 : $cr); + + $ids = array_filter($total, $filter); + + if ($negate) + return $ids ? ['id', array_keys($ids), '!'] : [1]; + else + return $ids ? ['id', array_keys($ids)] : ['id', array_keys($total), '!']; + } + + private function int2Filter(mixed $op, int $y) : ?array + { + return match ($op) { + 1 => [fn($x) => $x > $y, false], + 2 => [fn($x) => $x >= $y, false], + 3 => [fn($x) => $x == $y, false], + 4 => [fn($x) => $x > $y, true], + 5 => [fn($x) => $x >= $y, true], + 6 => [fn($x) => $x == $y, true], + default => null + }; + } + + private function prepareIconTotals(int $forCr = 0) : array + { + foreach ($this->criterion2field as $cr => $tbl) + { + if (!$tbl || isset($this->iconTotals[$cr]) || ($forCr && $forCr != $cr)) + continue; + + $this->iconTotals[$cr] = DB::Aowow()->selectCol('SELECT `iconId` AS ARRAY_KEY, COUNT(*) AS "n" FROM %n GROUP BY `iconId`', $tbl); + } + + if ($forCr) + return $this->iconTotals[$forCr]; + + if (!isset($this->iconTotals['all'])) + { + $this->iconTotals['all'] = []; + Util::arraySumByKey($this->iconTotals['all'], ...$this->iconTotals); + } + + return $this->iconTotals['all']; + } +} + +?> diff --git a/includes/dbtypes/item.class.php b/includes/dbtypes/item.class.php new file mode 100644 index 00000000..2932d2a9 --- /dev/null +++ b/includes/dbtypes/item.class.php @@ -0,0 +1,2643 @@ + Type::ITEM + 'i' => [['is', 'src', 'ic'], 'o' => 'i.`quality` DESC, i.`itemLevel` DESC'], + 'nml' => ['j' => ['::items_search nml ON nml.`id` = i.`id` AND nml.`locale` = DB_LOC_I']], + 'ic' => ['j' => ['::icons `ic` ON `ic`.`id` = `i`.`iconId`', true], 's' => ', ic.`name` AS "iconString"'], + 'is' => ['j' => ['::item_stats `is` ON `is`.`type` = 3 AND `is`.`typeId` = `i`.`id`', true], 's' => ', `is`.*'], + 's' => ['j' => ['::spell `s` ON `s`.`effect1CreateItemId` = `i`.`id`', true], 'g' => 'i.`id`'], + 'e' => ['j' => ['::events `e` ON `e`.`id` = `i`.`eventId`', true], 's' => ', e.`holidayId`'], + 'src' => ['j' => ['::source `src` ON `src`.`type` = 3 AND `src`.`typeId` = `i`.`id`', true], 's' => ', `moreType`, `moreTypeId`, `moreZoneId`, `moreMask`, `src1`, `src2`, `src3`, `src4`, `src5`, `src6`, `src7`, `src8`, `src9`, `src10`, `src11`, `src12`, `src13`, `src14`, `src15`, `src16`, `src17`, `src18`, `src19`, `src20`, `src21`, `src22`, `src23`, `src24`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + foreach ($this->iterate() as &$_curTpl) + { + // item is scaling; overwrite other values + if ($_curTpl['scalingStatDistribution'] > 0 && $_curTpl['scalingStatValue'] > 0) + $this->initScalingStats(); + + // fix missing icons + $_curTpl['iconString'] = $_curTpl['iconString'] ?: DEFAULT_ICON; + + // from json to json .. the gentle fuckups of legacy code integration + $this->initJsonStats(); + $this->jsonStats[$this->id] = (new StatsContainer())->fromJson($_curTpl, true)->toJson(Stat::FLAG_ITEM /* | Stat::FLAG_SERVERSIDE */, false); + + if ($miscData) + { + // readdress itemset .. is wrong for virtual sets + if (isset($miscData['pcsToSet']) && isset($miscData['pcsToSet'][$this->id])) + $this->json[$this->id]['itemset'] = $miscData['pcsToSet'][$this->id]; + + // additional rel attribute for listview rows + if (isset($miscData['extraOpts']['relEnchant'])) + $this->relEnchant = $miscData['extraOpts']['relEnchant']; + } + + // sources + for ($i = 1; $i < 25; $i++) + { + if ($_ = $_curTpl['src'.$i]) + $this->sources[$this->id][$i][] = $_; + + unset($_curTpl['src'.$i]); + } + } + } + + // todo (med): information will get lost if one vendor sells one item multiple times with different costs (e.g. for item 54637) + // wowhead seems to have had the same issues + public function getExtendedCost(?array $filter = [], ?array &$reqRating = []) : array + { + if ($this->error) + return []; + + $idx = $this->id; + + if (empty($this->vendors)) + { + $itemIds = array_keys($this->templates); + if (!empty($filter[Type::NPC]) && is_array($filter[Type::NPC])) + $itemIds = array_intersect($itemIds, $filter[Type::NPC]); + + $itemz = []; + $xCostData = []; + $rawEntries = DB::World()->selectAssoc( + 'SELECT nv.`item`, nv.`entry`, 0 AS "eventId", nv.`maxcount`, nv.`extendedCost`, nv.`incrtime` + FROM npc_vendor nv + WHERE nv.`item` IN %in + UNION + SELECT nv2.`item`, nv1.`entry`, 0 AS "eventId", nv2.`maxcount`, nv2.`extendedCost`, nv2.`incrtime` + FROM npc_vendor nv1 + JOIN npc_vendor nv2 ON -nv1.`item` = nv2.`entry` + WHERE nv2.`item` IN %in + UNION + SELECT genv.`item`, c.`id` AS "entry", ge.`eventEntry` AS "eventId", genv.`maxcount`, genv.`extendedCost`, genv.`incrtime` + FROM game_event_npc_vendor genv + LEFT JOIN game_event ge ON genv.`eventEntry` = ge.`eventEntry` + JOIN creature c ON c.`guid` = genv.`guid` + WHERE genv.`item` IN %in', + $itemIds, $itemIds, $itemIds + ); + + foreach ($rawEntries as $costEntry) + { + if ($costEntry['extendedCost']) + $xCostData[] = $costEntry['extendedCost']; + + if (!isset($itemz[$costEntry['item']][$costEntry['entry']])) + $itemz[$costEntry['item']][$costEntry['entry']] = [$costEntry]; + else + $itemz[$costEntry['item']][$costEntry['entry']][] = $costEntry; + } + + if ($xCostData) + $xCostData = DB::Aowow()->selectAssoc('SELECT *, `id` AS ARRAY_KEY FROM ::itemextendedcost WHERE `id` IN %in', $xCostData); + + $cItems = []; + foreach ($itemz as $k => $vendors) + { + foreach ($vendors as $l => $vendor) + { + foreach ($vendor as $m => $vInfo) + { + $costs = []; + if (!empty($xCostData[$vInfo['extendedCost']])) + $costs = $xCostData[$vInfo['extendedCost']]; + + $data = array( + 'stock' => $vInfo['maxcount'] ?: -1, + 'event' => $vInfo['eventId'], + 'restock' => $vInfo['incrtime'], + 'reqRating' => $costs ? $costs['reqPersonalRating'] : 0, + 'reqBracket' => $costs ? $costs['reqArenaSlot'] : 0 + ); + + // hardcode arena) & honor + if (!empty($costs['reqArenaPoints'])) + { + $data[-103] = $costs['reqArenaPoints']; + $this->jsGlobals[Type::CURRENCY][CURRENCY_ARENA_POINTS] = CURRENCY_ARENA_POINTS; + } + + if (!empty($costs['reqHonorPoints'])) + { + $data[-104] = $costs['reqHonorPoints']; + $this->jsGlobals[Type::CURRENCY][CURRENCY_HONOR_POINTS] = CURRENCY_HONOR_POINTS; + } + + for ($i = 1; $i < 6; $i++) + { + if (!empty($costs['reqItemId'.$i]) && $costs['itemCount'.$i] > 0) + { + $data[$costs['reqItemId'.$i]] = $costs['itemCount'.$i]; + $cItems[] = $costs['reqItemId'.$i]; + } + } + + // no extended cost or additional gold required + if (!$costs || $this->getField('flagsExtra') & 0x04) + { + $this->getEntry($k); + if ($_ = $this->getField('buyPrice')) + $data[0] = $_; + } + + $vendor[$m] = $data; + } + $vendors[$l] = $vendor; + } + + $itemz[$k] = $vendors; + } + + // convert items to currency if possible + if ($cItems) + { + $moneyItems = new CurrencyList(array(['itemId', $cItems])); + foreach ($moneyItems->getJSGlobals() as $type => $jsData) + foreach ($jsData as $k => $v) + $this->jsGlobals[$type][$k] = $v; + + foreach ($itemz as $itemId => $vendors) + { + foreach ($vendors as $npcId => $costData) + { + foreach ($costData as $itr => $cost) + { + foreach ($cost as $k => $v) + { + if (in_array($k, $cItems)) + { + $found = false; + foreach ($moneyItems->iterate() as $__) + { + if ($moneyItems->getField('itemId') == $k) + { + unset($cost[$k]); + $cost[-$moneyItems->id] = $v; + $found = true; + break; + } + } + + if (!$found) + $this->jsGlobals[Type::ITEM][$k] = $k; + } + } + $costData[$itr] = $cost; + } + $vendors[$npcId] = $costData; + } + $itemz[$itemId] = $vendors; + } + } + + $this->vendors = $itemz; + } + + $result = $this->vendors; + + // apply filter if given + $tok = !empty($filter[Type::ITEM]) ? $filter[Type::ITEM] : null; + $cur = !empty($filter[Type::CURRENCY]) ? $filter[Type::CURRENCY] : null; + + foreach ($result as $itemId => &$data) + { + $reqRating = []; + foreach ($data as $npcId => $entries) + { + foreach ($entries as $costs) + { + if ($tok || $cur) // bought with specific token or currency + { + $valid = false; + foreach ($costs as $k => $qty) + { + if ((!$tok || $k == $tok) && (!$cur || $k == -$cur)) + { + $valid = true; + break; + } + } + + if (!$valid) + unset($data[$npcId]); + } + + // reqRating ins't really a cost .. so pass it by ref instead of return + // data was invalid and deleted or some source doesn't require arena rating + if (!isset($data[$npcId]) || ($reqRating && !$reqRating[0])) + continue; + + // use lowest total value + if (!$costs['reqRating']) + $reqRating = [0, 2]; + else if ($costs['reqRating'] && (!$reqRating || $reqRating[0] > $costs['reqRating'])) + $reqRating = [$costs['reqRating'], $costs['reqBracket']]; + } + } + + if (empty($data)) + unset($result[$itemId]); + } + + // restore internal index; + $this->getEntry($idx); + + return $result; + } + + public function getListviewData(int $addInfoMask = 0x0, ?array $miscData = null) : array + { + /* + * ITEMINFO_JSON (0x01): jsonStats (including spells) and subitems parsed + * ITEMINFO_SUBITEMS (0x02): searched by comparison + * ITEMINFO_VENDOR (0x04): costs-obj, when displayed as vendor + * ITEMINFO_GEM (0x10): gem infos and score + * ITEMINFO_MODEL (0x20): sameModelAs-Tab + */ + + $data = []; + + // random item is random + if ($addInfoMask & ITEMINFO_SUBITEMS) + $this->initSubItems(); + + if ($addInfoMask & ITEMINFO_JSON) + { + $this->extendJsonStats(); + Util::arraySumByKey($data, $this->jsonStats); + } + + $extCosts = []; + if ($addInfoMask & ITEMINFO_VENDOR) + $extCosts = $this->getExtendedCost($miscData); + + $extCostOther = []; + foreach ($this->iterate() as $__) + { + foreach ($this->json[$this->id] as $k => $v) + $data[$this->id][$k] = $v; + + // json vs listview quirk + $data[$this->id]['name'] = $data[$this->id]['quality'].Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_RAW); + unset($data[$this->id]['quality']); + + if (!empty($this->relEnchant) && $this->curTpl['randomEnchant']) + { + if (($x = array_search($this->curTpl['randomEnchant'], array_column($this->relEnchant, 'entry'))) !== false) + { + $data[$this->id]['rel'] = 'rand='.$this->relEnchant[$x]['ench']; + $data[$this->id]['name'] .= ' '.$this->relEnchant[$x]['name']; + } + } + + if ($addInfoMask & ITEMINFO_JSON) + { + if ($_ = intVal(($this->curTpl['minMoneyLoot'] + $this->curTpl['maxMoneyLoot']) / 2)) + $data[$this->id]['avgmoney'] = $_; + + if ($_ = $this->curTpl['repairPrice']) + $data[$this->id]['repaircost'] = $_; + } + + if ($addInfoMask & (ITEMINFO_JSON | ITEMINFO_GEM)) + if (isset($this->curTpl['score'])) + $data[$this->id]['score'] = $this->curTpl['score']; + + if ($addInfoMask & ITEMINFO_GEM) + { + $data[$this->id]['uniqEquip'] = ($this->curTpl['flags'] & ITEM_FLAG_UNIQUEEQUIPPED) ? 1 : 0; + $data[$this->id]['socketLevel'] = 0; // not used with wotlk + } + + if ($addInfoMask & ITEMINFO_VENDOR) + { + // just use the first results + // todo (med): dont use first vendor; search for the right one + if (!empty($extCosts[$this->id])) + { + $cost = reset($extCosts[$this->id]); + foreach ($cost as $itr => $entries) + { + $currency = []; + $tokens = []; + $costArr = []; + + foreach ($entries as $k => $qty) + { + if (is_string($k)) + continue; + + if ($k > 0) + $tokens[] = [$k, $qty]; + else if ($k < 0) + $currency[] = [-$k, $qty]; + } + + $costArr['stock'] = $entries['stock'];// display as column in lv + $costArr['avail'] = $entries['stock'];// display as number on icon + $costArr['cost'] = [empty($entries[0]) ? 0 : $entries[0]]; + $costArr['restock'] = $entries['restock']; + + if ($entries['event']) + if (Conditions::extendListviewRow($costArr, Conditions::SRC_NONE, $this->id, [Conditions::ACTIVE_EVENT, $entries['event']])) + $this->jsGlobals[Type::WORLDEVENT][$entries['event']] = $entries['event']; + + if ($currency || $tokens) // fill idx:3 if required + $costArr['cost'][] = $currency; + + if ($tokens) + $costArr['cost'][] = $tokens; + + if (!empty($entries['reqRating'])) + $costArr['reqarenartng'] = $entries['reqRating']; + + if ($itr > 0) + $extCostOther[$this->id][] = $costArr; + else + $data[$this->id] = array_merge($data[$this->id], $costArr); + } + } + + if ($x = $this->curTpl['buyPrice']) + $data[$this->id]['buyprice'] = $x; + + if ($x = $this->curTpl['sellPrice']) + $data[$this->id]['sellprice'] = $x; + + if ($x = $this->curTpl['buyCount']) + $data[$this->id]['stack'] = $x; + } + + if ($this->curTpl['class'] == ITEM_CLASS_GLYPH) + $data[$this->id]['glyph'] = $this->curTpl['subSubClass']; + + if ($x = $this->curTpl['requiredSkill']) + $data[$this->id]['reqskill'] = $x; + + if ($x = $this->curTpl['requiredSkillRank']) + $data[$this->id]['reqskillrank'] = $x; + + if ($x = $this->curTpl['requiredSpell']) + $data[$this->id]['reqspell'] = $x; + + if ($x = $this->curTpl['requiredFaction']) + $data[$this->id]['reqfaction'] = $x; + + if ($x = $this->curTpl['requiredFactionRank']) + { + $data[$this->id]['reqrep'] = $x; + $data[$this->id]['standing'] = $x; // used in /faction item-listing + } + + if ($x = $this->curTpl['slots']) + $data[$this->id]['nslots'] = $x; + + $_ = $this->curTpl['requiredRace']; + if (ChrRace::sideFromMask($_) != SIDE_BOTH) + $data[$this->id]['reqrace'] = $_; + + if ($_ = $this->curTpl['requiredClass']) + $data[$this->id]['reqclass'] = $_; // $data[$this->id]['classes'] ?? + + if ($this->curTpl['flags'] & ITEM_FLAG_HEROIC) + $data[$this->id]['heroic'] = true; + + if ($addInfoMask & ITEMINFO_MODEL) + if ($_ = $this->getField('displayId')) + $data[$this->id]['displayid'] = $_; + + if ($this->getSources($s, $sm)) + { + $data[$this->id]['source'] = $s; + if ($sm) + $data[$this->id]['sourcemore'] = $sm; + } + + if (!empty($this->curTpl['cooldown'])) + $data[$this->id]['cooldown'] = $this->curTpl['cooldown'] / 1000; + } + + foreach ($extCostOther as $itemId => $duplicates) + foreach ($duplicates as $d) + $data[] = array_merge($data[$itemId], $d); // we dont really use keys on data, but this may cause errors in future + + /* even more complicated crap + modelviewer {type:X, displayid:Y, slot:z} .. not sure, when to set + */ + + return $data; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_SELF, ?array &$extra = []) : array + { + $data = $addMask & GLOBALINFO_RELATED ? $this->jsGlobals : []; + + foreach ($this->iterate() as $id => $__) + { + if ($addMask & GLOBALINFO_SELF) + { + $data[Type::ITEM][$id] = array( + 'name' => Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_RAW), + 'quality' => $this->curTpl['quality'], + 'icon' => $this->curTpl['iconString'] + ); + + if ($this->curTpl['class'] == ITEM_CLASS_RECIPE) + $data[Type::ITEM][$id]['completion_category'] = $this->curTpl['class']; + else if ($this->curTpl['class'] == ITEM_CLASS_MISC && in_array($this->curTpl['subClass'], [2, 5, -7])) + $data[Type::ITEM][$id]['completion_category'] = $this->curTpl['class'].'-'.$this->curTpl['subClass']; + } + + if ($addMask & GLOBALINFO_EXTRA) + { + $extra[$id] = array( + // 'id' => $id, + 'tooltip' => $this->renderTooltip(true), + 'spells' => new \StdClass // placeholder for knownSpells + ); + } + } + + return $data; + } + + /* + enhance (set by comparison tool or formated external links) + ench: enchantmentId + sock: bool (extraScoket (gloves, belt)) + gems: array (:-separated itemIds) + rand: >0: randomPropId; <0: randomSuffixId + interactive (set to place javascript/anchors to manipulate level and ratings or link to filters (static tooltips vs popup tooltip)) + subOf (tabled layout doesn't work if used as sub-tooltip in other item or spell tooltips; use line-break instead) + */ + public function getField(string $field, bool $localized = false, bool $silent = false, ?array $enhance = []) : mixed + { + $res = parent::getField($field, $localized, $silent); + + if ($field == 'name' && !empty($enhance['r'])) + if ($this->getRandEnchantForItem($enhance['r'])) + $res .= ' '.Util::localizedString($this->enhanceR, 'name'); + + return $res; + } + + public function renderTooltip(bool $interactive = false, int $subOf = 0, ?array $enhance = []) : ?string + { + if ($this->error) + return null; + + $_name = Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_HTML); + $_reqLvl = $this->curTpl['requiredLevel']; + $_quality = $this->curTpl['quality']; + $_flags = $this->curTpl['flags']; + $_class = $this->curTpl['class']; + $_subClass = $this->curTpl['subClass']; + $_slot = $this->curTpl['slot']; + $causesScaling = false; + + if (!empty($enhance['r'])) + { + if ($this->getRandEnchantForItem($enhance['r'])) + { + $_name .= ' '.Util::localizedString($this->enhanceR, 'name'); + $randEnchant = ''; + + for ($i = 1; $i < 6; $i++) + { + if ($this->enhanceR['enchantId'.$i] <= 0) + continue; + + $enchant = DB::Aowow()->selectRow('SELECT * FROM ::itemenchantment WHERE `id` = %i', $this->enhanceR['enchantId'.$i]); + if ($this->enhanceR['allocationPct'.$i] > 0) + { + $amount = intVal($this->enhanceR['allocationPct'.$i] * $this->generateEnchSuffixFactor()); + $randEnchant .= ''.str_replace('$i', $amount, Util::localizedString($enchant, 'name')).'
'; + } + else + $randEnchant .= ''.Util::localizedString($enchant, 'name').'
'; + } + } + else + unset($enhance['r']); + } + + if (isset($enhance['s']) && !in_array($_slot, [INVTYPE_WRISTS, INVTYPE_WAIST, INVTYPE_HANDS])) + unset($enhance['s']); + + // IMPORTAT: DO NOT REMOVE THE HTML-COMMENTS! THEY ARE REQUIRED TO UPDATE THE TOOLTIP CLIENTSIDE + $x = ''; + + // upper table: stats + if (!$subOf) + $x .= '
'; + + // name; quality + if ($subOf) + $x .= ''.$_name.''; + else + $x .= ''.$_name.''; + + // heroic tag + if (($_flags & ITEM_FLAG_HEROIC) && $_quality == ITEM_QUALITY_EPIC) + $x .= '
'.Lang::item('heroic').''; + + // requires map (todo: reparse :zones for non-conflicting data; generate Link to zone) + if ($_ = $this->curTpl['map']) + { + $map = DB::Aowow()->selectRow('SELECT * FROM ::zones WHERE `mapId` = %i LIMIT 1', $_); + $x .= '
'.Util::localizedString($map, 'name').''; + } + + // requires area + if ($this->curTpl['area']) + { + $area = DB::Aowow()->selectRow('SELECT * FROM ::zones WHERE `id` = %i LIMIT 1', $this->curTpl['area']); + $x .= '
'.Util::localizedString($area, 'name'); + } + + // conjured + if ($_flags & ITEM_FLAG_CONJURED) + $x .= '
'.Lang::item('conjured'); + + // bonding + if ($_flags & ITEM_FLAG_ACCOUNTBOUND) + $x .= '
'.Lang::item('bonding', 0); + else if ($this->curTpl['bonding']) + $x .= '
'.Lang::item('bonding', $this->curTpl['bonding']); + + // unique || unique-equipped || unique-limited + if ($this->curTpl['maxCount'] == 1) + $x .= '
'.Lang::item('unique', 0); + // not for currency tokens + else if ($this->curTpl['maxCount'] && $this->curTpl['bagFamily'] != 8192) + $x .= '
'.sprintf(Lang::item('unique', 1), $this->curTpl['maxCount']); + else if ($_flags & ITEM_FLAG_UNIQUEEQUIPPED) + $x .= '
'.Lang::item('uniqueEquipped', 0); + else if ($this->curTpl['itemLimitCategory']) + { + $limit = DB::Aowow()->selectRow('SELECT * FROM ::itemlimitcategory WHERE `id` = %i', $this->curTpl['itemLimitCategory']); + $x .= '
'.sprintf(Lang::item($limit['isGem'] ? 'uniqueEquipped' : 'unique', 2), Util::localizedString($limit, 'name'), $limit['count']); + } + + // required holiday + if ($eId = $this->curTpl['eventId']) + if ($hName = DB::Aowow()->selectRow('SELECT h.* FROM ::holidays h JOIN ::events e ON e.`holidayId` = h.`id` WHERE e.`id` = %i', $eId)) + $x .= '
'.sprintf(Lang::game('requires'), ''.Util::localizedString($hName, 'name').''); + + // item begins a quest + if ($this->curTpl['startQuest']) + $x .= '
'.Lang::item('startQuest').''; + + // containerType (slotCount) + if ($this->curTpl['slots'] > 0) + { + $fam = $this->curTpl['bagFamily'] ? log($this->curTpl['bagFamily'], 2) + 1 : 0; + $x .= '
'.Lang::item('bagSlotString', [$this->curTpl['slots'], Lang::item('bagFamily', $fam)]); + } + + if (in_array($_class, [ITEM_CLASS_ARMOR, ITEM_CLASS_WEAPON, ITEM_CLASS_AMMUNITION])) + { + $x .= ''; + + // Class + if ($_slot) + $x .= ''; + + // Subclass + if ($_class == ITEM_CLASS_ARMOR && $_subClass > 0) + $x .= ''; + else if ($_class == ITEM_CLASS_WEAPON) + $x .= ''; + else if ($_class == ITEM_CLASS_AMMUNITION) + $x .= ''; + + $x .= '
'.Lang::item('inventoryType', $_slot).''.Lang::item('armorSubClass', $_subClass).''.Lang::item('weaponSubClass', $_subClass).''.Lang::item('projectileSubClass', $_subClass).'
'; + } + else if ($_slot && $_class != ITEM_CLASS_CONTAINER) // yes, slot can occur on random items and is then also displayed <_< .. excluding Bags >_> + $x .= '
'.Lang::item('inventoryType', $_slot).'
'; + else + $x .= '
'; + + // Weapon/Ammunition Stats (not limited to weapons (see item:1700)) + $speed = $this->curTpl['delay'] / 1000; + $sc1 = $this->curTpl['dmgType1']; + $sc2 = $this->curTpl['dmgType2']; + $dmgmin = $this->curTpl['tplDmgMin1'] + $this->curTpl['dmgMin2']; + $dmgmax = $this->curTpl['tplDmgMax1'] + $this->curTpl['dmgMax2']; + $dps = $speed ? ($dmgmin + $dmgmax) / (2 * $speed) : 0; + + if ($_class == ITEM_CLASS_AMMUNITION && $dmgmin && $dmgmax) + { + if ($sc1) + $x .= sprintf(Lang::item('damage', 'ammo', 1), ($dmgmin + $dmgmax) / 2, Lang::game('sc', $sc1)).'
'; + else + $x .= sprintf(Lang::item('damage', 'ammo', 0), ($dmgmin + $dmgmax) / 2).'
'; + } + else if ($dps) + { + if ($this->curTpl['tplDmgMin1'] == $this->curTpl['tplDmgMax1']) + $dmg = sprintf(Lang::item('damage', 'single', $sc1 ? 1 : 0), $this->curTpl['tplDmgMin1'], $sc1 ? Lang::game('sc', $sc1) : null); + else + $dmg = sprintf(Lang::item('damage', 'range', $sc1 ? 1 : 0), $this->curTpl['tplDmgMin1'], $this->curTpl['tplDmgMax1'], $sc1 ? Lang::game('sc', $sc1) : null); + + if ($_class == ITEM_CLASS_WEAPON) // do not use localized format here! + $x .= '
'.$dmg.''.Lang::item('speed').' '.number_format($speed, 2).'
'; + else + $x .= ''.$dmg.'
'; + + // secondary damage is set + if (($this->curTpl['dmgMin2'] || $this->curTpl['dmgMax2']) && $this->curTpl['dmgMin2'] != $this->curTpl['dmgMax2']) + $x .= sprintf(Lang::item('damage', 'range', $sc2 ? 3 : 2), $this->curTpl['dmgMin2'], $this->curTpl['dmgMax2'], $sc2 ? Lang::game('sc', $sc2) : null).'
'; + else if ($this->curTpl['dmgMin2']) + $x .= sprintf(Lang::item('damage', 'single', $sc2 ? 3 : 2), $this->curTpl['dmgMin2'], $sc2 ? Lang::game('sc', $sc2) : null).'
'; + + if ($_class == ITEM_CLASS_WEAPON) + $x .= ''.Lang::item('dps', [$dps]).'
'; + + // display FeralAttackPower if set + if ($fap = $this->getFeralAP()) + $x .= '('.$fap.' '.Lang::item('fap').')
'; + } + + // Armor + if ($_class == ITEM_CLASS_ARMOR && $this->curTpl['armorDamageModifier'] > 0) + { + $spanI = 'class="q2"'; + if ($interactive) + $spanI = 'class="q2 tip" onmouseover="$WH.Tooltip.showAtCursor(event, $WH.sprintf(LANG.tooltip_armorbonus, '.$this->curTpl['armorDamageModifier'].'), 0, 0, \'q\')" onmousemove="$WH.Tooltip.cursorUpdate(event)" onmouseout="$WH.Tooltip.hide()"'; + + $x .= ''.Lang::item('armor', [$this->curTpl['tplArmor']]).'
'; + } + else if ($this->curTpl['tplArmor']) + $x .= ''.Lang::item('armor', [$this->curTpl['tplArmor']]).'
'; + + // Block (note: block value from field block and from field stats or parsed from itemSpells are displayed independently) + if ($this->curTpl['tplBlock']) + $x .= ''.sprintf(Lang::item('block'), $this->curTpl['tplBlock']).'
'; + + // Item is a gem (don't mix with sockets) + if ($geId = $this->curTpl['gemEnchantmentId']) + { + $gemEnch = DB::Aowow()->selectRow('SELECT * FROM ::itemenchantment WHERE `id` = %i', $geId); + $x .= ''.Util::localizedString($gemEnch, 'name').'
'; + + // activation conditions for meta gems + if (!empty($gemEnch['conditionId'])) + $x .= Game::getEnchantmentCondition($gemEnch['conditionId'], $interactive); + } + + // Random Enchantment - if random enchantment is set, prepend stats from it + if ($this->curTpl['randomEnchant'] && empty($enhance['r'])) + $x .= ''.Lang::item('randEnchant').'
'; + else if (!empty($enhance['r'])) + $x .= $randEnchant; + + // itemMods (display stats and save ratings for later use) + for ($j = 1; $j <= 10; $j++) + { + $type = $this->curTpl['statType'.$j]; + $qty = $this->curTpl['statValue'.$j]; + + if (!$qty || $type <= 0) + continue; + + $statId = Stat::getIndexFrom(Stat::IDX_ITEM_MOD, $type); + + // base stat + switch ($statId) + { + case Stat::MANA: + case Stat::HEALTH: + case Stat::AGILITY: + case Stat::STRENGTH: + case Stat::INTELLECT: + case Stat::SPIRIT: + case Stat::STAMINA: + // case Stat::ARMOR: // unused by 335a client, still set in item_template + // case Stat::FIRE_RESISTANCE: + // case Stat::FROST_RESISTANCE: + // case Stat::HOLY_RESISTANCE: + // case Stat::SHADOW_RESISTANCE: + // case Stat::NATURE_RESISTANCE: + // case Stat::ARCANE_RESISTANCE: + $x .= ''.Lang::item('statType', $type, [ord($qty > 0 ? '+' : '-'), abs($qty)]).'
'; + break; + default: // rating with % for reqLevel + $green[] = $this->formatRating($statId, $type, $qty, $interactive, $causesScaling); + } + } + + // magic resistances + foreach (Game::$resistanceFields as $j => $rowName) + if ($rowName && $this->curTpl[$rowName] != 0) + $x .= '+'.$this->curTpl[$rowName].' '.Lang::game('resistances', $j).'
'; + + // Enchantment + if (isset($enhance['e'])) + { + if ($enchText = DB::Aowow()->selectRow('SELECT * FROM ::itemenchantment WHERE `id` = %s', $enhance['e'])) + $x .= ''.Util::localizedString($enchText, 'name').'
'; + else + { + unset($enhance['e']); + $x .= ''; + } + } + else // enchantment placeholder + $x .= ''; + + // Sockets w/ Gems + if (!empty($enhance['g'])) + { + $gems = DB::Aowow()->selectAssoc( + 'SELECT it.`id` AS ARRAY_KEY, ic.`name` AS "iconString", ae.*, it.`gemColorMask` AS "colorMask" + FROM ::items it + JOIN ::itemenchantment ae ON ae.`id` = it.`gemEnchantmentId` + JOIN ::icons ic ON ic.`id` = it.`iconId` + WHERE it.`id` IN %in', + $enhance['g'] + ); + + foreach ($enhance['g'] as $k => $v) + if ($v && !in_array($v, array_keys($gems))) // 0 is valid + unset($enhance['g'][$k]); + } + else + $enhance['g'] = []; + + // zero fill empty sockets + $sockCount = isset($enhance['s']) ? 1 : 0; + if (!empty($this->json[$this->id]['nsockets'])) + $sockCount += $this->json[$this->id]['nsockets']; + + while ($sockCount > count($enhance['g'])) + $enhance['g'][] = 0; + + $enhance['g'] = array_reverse($enhance['g']); + + $hasMatch = 1; + // fill native sockets + for ($j = 1; $j <= 3; $j++) + { + if (!$this->curTpl['socketColor'.$j]) + continue; + + for ($i = 0; $i < 4; $i++) + if (($this->curTpl['socketColor'.$j] & (1 << $i))) + $colorId = $i; + + $pop = array_pop($enhance['g']); + $col = $pop ? 1 : 0; + $hasMatch &= $pop ? (($gems[$pop]['colorMask'] & (1 << $colorId)) ? 1 : 0) : 0; + $icon = $pop ? sprintf('style="background-image: url(%s/images/wow/icons/tiny/%s.gif)"', Cfg::get('STATIC_URL'), strtolower($gems[$pop]['iconString'])) : null; + $text = $pop ? Util::localizedString($gems[$pop], 'name') : Lang::item('socket', $colorId); + + if ($interactive) + $x .= ''.$text.'
'; + else + $x .= ''.$text.'
'; + } + + // fill extra socket + if (isset($enhance['s'])) + { + $pop = array_pop($enhance['g']); + $col = $pop ? 1 : 0; + $icon = $pop ? sprintf('style="background-image: url(%s/images/wow/icons/tiny/%s.gif)"', Cfg::get('STATIC_URL'), strtolower($gems[$pop]['iconString'])) : null; + $text = $pop ? Util::localizedString($gems[$pop], 'name') : Lang::item('socket', -1); + + if ($interactive) + $x .= ''.$text.'
'; + else + $x .= ''.$text.'
'; + } + else // prismatic socket placeholder + $x .= ''; + + if ($_ = $this->curTpl['socketBonus']) + { + $sbonus = DB::Aowow()->selectRow('SELECT * FROM ::itemenchantment WHERE `id` = %i', $_); + $x .= ''.Lang::item('socketBonus', [''.Util::localizedString($sbonus, 'name').'']).'
'; + } + + // durability + if ($dur = $this->curTpl['durability']) + $x .= sprintf(Lang::item('durability'), $dur, $dur).'
'; + + // max duration + if ($dur = $this->curTpl['duration']) + { + $rt = ''; + if ($this->curTpl['flagsCustom'] & 0x1) + $rt = $interactive ? ' ('.sprintf(Util::$dfnString, 'LANG.tooltip_realduration', Lang::item('realTime')).')' : ' ('.Lang::item('realTime').')'; + + $x .= Lang::formatTime(abs($dur) * 1000, 'item', 'duration').$rt."
"; + } + + // required classes + $jsg = []; + if ($classes = Lang::getClassString($this->curTpl['requiredClass'], $jsg)) + { + foreach ($jsg as $js) + $this->jsGlobals[Type::CHR_CLASS][$js] ??= $js; + + $x .= Lang::game('classes').Lang::main('colon').$classes.'
'; + } + + // required races + $jsg = []; + if ($races = Lang::getRaceString($this->curTpl['requiredRace'], $jsg)) + { + foreach ($jsg as $js) + $this->jsGlobals[Type::CHR_RACE][$js] ??= $js; + + $x .= Lang::game('races').Lang::main('colon').$races.'
'; + } + + // required honorRank (not used anymore) + if ($rhr = $this->curTpl['requiredHonorRank']) + $x .= Lang::game('requires', [implode(' / ', Lang::game('pvpRank', $rhr))]).'
'; + + // required CityRank..? + // what the f.. + + // required level + if (($_flags & ITEM_FLAG_ACCOUNTBOUND) && $_quality == ITEM_QUALITY_HEIRLOOM) + $x .= sprintf(Lang::item('reqLevelRange'), 1, MAX_LEVEL, ($interactive ? sprintf(Util::$changeLevelString, MAX_LEVEL) : ''.MAX_LEVEL)).'
'; + else if ($_reqLvl > 1) + $x .= sprintf(Lang::item('reqMinLevel'), $_reqLvl).'
'; + + // required arena team rating / personal rating / todo (low): sort out what kind of rating + if (!empty($this->getExtendedCost([], $reqRating)[$this->id]) && $reqRating && $reqRating[0]) + $x .= sprintf(Lang::item('reqRating', $reqRating[1]), $reqRating[0]).'
'; + + // item level + if (in_array($_class, [ITEM_CLASS_ARMOR, ITEM_CLASS_WEAPON])) + $x .= sprintf(Lang::item('itemLevel'), $this->curTpl['itemLevel']).'
'; + + // required skill + if ($reqSkill = $this->curTpl['requiredSkill']) + { + $_ = ''.SkillList::getName($reqSkill).''; + if ($this->curTpl['requiredSkillRank'] > 0) + $_ .= ' ('.$this->curTpl['requiredSkillRank'].')'; + + $x .= sprintf(Lang::game('requires'), $_).'
'; + } + + // required spell + if ($reqSpell = $this->curTpl['requiredSpell']) + $x .= Lang::game('requires2').' '.SpellList::getName($reqSpell).'
'; + + // required reputation w/ faction + if ($reqFac = $this->curTpl['requiredFaction']) + $x .= sprintf(Lang::game('requires'), ''.FactionList::getName($reqFac).' - '.Lang::game('rep', $this->curTpl['requiredFactionRank'])).'
'; + + // locked or openable + if ($locks = Lang::getLocks($this->curTpl['lockId'], $arr, true)) + $x .= ''.Lang::item('locked').'
'.implode('
', array_map(fn($x) => Lang::game('requires', [$x]), $locks)).'

'; + else if ($this->curTpl['flags'] & ITEM_FLAG_OPENABLE) + $x .= ''.Lang::item('openClick').'
'; + + // upper table: done + if (!$subOf) + $x .= '
'; + + // spells on item + if (!$this->canTeachSpell()) + { + $itemSpellsAndTrigger = []; + for ($j = 1; $j <= 5; $j++) + { + if ($this->curTpl['spellId'.$j] > 0) + { + $cd = $this->curTpl['spellCooldown'.$j]; + if ($cd < $this->curTpl['spellCategoryCooldown'.$j]) + $cd = $this->curTpl['spellCategoryCooldown'.$j]; + + $extra = []; + if ($cd >= 5000 && $this->curTpl['spellTrigger'.$j] != SPELL_TRIGGER_EQUIP) + { + $pt = DateTime::parse($cd); + if (count(array_filter($pt)) == 1) // simple time: use simple method + $extra[] = Lang::formatTime($cd, 'item', 'cooldown'); + else // build block with generic time + $extra[] = Lang::item('cooldown', 0, [Lang::formatTime($cd, 'game', 'timeAbbrev', true)]); + } + if ($this->curTpl['spellTrigger'.$j] == SPELL_TRIGGER_HIT) + if ($ppm = $this->curTpl['spellppmRate'.$j]) + $extra[] = Lang::spell('ppm', [$ppm]); + + $itemSpellsAndTrigger[$this->curTpl['spellId'.$j]] = [$this->curTpl['spellTrigger'.$j], $extra ? ' '.implode(', ', $extra) : '']; + } + } + + if ($itemSpellsAndTrigger) + { + $itemSpells = new SpellList(array(['s.id', array_keys($itemSpellsAndTrigger)])); + foreach ($itemSpells->iterate() as $sId => $__) + { + [$parsed, $_, $scaling] = $itemSpells->parseText('description', $_reqLvl > 1 ? $_reqLvl : MAX_LEVEL); + if (!$parsed && User::isInGroup(U_GROUP_EMPLOYEE)) + $parsed = '<'.$itemSpells->getField('name', true, true).'>'; + else if (!$parsed) + continue; + + if ($scaling) + $causesScaling = true; + + if ($interactive) + { + $link = '%s'; + $parsed = preg_replace_callback('/([^;]*)( .*?<\/small>)([^&]*)/i', function($m) use($link) { + $m[1] = $m[1] ? sprintf($link, $m[1]) : ''; + $m[3] = $m[3] ? sprintf($link, $m[3]) : ''; + return $m[1].$m[2].$m[3]; + }, $parsed, -1, $nMatches + ); + + if (!$nMatches) + $parsed = sprintf($link, $parsed); + } + + $green[] = Lang::item('trigger', $itemSpellsAndTrigger[$itemSpells->id][0]).$parsed.$itemSpellsAndTrigger[$itemSpells->id][1]; + } + } + } + + // lower table (ratings, spells, ect) + if (!$subOf) + $x .= '
'; + + if (isset($green)) + foreach ($green as $j => $bonus) + if ($bonus) + $x .= ''.$bonus.'
'; + + // Item Set + $pieces = []; + if ($setId = $this->getField('itemset')) + { + $condition = [ + ['refSetId', $setId], + // ['quality', $this->curTpl['quality']], + ['minLevel', $this->curTpl['itemLevel'], '<='], + ['maxLevel', $this->curTpl['itemLevel'], '>='] + ]; + + $itemset = new ItemsetList($condition); + if (!$itemset->error && $itemset->pieceToSet) + { + // handle special cases where: + // > itemset has items of different qualities (handled by not limiting for this in the initial query) + // > itemset is virtual and multiple instances have the same itemLevel but not quality (filter below) + foreach ($itemset->iterate() as $id => $__) + { + if ($itemset->getField('quality') == $this->curTpl['quality']) + { + $itemset->pieceToSet = array_filter($itemset->pieceToSet, function($x) use ($id) { return $id == $x; }); + break; + } + } + + $pieces = DB::Aowow()->selectAssoc( + 'SELECT b.`id` AS ARRAY_KEY, b.`name_loc0`, b.`name_loc2`, b.`name_loc3`, b.`name_loc4`, b.`name_loc6`, b.`name_loc8`, GROUP_CONCAT(a.`id` SEPARATOR ":") AS "equiv" + FROM ::items a, ::items b + WHERE a.`slotBak` = b.`slotBak` AND a.`itemset` = b.`itemset` AND b.`id` IN %in + GROUP BY b.`id`', + array_keys($itemset->pieceToSet) + ); + + foreach ($pieces as $k => &$p) + $p = ''.Util::localizedString($p, 'name').''; + + $xSet = '
'.Lang::item('setName', [''.$itemset->getField('name', true).'', 0, count($pieces)]).''; + + if ($skId = $itemset->getField('skillId')) // bonus requires skill to activate + { + $xSet .= '
'.sprintf(Lang::game('requires'), ''.SkillList::getName($skId).''); + + if ($_ = $itemset->getField('skillLevel')) + $xSet .= ' ('.$_.')'; + + $xSet .= '
'; + } + + // list pieces + $xSet .= '
'.implode('
', $pieces).'

'; + + // get bonuses + $setSpellsAndIdx = []; + for ($j = 1; $j <= 8; $j++) + if ($_ = $itemset->getField('spell'.$j)) + $setSpellsAndIdx[$_] = $j; + + $setSpells = []; + if ($setSpellsAndIdx) + { + $boni = new SpellList(array(['s.id', array_keys($setSpellsAndIdx)])); + foreach ($boni->iterate() as $__) + { + [$parsed, $_, $scaling] = $boni->parseText('description', $_reqLvl > 1 ? $_reqLvl : MAX_LEVEL); + if ($scaling && $interactive) + $causesScaling = true; + + $setSpells[] = array( + 'tooltip' => $parsed, + 'entry' => $itemset->getField('spell'.$setSpellsAndIdx[$boni->id]), + 'bonus' => $itemset->getField('bonus'.$setSpellsAndIdx[$boni->id]) + ); + } + } + + // sort and list bonuses + $xSet .= ''; + for ($i = 0; $i < count($setSpells); $i++) + { + for ($j = $i; $j < count($setSpells); $j++) + { + if ($setSpells[$j]['bonus'] >= $setSpells[$i]['bonus']) + continue; + + $tmp = $setSpells[$i]; + $setSpells[$i] = $setSpells[$j]; + $setSpells[$j] = $tmp; + } + $xSet .= ''.Lang::item('setBonus', [$setSpells[$i]['bonus'], ''.$setSpells[$i]['tooltip'].'']).''; + if ($i < count($setSpells) - 1) + $xSet .= '
'; + } + $xSet .= '
'; + } + } + + // recipes, vanity pets, mounts + if ($this->canTeachSpell()) + { + $craftSpell = new SpellList(array(['s.id', intVal($this->curTpl['spellId2'])])); + if (!$craftSpell->error) + { + $xCraft = ''; + if ($desc = $this->getField('description', true)) + $x .= ''.Lang::item('trigger', SPELL_TRIGGER_USE).' '.$desc.'
'; + + // recipe handling (some stray Techniques have subclass == 0), place at bottom of tooltipp + if ($_class == ITEM_CLASS_RECIPE || $this->curTpl['bagFamily'] == 16) + { + if ($craftSpell->canCreateItem()) + { + $craftItem = new ItemList(array(['i.id', (int)$craftSpell->curTpl['effect1CreateItemId']])); + if (!$craftItem->error) + if ($itemTT = $craftItem->renderTooltip($interactive, $this->id)) + $xCraft .= '

'.$itemTT.'
'; + } + + $reagentItems = []; + for ($i = 1; $i <= 8; $i++) + if ($rId = $craftSpell->getField('reagent'.$i)) + $reagentItems[$rId] = $craftSpell->getField('reagentCount'.$i); + + if ($reagentItems) + { + $reagents = new ItemList(array(['i.id', array_keys($reagentItems)])); + $reqReag = []; + + foreach ($reagents->iterate() as $__) + $reqReag[] = ''.$reagents->getField('name', true).' ('.$reagentItems[$reagents->id].')'; + + $xCraft .= '

'.Lang::game('requires2').' '.implode(', ', $reqReag).'
'; + } + } + } + } + + // misc (no idea, how to organize the
better) + $xMisc = []; + + // itemset: pieces and boni + if (isset($xSet)) + $xMisc[] = $xSet; + + // funny, yellow text at the bottom, omit if we have a recipe + if ($this->curTpl['description_loc0'] && !$this->canTeachSpell()) + $xMisc[] = '"'.Util::parseHtmlText($this->getField('description', true), false).'"'; + + // readable + if ($this->curTpl['pageTextId']) + $xMisc[] = ''.Lang::item('readClick').''; + + // charges + for ($i = 1; $i < 6; $i++) + { + if (in_array($this->curTpl['spellTrigger'.$i], [SPELL_TRIGGER_USE, SPELL_TRIGGER_SOULSTONE, SPELL_TRIGGER_USE_NODELAY, SPELL_TRIGGER_LEARN]) && $this->curTpl['spellCharges'.$i]) + { + $xMisc[] = ''.Lang::item('charges', [abs($this->curTpl['spellCharges'.$i])]).''; + break; + } + } + + // list required reagents + if (isset($xCraft)) + $xMisc[] = $xCraft; + + if ($xMisc) + $x .= implode('
', $xMisc); + + if ($sp = $this->curTpl['sellPrice']) + $x .= '
'.Lang::item('sellPrice').Lang::main('colon').Util::formatMoney($sp).'
'; + + if (!$subOf) + $x .= '
'; + + // tooltip scaling + if (!isset($xCraft)) + { + $itemId = $subOf ?: $this->id; + + $x .= ''; + } + + return $x; + } + + public function getRandEnchantForItem(int $randId) : bool + { + // is it available for this item? .. does it even exist?! + if (empty($this->enhanceR)) + if (DB::World()->selectCell('SELECT 1 FROM item_enchantment_template WHERE `entry` = %i AND `ench` = %i', abs($this->getField('randomEnchant')), abs($randId))) + if ($_ = DB::Aowow()->selectRow('SELECT * FROM ::itemrandomenchant WHERE `id` = %i', $randId)) + $this->enhanceR = $_; + + return !empty($this->enhanceR); + } + + // from Trinity + public function generateEnchSuffixFactor() : float + { + if (empty($this->randPropPoints[$this->curTpl['itemLevel']])) + $this->randPropPoints[$this->curTpl['itemLevel']] = DB::Aowow()->selectRow('SELECT * FROM ::itemrandomproppoints WHERE `id` = %s', $this->curTpl['itemLevel']); + + $rpp = &$this->randPropPoints[$this->curTpl['itemLevel']]; + + if (!$rpp) + return 0.0; + + $fieldIdx = match((int)$this->curTpl['slot']) + { + INVTYPE_HEAD, + INVTYPE_BODY, + INVTYPE_CHEST, + INVTYPE_LEGS, + INVTYPE_2HWEAPON, + INVTYPE_ROBE => 1, + INVTYPE_SHOULDERS, + INVTYPE_WAIST, + INVTYPE_FEET, + INVTYPE_HANDS, + INVTYPE_TRINKET => 2, + INVTYPE_NECK, + INVTYPE_WRISTS, + INVTYPE_FINGER, + INVTYPE_SHIELD, + INVTYPE_CLOAK, + INVTYPE_HOLDABLE => 3, + INVTYPE_WEAPON, + INVTYPE_WEAPONMAINHAND, + INVTYPE_WEAPONOFFHAND => 4, + INVTYPE_RANGED, + INVTYPE_THROWN, + INVTYPE_RANGEDRIGHT => 5, + default => 0 // inv types that don`t have points + }; + + if (!$fieldIdx) + return 0.0; + + // Select rare/epic modifier + return match((int)$this->curTpl['quality']) + { + ITEM_QUALITY_UNCOMMON => $rpp['uncommon'.$fieldIdx] / 10000, + ITEM_QUALITY_RARE => $rpp['rare'.$fieldIdx] / 10000, + ITEM_QUALITY_EPIC => $rpp['epic'.$fieldIdx] / 10000, + default => 0.0 // qualities that don't have random properties + }; + } + + public function extendJsonStats() : void + { + $enchantments = []; // buffer Ids for lookup id => src; src>0: socketBonus; src<0: gemEnchant + + foreach ($this->iterate() as $__) + { + // fetch and add socketbonusstats + if (!empty($this->json[$this->id]['socketbonus'])) + $enchantments[$this->json[$this->id]['socketbonus']][] = $this->id; + + // Item is a gem (don't mix with sockets) + if ($geId = $this->curTpl['gemEnchantmentId']) + $enchantments[$geId][] = -$this->id; + } + + if ($enchantments) + { + $eStats = DB::Aowow()->selectAssoc('SELECT *, `typeId` AS ARRAY_KEY FROM ::item_stats WHERE `type` = %i AND `typeId` IN %in', Type::ENCHANTMENT, array_keys($enchantments)); + + // and merge enchantments back + foreach ($enchantments as $eId => $items) + { + if (empty($eStats[$eId])) + continue; + + foreach ($items as $item) + { + if ($item > 0) // apply socketBonus + $this->json[$item]['socketbonusstat'] = array_filter($eStats[$eId]); + else /* if ($item < 0) */ // apply gemEnchantment + Util::arraySumByKey($this->json[-$item], array_filter($eStats[$eId])); + } + } + } + + foreach ($this->json as $item => $json) + foreach ($json as $k => $v) + if (!$v && !in_array($k, ['classs', 'subclass', 'quality', 'side', 'gearscore'])) + unset($this->json[$item][$k]); + } + + public function getOnUseStats() : ?StatsContainer + { + if ($this->curTpl['class'] != ITEM_CLASS_CONSUMABLE) + return null; + + $onUseStats = new StatsContainer(); + + // convert Spells + for ($h = 1; $h <= 5; $h++) + { + if ($this->curTpl['spellId'.$h] <= 0) + continue; + + if ($this->curTpl['spellTrigger'.$h] != SPELL_TRIGGER_USE) + continue; + + if ($spell = DB::Aowow()->selectRow( + 'SELECT `effect1Id`, `effect1TriggerSpell`, `effect1AuraId`, `effect1MiscValue`, `effect1BasePoints`, `effect1DieSides`, + `effect2Id`, `effect2TriggerSpell`, `effect2AuraId`, `effect2MiscValue`, `effect2BasePoints`, `effect2DieSides`, + `effect3Id`, `effect3TriggerSpell`, `effect3AuraId`, `effect3MiscValue`, `effect3BasePoints`, `effect3DieSides` + FROM ::spell + WHERE `id` = %i', + $this->curTpl['spellId'.$h] + )) + $onUseStats->fromSpell($spell); + } + + return $onUseStats; + } + + public function getSourceData(int $id = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if ($id && $id != $this->id) + continue; + + $data[$this->id] = array( + 'n' => $this->getField('name', true), + 't' => Type::ITEM, + 'ti' => $this->id, + 'q' => $this->curTpl['quality'], + // 'p' => PvP [NYI] + 'icon' => $this->curTpl['iconString'] + ); + } + + return $data; + } + + private function canTeachSpell() : bool + { + if (!in_array($this->curTpl['spellId1'], LEARN_SPELLS)) + return false; + + // needs learnable spell + if (!$this->curTpl['spellId2']) + return false; + + return true; + } + + private function getFeralAP() : float + { + // must be weapon + if ($this->curTpl['class'] != ITEM_CLASS_WEAPON) + return 0.0; + + // thats fucked up.. + if (!$this->curTpl['delay']) + return 0.0; + + // must have enough damage + $dps = ($this->curTpl['tplDmgMin1'] + $this->curTpl['dmgMin2'] + $this->curTpl['tplDmgMax1'] + $this->curTpl['dmgMax2']) / (2 * $this->curTpl['delay'] / 1000); + if ($dps <= 54.8) + return 0.0; + + $subClasses = [ITEM_SUBCLASS_MISC_WEAPON]; + $weaponTypeMask = DB::Aowow()->selectCell('SELECT `weaponTypeMask` FROM ::classes WHERE `id` = %i', ChrClass::DRUID->value); + if ($weaponTypeMask) + for ($i = 0; $i < 21; $i++) + if ($weaponTypeMask & (1 << $i)) + $subClasses[] = $i; + + // cannot be used by druids + if (!in_array($this->curTpl['subClass'], $subClasses)) + return 0.0; + + return round(($dps - 54.8) * 14); + } + + public function isRangedWeapon() : bool + { + if ($this->curTpl['class'] != ITEM_CLASS_WEAPON) + return false; + + return in_array($this->curTpl['subClassBak'], [ITEM_SUBCLASS_BOW, ITEM_SUBCLASS_GUN, ITEM_SUBCLASS_THROWN, ITEM_SUBCLASS_CROSSBOW, ITEM_SUBCLASS_WAND]); + } + + public function isBodyArmor() : bool + { + if ($this->curTpl['class'] != ITEM_CLASS_ARMOR) + return false; + + return in_array($this->curTpl['subClassBak'], [ITEM_SUBCLASS_CLOTH_ARMOR, ITEM_SUBCLASS_LEATHER_ARMOR, ITEM_SUBCLASS_MAIL_ARMOR, ITEM_SUBCLASS_PLATE_ARMOR]); + } + + public function isDisplayable() : bool + { + if (!$this->curTpl['displayId']) + return false; + + return in_array($this->curTpl['slot'], array( + INVTYPE_HEAD, INVTYPE_SHOULDERS, INVTYPE_BODY, INVTYPE_CHEST, INVTYPE_WAIST, INVTYPE_LEGS, INVTYPE_FEET, INVTYPE_WRISTS, + INVTYPE_HANDS, INVTYPE_WEAPON, INVTYPE_SHIELD, INVTYPE_RANGED, INVTYPE_CLOAK, INVTYPE_2HWEAPON, INVTYPE_TABARD, INVTYPE_ROBE, + INVTYPE_WEAPONMAINHAND, INVTYPE_WEAPONOFFHAND, INVTYPE_HOLDABLE, INVTYPE_THROWN, INVTYPE_RANGEDRIGHT)); + } + + private function formatRating(int $statId, int $itemMod, int $qty, bool $interactive = false, bool &$scaling = false) : string + { + // clamp level range + $ssdLvl = isset($this->ssd[$this->id]) ? $this->ssd[$this->id]['maxLevel'] : 1; + $reqLvl = $this->curTpl['requiredLevel'] > 1 ? $this->curTpl['requiredLevel'] : MAX_LEVEL; + $level = min(max($reqLvl, $ssdLvl), MAX_LEVEL); + + // unknown rating + if (!$statId) + { + if (User::isInGroup(U_GROUP_EMPLOYEE)) + return Lang::item('statType', count(Lang::item('statType')) - 1, [$itemMod, $qty]); + else + return ''; + } + + // level independent Bonus + if (Stat::isLevelIndependent($statId)) + return Lang::item('trigger', SPELL_TRIGGER_EQUIP).str_replace('%d', ''.$qty, Lang::item('statType', $itemMod)); + + // rating-Bonuses + $scaling = true; + + if ($interactive) + $js = ' ('.sprintf(Util::$changeLevelString, Util::setRatingLevel($level, $statId, $qty)).')'; + else + $js = ' ('.Util::setRatingLevel($level, $statId, $qty).')'; + + return Lang::item('trigger', SPELL_TRIGGER_EQUIP).str_replace('%d', ''.$qty.$js, Lang::item('statType', $itemMod)); + } + + private function getSSDMod(string $type) : int + { + $mask = $this->curTpl['scalingStatValue']; + + $mask &= match ($type) + { + 'stats' => 0x04001F, + 'armor' => 0xF001E0, + 'dps' => 0x007E00, + 'spell' => 0x008000, + 'fap' => 0x010000, // unused + default => 0x0 + }; + + $field = null; + for ($i = 0; $i < count(Util::$ssdMaskFields); $i++) + if ($mask & (1 << $i)) + $field = Util::$ssdMaskFields[$i]; + + return $field ? DB::Aowow()->selectCell('SELECT %n FROM ::scalingstatvalues WHERE `id` = %i', $field, $this->ssd[$this->id]['maxLevel']) : 0; + } + + private function initScalingStats() : void + { + $this->ssd[$this->id] = DB::Aowow()->selectRow('SELECT * FROM ::scalingstatdistribution WHERE `id` = %i', $this->curTpl['scalingStatDistribution']); + + if (!$this->ssd[$this->id]) + return; + + // stats and ratings + for ($i = 1; $i <= 10; $i++) + { + if ($this->ssd[$this->id]['statMod'.$i] <= 0) + { + $this->templates[$this->id]['statType'.$i] = 0; + $this->templates[$this->id]['statValue'.$i] = 0; + } + else + { + $this->templates[$this->id]['statType'.$i] = $this->ssd[$this->id]['statMod'.$i]; + $this->templates[$this->id]['statValue'.$i] = intVal(($this->getSSDMod('stats') * $this->ssd[$this->id]['modifier'.$i]) / 10000); + } + } + + // armor: only replace if set + if ($ssvArmor = $this->getSSDMod('armor')) + $this->templates[$this->id]['armor'] = $ssvArmor; + + // if set dpsMod in ScalingStatValue use it for min/max damage + // mle: 20% range / rgd: 30% range + if ($extraDPS = $this->getSSDMod('dps')) // dmg_x2 not used for heirlooms + { + $range = isset($this->json[$this->id]['rgddps']) ? 0.3 : 0.2; + $average = $extraDPS * $this->curTpl['delay'] / 1000; + + $this->templates[$this->id]['tplDmgMin1'] = floor((1 - $range) * $average); + $this->templates[$this->id]['tplDmgMax1'] = floor((1 + $range) * $average); + } + + // apply Spell Power from ScalingStatValue if set + if ($spellBonus = $this->getSSDMod('spell')) + { + $this->templates[$this->id]['statType10'] = ITEM_MOD_SPELL_POWER; + $this->templates[$this->id]['statValue10'] = $spellBonus; + } + } + + public function initSubItems() : void + { + if (!array_keys($this->templates)) + return; + + $subItemIds = []; + foreach ($this->iterate() as $__) + if ($_ = $this->getField('randomEnchant')) + $subItemIds[abs($_)] = $_; + + if (!$subItemIds) + return; + + // remember: id < 0: randomSuffix; id > 0: randomProperty + $subItemTpls = DB::World()->selectAssoc( + 'SELECT CAST( `entry` AS SIGNED) AS ARRAY_KEY, CAST( `ench` AS SIGNED) AS ARRAY_KEY2, `chance` FROM item_enchantment_template WHERE `entry` IN %in UNION + SELECT CAST(-`entry` AS SIGNED) AS ARRAY_KEY, CAST(-`ench` AS SIGNED) AS ARRAY_KEY2, `chance` FROM item_enchantment_template WHERE `entry` IN %in', + array_keys(array_filter($subItemIds, fn($v) => $v > 0)) ?: [0], + array_keys(array_filter($subItemIds, fn($v) => $v < 0)) ?: [0] + ); + + $randIds = []; + foreach ($subItemTpls as $tpl) + $randIds = array_merge($randIds, array_keys($tpl)); + + if (!$randIds) + return; + + $randEnchants = DB::Aowow()->selectAssoc('SELECT *, `id` AS ARRAY_KEY FROM ::itemrandomenchant WHERE `id` IN %in', $randIds); + $enchIds = array_unique(array_merge( + array_column($randEnchants, 'enchantId1'), + array_column($randEnchants, 'enchantId2'), + array_column($randEnchants, 'enchantId3'), + array_column($randEnchants, 'enchantId4'), + array_column($randEnchants, 'enchantId5') + )); + + $enchants = new EnchantmentList(array(['id', $enchIds])); + foreach ($enchants->iterate() as $eId => $_) + { + $this->rndEnchIds[$eId] = array( + 'text' => $enchants->getField('name', true), + 'stats' => $enchants->getStatGainForCurrent() + ); + } + + foreach ($this->iterate() as $mstItem => $__) + { + if (!$this->getField('randomEnchant')) + continue; + + if (empty($subItemTpls[$this->getField('randomEnchant')])) + continue; + + foreach ($subItemTpls[$this->getField('randomEnchant')] as $subId => $data) + { + if (empty($randEnchants[$subId])) + continue; + + $data = array_merge($randEnchants[$subId], $data); + $jsonEquip = []; + $jsonText = []; + + for ($i = 1; $i < 6; $i++) + { + $enchId = $data['enchantId'.$i]; + if ($enchId <= 0 || empty($this->rndEnchIds[$enchId])) + continue; + + if ($data['allocationPct'.$i] > 0) // RandomSuffix: scaling Enchantment; enchId < 0 + { + $qty = intVal($data['allocationPct'.$i] * $this->generateEnchSuffixFactor()); + $stats = array_fill_keys(array_keys($this->rndEnchIds[$enchId]['stats']), $qty); + + $jsonText[$enchId] = str_replace('$i', $qty, $this->rndEnchIds[$enchId]['text']); + Util::arraySumByKey($jsonEquip, $stats); + } + else // RandomProperty: static Enchantment; enchId > 0 + { + $jsonText[$enchId] = $this->rndEnchIds[$enchId]['text']; + Util::arraySumByKey($jsonEquip, $this->rndEnchIds[$enchId]['stats']); + } + } + + $this->subItems[$mstItem][$subId] = array( + 'name' => Util::localizedString($data, 'name'), + 'enchantment' => $jsonText, + 'jsonequip' => $jsonEquip, + 'chance' => $data['chance'] // hmm, only needed for item detail page... + ); + } + + if (!empty($this->subItems[$mstItem])) + $this->json[$mstItem]['subitems'] = $this->subItems[$mstItem]; + } + } + + public function getScoreTotal(int $class = 0, array $spec = [], int $mhItem = 0, int $ohItem = 0) : int + { + if (!$class || !$spec) + return array_sum(array_column($this->json, 'gearscore')); + + $score = 0.0; + $mh = $oh = []; + + foreach ($this->json as $j) + { + if ($j['id'] == $mhItem) + $mh = $j; + else if ($j['id'] == $ohItem) + $oh = $j; + else if (!empty($j['gearscore'])) + { + if ($j['slot'] == INVTYPE_RELIC) + $score += 20; + + $score += round($j['gearscore']); + } + } + + $score += array_sum(Util::fixWeaponScores($class, $spec, $mh, $oh)); + + return $score; + } + + private function initJsonStats() : void + { + $class = $this->curTpl['class']; + $subclass = $this->curTpl['subClass']; + + $json = array( + 'id' => $this->id, + 'quality' => ITEM_QUALITY_HEIRLOOM - $this->curTpl['quality'], + 'classs' => $class, + 'subclass' => $subclass, + 'subsubclass' => $this->curTpl['subSubClass'], + 'heroic' => ($this->curTpl['flags'] & ITEM_FLAG_HEROIC) >> 3, + 'side' => $this->curTpl['flagsExtra'] & 0x3 ? SIDE_BOTH - ($this->curTpl['flagsExtra'] & 0x3) : ChrRace::sideFromMask($this->curTpl['requiredRace']), + 'slot' => $this->curTpl['slot'], + 'slotbak' => $this->curTpl['slotBak'], + 'level' => $this->curTpl['itemLevel'], + 'reqlevel' => $this->curTpl['requiredLevel'], + 'displayid' => $this->curTpl['displayId'], + 'holres' => $this->curTpl['resHoly'], + 'firres' => $this->curTpl['resFire'], + 'natres' => $this->curTpl['resNature'], + 'frores' => $this->curTpl['resFrost'], + 'shares' => $this->curTpl['resShadow'], + 'arcres' => $this->curTpl['resArcane'], + 'armorbonus' => $class != ITEM_CLASS_ARMOR ? 0 : max(0, intVal($this->curTpl['armorDamageModifier'])), + 'armor' => $this->curTpl['tplArmor'], + 'dura' => $this->curTpl['durability'], + 'itemset' => $this->curTpl['itemset'], + 'socket1' => $this->curTpl['socketColor1'], + 'socket2' => $this->curTpl['socketColor2'], + 'socket3' => $this->curTpl['socketColor3'], + 'nsockets' => ($this->curTpl['socketColor1'] > 0 ? 1 : 0) + ($this->curTpl['socketColor2'] > 0 ? 1 : 0) + ($this->curTpl['socketColor3'] > 0 ? 1 : 0), + 'socketbonus' => $this->curTpl['socketBonus'], + 'scadist' => $this->curTpl['scalingStatDistribution'], + 'scaflags' => $this->curTpl['scalingStatValue'] + ); + + $json = array_map('intval', $json); + + $json['name'] = $this->getField('name', true); + $json['icon'] = $this->curTpl['iconString']; + + if ($class == ITEM_CLASS_AMMUNITION) + $json['dps'] = round(($this->curTpl['tplDmgMin1'] + $this->curTpl['dmgMin2'] + $this->curTpl['tplDmgMax1'] + $this->curTpl['dmgMax2']) / 2, 2); + else if ($class == ITEM_CLASS_WEAPON) + { + $json['dmgtype1'] = (int)$this->curTpl['dmgType1']; + $json['dmgmin1'] = (int)($this->curTpl['tplDmgMin1'] + $this->curTpl['dmgMin2']); + $json['dmgmax1'] = (int)($this->curTpl['tplDmgMax1'] + $this->curTpl['dmgMax2']); + $json['speed'] = round($this->curTpl['delay'] / 1000, 2); + $json['dps'] = $json['speed'] ? round(($json['dmgmin1'] + $json['dmgmax1']) / (2 * $json['speed']), 1) : 0.0; + + if ($this->isRangedWeapon()) + { + $json['rgddmgmin'] = $json['dmgmin1']; + $json['rgddmgmax'] = $json['dmgmax1']; + $json['rgdspeed'] = $json['speed']; + $json['rgddps'] = $json['dps']; + } + else + { + $json['mledmgmin'] = $json['dmgmin1']; + $json['mledmgmax'] = $json['dmgmax1']; + $json['mlespeed'] = $json['speed']; + $json['mledps'] = $json['dps']; + } + + if ($fap = $this->getFeralAP()) + $json['feratkpwr'] = $fap; + } + + if ($class == ITEM_CLASS_ARMOR || $class == ITEM_CLASS_WEAPON) + $json['gearscore'] = Util::getEquipmentScore($json['level'], $this->getField('quality'), $json['slot'], $json['nsockets']); + else if ($class == ITEM_CLASS_GEM) + $json['gearscore'] = Util::getGemScore($json['level'], $this->getField('quality'), $this->getField('requiredSkill') == SKILL_JEWELCRAFTING, $this->id); + + // clear zero-values afterwards + foreach ($json as $k => $v) + if (!$v && !in_array($k, ['classs', 'subclass', 'quality', 'side', 'gearscore'])) + unset($json[$k]); + + $this->json[$json['id']] = $json; + } +} + + +class ItemListFilter extends Filter +{ + public const /* int */ GROUP_BY_NONE = 0; + public const /* int */ GROUP_BY_SLOT = 1; + public const /* int */ GROUP_BY_LEVEL = 2; + public const /* int */ GROUP_BY_SOURCE = 3; + + private array $ubFilter = []; // usable-by - limit weapon/armor selection per CharClass - itemClass => available itemsubclasses + private string $extCostQuery = 'SELECT `item` FROM npc_vendor WHERE `extendedCost` IN %in UNION + SELECT `item` FROM game_event_npc_vendor WHERE `extendedCost` IN %in'; + + protected string $type = 'items'; + protected static array $enums = array( + 16 => parent::ENUM_ZONE, // drops in zone + 17 => parent::ENUM_FACTION, // requiresrepwith + 99 => parent::ENUM_PROFESSION, // requiresprof + 86 => parent::ENUM_PROFESSION, // craftedprof + 87 => parent::ENUM_PROFESSION, // reagentforability + 105 => parent::ENUM_HEROICDUNGEON, // drops in nh dungeon + 106 => parent::ENUM_HEROICDUNGEON, // drops in hc dungeon + 126 => parent::ENUM_ZONE, // rewardedbyquestin + 147 => parent::ENUM_MULTIMODERAID, // drops in nh raid 10 + 148 => parent::ENUM_MULTIMODERAID, // drops in nh raid 25 + 149 => parent::ENUM_HEROICRAID, // drops in hc raid 10 + 150 => parent::ENUM_HEROICRAID, // drops in hc raid 25 + 152 => parent::ENUM_CLASSS, // class-specific + 153 => parent::ENUM_RACE, // race-specific + 160 => parent::ENUM_EVENT, // relatedevent + 169 => parent::ENUM_EVENT, // requiresevent + 158 => parent::ENUM_CURRENCY, // purchasablewithcurrency + 118 => array( // itemcurrency + 52027, 52030, 52026, 52029, 52025, 52028, 47242, 47557, 47558, 47559, 45632, 45633, 45634, 45635, 45636, 45637, 45638, 45639, 45640, 45641, + 45642, 45643, 45644, 45645, 45646, 45647, 45648, 45649, 45650, 45651, 45652, 45653, 45654, 45655, 45656, 45657, 45658, 45659, 45660, 45661, + 40625, 40626, 40627, 40610, 40611, 40612, 40631, 40632, 40633, 40628, 40629, 40630, 40613, 40614, 40615, 40616, 40617, 40618, 40619, 40620, + 40621, 40634, 40635, 40636, 40637, 40638, 40639, 40622, 40623, 40624, 34853, 34854, 34855, 34856, 34857, 34858, 34848, 34851, 34852, 31089, + 31091, 31090, 31092, 31094, 31093, 31097, 31095, 31096, 31098, 31100, 31099, 31101, 31103, 31102, 30236, 30237, 30238, 30239, 30240, 30241, + 30242, 30243, 30244, 30245, 30246, 30247, 30248, 30249, 30250, 29754, 29753, 29755, 29757, 29758, 29756, 29760, 29761, 29759, 29766, 29767, + 29765, 29763, 29764, 29762, 34169, 34186, 34245, 34332, 34339, 34345, 34244, 34208, 34180, 34229, 34350, 34342, 34211, 34243, 34216, 34167, + 34170, 34192, 34233, 34234, 34202, 34195, 34209, 34193, 34212, 34351, 34215 + ), + 163 => array( // enchantment mats + 34057, 22445, 11176, 34052, 11082, 34055, 16203, 10939, 11135, 11175, 22446, 16204, 34054, 14344, 11084, 11139, 22449, 11178, 10998, 34056, + 16202, 10938, 11134, 11174, 22447, 20725, 14343, 34053, 10978, 11138, 22448, 11177, 11083, 10940, 11137, 22450 + ), + 91 => array( // tool + 3, 14, 162, 168, 141, 2, 4, 169, 161, 15, 167, 81, 21, 165, 12, 62, 10, 101, 189, 6, + 63, 41, 8, 7, 190, 9, 166, 121, 5 + ), + 66 => array( // profession specialization + 1 => -1, + 2 => [ 9788, 9787, 17041, 17040, 17039 ], + 3 => -1, + 4 => -1, + 5 => [20219, 20222 ], + 6 => -1, + 7 => -1, + 8 => [10656, 10658, 10660 ], + 9 => -1, + 10 => [26798, 26801, 26797 ], + 11 => [ 9788, 9787, 17041, 17040, 17039, 20219, 20222, 10656, 10658, 10660, 26798, 26801, 26797], // i know, i know .. lazy as fuck + 12 => false, + 13 => -1, + 14 => -1, + 15 => -1 + ), + 128 => array( // source + 1 => true, // Any + 2 => false, // None + 3 => SRC_CRAFTED, + 4 => SRC_DROP, + 5 => SRC_PVP, + 6 => SRC_QUEST, + 7 => SRC_VENDOR, + 9 => SRC_STARTER, + 10 => SRC_EVENT, + 11 => SRC_ACHIEVEMENT, + 12 => SRC_FISHING + ) + ); + + protected static array $genericFilter = array( + 2 => [parent::CR_CALLBACK, 'cbFieldHasVal', 'bonding', 1 ], // bindonpickup [yn] + 3 => [parent::CR_CALLBACK, 'cbFieldHasVal', 'bonding', 2 ], // bindonequip [yn] + 4 => [parent::CR_CALLBACK, 'cbFieldHasVal', 'bonding', 3 ], // bindonuse [yn] + 5 => [parent::CR_CALLBACK, 'cbFieldHasVal', 'bonding', [4, 5] ], // questitem [yn] + 6 => [parent::CR_CALLBACK, 'cbQuestRelation', null, null ], // startsquest [side] + 7 => [parent::CR_BOOLEAN, 'description_loc0', true ], // hasflavortext + 8 => [parent::CR_BOOLEAN, 'requiredDisenchantSkill' ], // disenchantable + 9 => [parent::CR_FLAG, 'flags', ITEM_FLAG_CONJURED ], // conjureditem + 10 => [parent::CR_BOOLEAN, 'lockId' ], // locked + 11 => [parent::CR_FLAG, 'flags', ITEM_FLAG_OPENABLE ], // openable + 12 => [parent::CR_BOOLEAN, 'itemset' ], // partofset + 13 => [parent::CR_BOOLEAN, 'randomEnchant' ], // randomlyenchanted + 14 => [parent::CR_BOOLEAN, 'pageTextId' ], // readable + 15 => [parent::CR_CALLBACK, 'cbFieldHasVal', 'maxCount', 1 ], // unique [yn] + 16 => [parent::CR_CALLBACK, 'cbDropsInZone', null, null ], // dropsin [zone] + 17 => [parent::CR_ENUM, 'requiredFaction', true, true ], // requiresrepwith + 18 => [parent::CR_CALLBACK, 'cbFactionQuestReward', null, null ], // rewardedbyfactionquest [side] + 20 => [parent::CR_NUMERIC, 'is.str', NUM_CAST_INT, true ], // str + 21 => [parent::CR_NUMERIC, 'is.agi', NUM_CAST_INT, true ], // agi + 22 => [parent::CR_NUMERIC, 'is.sta', NUM_CAST_INT, true ], // sta + 23 => [parent::CR_NUMERIC, 'is.int', NUM_CAST_INT, true ], // int + 24 => [parent::CR_NUMERIC, 'is.spi', NUM_CAST_INT, true ], // spi + 25 => [parent::CR_NUMERIC, 'is.arcres', NUM_CAST_INT, true ], // arcres + 26 => [parent::CR_NUMERIC, 'is.firres', NUM_CAST_INT, true ], // firres + 27 => [parent::CR_NUMERIC, 'is.natres', NUM_CAST_INT, true ], // natres + 28 => [parent::CR_NUMERIC, 'is.frores', NUM_CAST_INT, true ], // frores + 29 => [parent::CR_NUMERIC, 'is.shares', NUM_CAST_INT, true ], // shares + 30 => [parent::CR_NUMERIC, 'is.holres', NUM_CAST_INT, true ], // holres + 32 => [parent::CR_NUMERIC, 'is.dps', NUM_CAST_FLOAT, true ], // dps + 33 => [parent::CR_NUMERIC, 'is.dmgmin1', NUM_CAST_INT, true ], // dmgmin1 + 34 => [parent::CR_NUMERIC, 'is.dmgmax1', NUM_CAST_INT, true ], // dmgmax1 + 35 => [parent::CR_CALLBACK, 'cbDamageType', null, null ], // damagetype [enum] + 36 => [parent::CR_NUMERIC, 'is.speed', NUM_CAST_FLOAT, true ], // speed + 37 => [parent::CR_NUMERIC, 'is.mleatkpwr', NUM_CAST_INT, true ], // mleatkpwr + 38 => [parent::CR_NUMERIC, 'is.rgdatkpwr', NUM_CAST_INT, true ], // rgdatkpwr + 39 => [parent::CR_NUMERIC, 'is.rgdhitrtng', NUM_CAST_INT, true ], // rgdhitrtng + 40 => [parent::CR_NUMERIC, 'is.rgdcritstrkrtng', NUM_CAST_INT, true ], // rgdcritstrkrtng + 41 => [parent::CR_NUMERIC, 'is.armor', NUM_CAST_INT, true ], // armor + 42 => [parent::CR_NUMERIC, 'is.defrtng', NUM_CAST_INT, true ], // defrtng + 43 => [parent::CR_NUMERIC, 'is.block', NUM_CAST_INT, true ], // block + 44 => [parent::CR_NUMERIC, 'is.blockrtng', NUM_CAST_INT, true ], // blockrtng + 45 => [parent::CR_NUMERIC, 'is.dodgertng', NUM_CAST_INT, true ], // dodgertng + 46 => [parent::CR_NUMERIC, 'is.parryrtng', NUM_CAST_INT, true ], // parryrtng + 48 => [parent::CR_NUMERIC, 'is.splhitrtng', NUM_CAST_INT, true ], // splhitrtng + 49 => [parent::CR_NUMERIC, 'is.splcritstrkrtng', NUM_CAST_INT, true ], // splcritstrkrtng + 50 => [parent::CR_NUMERIC, 'is.splheal', NUM_CAST_INT, true ], // splheal + 51 => [parent::CR_NUMERIC, 'is.spldmg', NUM_CAST_INT, true ], // spldmg + 52 => [parent::CR_NUMERIC, 'is.arcsplpwr', NUM_CAST_INT, true ], // arcsplpwr + 53 => [parent::CR_NUMERIC, 'is.firsplpwr', NUM_CAST_INT, true ], // firsplpwr + 54 => [parent::CR_NUMERIC, 'is.frosplpwr', NUM_CAST_INT, true ], // frosplpwr + 55 => [parent::CR_NUMERIC, 'is.holsplpwr', NUM_CAST_INT, true ], // holsplpwr + 56 => [parent::CR_NUMERIC, 'is.natsplpwr', NUM_CAST_INT, true ], // natsplpwr + 57 => [parent::CR_NUMERIC, 'is.shasplpwr', NUM_CAST_INT, true ], // shasplpwr + 59 => [parent::CR_NUMERIC, 'durability', NUM_CAST_INT, true ], // dura + 60 => [parent::CR_NUMERIC, 'is.healthrgn', NUM_CAST_INT, true ], // healthrgn + 61 => [parent::CR_NUMERIC, 'is.manargn', NUM_CAST_INT, true ], // manargn + 62 => [parent::CR_CALLBACK, 'cbCooldown', null, null ], // cooldown [op] [int] + 63 => [parent::CR_NUMERIC, 'buyPrice', NUM_CAST_INT, true ], // buyprice + 64 => [parent::CR_NUMERIC, 'sellPrice', NUM_CAST_INT, true ], // sellprice + 65 => [parent::CR_CALLBACK, 'cbAvgMoneyContent', null, null ], // avgmoney [op] [int] + 66 => [parent::CR_ENUM, 'requiredSpell' ], // requiresprofspec + 68 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_DISENCHANTMENT, null ], // otdisenchanting [yn] + 69 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_FISHING, null ], // otfishing [yn] + 70 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_GATHERING, null ], // otherbgathering [yn] + 71 => [parent::CR_FLAG, 'cuFlags', ITEM_CU_OT_ITEMLOOT ], // otitemopening [yn] + 72 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_DROP, null ], // otlooting [yn] + 73 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_MINING, null ], // otmining [yn] + 74 => [parent::CR_FLAG, 'cuFlags', ITEM_CU_OT_OBJECTLOOT ], // otobjectopening [yn] + 75 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_PICKPOCKETING, null ], // otpickpocketing [yn] + 76 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_SKINNING, null ], // otskinning [yn] + 77 => [parent::CR_NUMERIC, 'is.atkpwr', NUM_CAST_INT, true ], // atkpwr + 78 => [parent::CR_NUMERIC, 'is.mlehastertng', NUM_CAST_INT, true ], // mlehastertng + 79 => [parent::CR_NUMERIC, 'is.resirtng', NUM_CAST_INT, true ], // resirtng + 80 => [parent::CR_CALLBACK, 'cbHasSockets', null, null ], // has sockets [enum] + 81 => [parent::CR_CALLBACK, 'cbFitsGemSlot', null, null ], // fits gem slot [enum] + 83 => [parent::CR_FLAG, 'flags', ITEM_FLAG_UNIQUEEQUIPPED ], // uniqueequipped + 84 => [parent::CR_NUMERIC, 'is.mlecritstrkrtng', NUM_CAST_INT, true ], // mlecritstrkrtng + 85 => [parent::CR_CALLBACK, 'cbObjectiveOfQuest', null, null ], // objectivequest [side] + 86 => [parent::CR_CALLBACK, 'cbCraftedByProf', null, null ], // craftedprof [enum] + 87 => [parent::CR_CALLBACK, 'cbReagentForAbility', null, null ], // reagentforability [enum] + 88 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_PROSPECTING, null ], // otprospecting [yn] + 89 => [parent::CR_FLAG, 'flags', ITEM_FLAG_PROSPECTABLE ], // prospectable + 90 => [parent::CR_CALLBACK, 'cbAvgBuyout', null, null ], // avgbuyout [op] [int] + 91 => [parent::CR_ENUM, 'totemCategory', false, true ], // tool + 92 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_VENDOR, null ], // soldbyvendor [yn] + 93 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_PVP, null ], // otpvp [pvp] + 94 => [parent::CR_NUMERIC, 'is.splpen', NUM_CAST_INT, true ], // splpen + 95 => [parent::CR_NUMERIC, 'is.mlehitrtng', NUM_CAST_INT, true ], // mlehitrtng + 96 => [parent::CR_NUMERIC, 'is.critstrkrtng', NUM_CAST_INT, true ], // critstrkrtng + 97 => [parent::CR_NUMERIC, 'is.feratkpwr', NUM_CAST_INT, true ], // feratkpwr + 98 => [parent::CR_FLAG, 'flags', ITEM_FLAG_PARTYLOOT ], // partyloot + 99 => [parent::CR_ENUM, 'requiredSkill' ], // requiresprof + 100 => [parent::CR_NUMERIC, 'is.nsockets', NUM_CAST_INT ], // nsockets + 101 => [parent::CR_NUMERIC, 'is.rgdhastertng', NUM_CAST_INT, true ], // rgdhastertng + 102 => [parent::CR_NUMERIC, 'is.splhastertng', NUM_CAST_INT, true ], // splhastertng + 103 => [parent::CR_NUMERIC, 'is.hastertng', NUM_CAST_INT, true ], // hastertng + 104 => [parent::CR_STRING, 'description', STR_LOCALIZED, 'nml.nDescription'], // flavortext + 105 => [parent::CR_CALLBACK, 'cbDropsInInstance', SRC_FLAG_DUNGEON_DROP, 1 ], // dropsinnormal [heroicdungeon-any] + 106 => [parent::CR_CALLBACK, 'cbDropsInInstance', SRC_FLAG_DUNGEON_DROP, 2 ], // dropsinheroic [heroicdungeon-any] + 107 => [parent::CR_STRING, '', STR_LOCALIZED, 'nml.nEffects' ], // effecttext [str] + 109 => [parent::CR_CALLBACK, 'cbArmorBonus', null, null ], // armorbonus [op] [int] + 111 => [parent::CR_NUMERIC, 'requiredSkillRank', NUM_CAST_INT, true ], // reqskillrank + 113 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 114 => [parent::CR_NUMERIC, 'is.armorpenrtng', NUM_CAST_INT, true ], // armorpenrtng + 115 => [parent::CR_NUMERIC, 'is.health', NUM_CAST_INT, true ], // health + 116 => [parent::CR_NUMERIC, 'is.mana', NUM_CAST_INT, true ], // mana + 117 => [parent::CR_NUMERIC, 'is.exprtng', NUM_CAST_INT, true ], // exprtng + 118 => [parent::CR_CALLBACK, 'cbPurchasableWith', null, null ], // purchasablewithitem [enum] + 119 => [parent::CR_NUMERIC, 'is.hitrtng', NUM_CAST_INT, true ], // hitrtng + 123 => [parent::CR_NUMERIC, 'is.splpwr', NUM_CAST_INT, true ], // splpwr + 124 => [parent::CR_CALLBACK, 'cbHasRandEnchant', null, null ], // randomenchants [str] + 125 => [parent::CR_CALLBACK, 'cbReqArenaRating', null, null ], // reqarenartng [op] [int] todo (low): 'find out, why "IN (W, X, Y) AND IN (X, Y, Z)" doesn't result in "(X, Y)" + 126 => [parent::CR_CALLBACK, 'cbQuestRewardIn', null, null ], // rewardedbyquestin [zone-any] + 128 => [parent::CR_CALLBACK, 'cbSource', null, null ], // source [enum] + 129 => [parent::CR_CALLBACK, 'cbSoldByNPC', null, null ], // soldbynpc [str-small] + 130 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 132 => [parent::CR_CALLBACK, 'cbGlyphType', null, null ], // glyphtype [enum] + 133 => [parent::CR_FLAG, 'flags', ITEM_FLAG_ACCOUNTBOUND ], // accountbound + 134 => [parent::CR_NUMERIC, 'is.mledps', NUM_CAST_FLOAT, true ], // mledps + 135 => [parent::CR_NUMERIC, 'is.mledmgmin', NUM_CAST_INT, true ], // mledmgmin + 136 => [parent::CR_NUMERIC, 'is.mledmgmax', NUM_CAST_INT, true ], // mledmgmax + 137 => [parent::CR_NUMERIC, 'is.mlespeed', NUM_CAST_FLOAT, true ], // mlespeed + 138 => [parent::CR_NUMERIC, 'is.rgddps', NUM_CAST_FLOAT, true ], // rgddps + 139 => [parent::CR_NUMERIC, 'is.rgddmgmin', NUM_CAST_INT, true ], // rgddmgmin + 140 => [parent::CR_NUMERIC, 'is.rgddmgmax', NUM_CAST_INT, true ], // rgddmgmax + 141 => [parent::CR_NUMERIC, 'is.rgdspeed', NUM_CAST_FLOAT, true ], // rgdspeed + 142 => [parent::CR_STRING, 'ic.name' ], // icon + 143 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_MILLING, null ], // otmilling [yn] + 144 => [parent::CR_CALLBACK, 'cbPvpPurchasable', 'reqHonorPoints', null ], // purchasablewithhonor [yn] + 145 => [parent::CR_CALLBACK, 'cbPvpPurchasable', 'reqArenaPoints', null ], // purchasablewitharena [yn] + 146 => [parent::CR_FLAG, 'flags', ITEM_FLAG_HEROIC ], // heroic + 147 => [parent::CR_CALLBACK, 'cbDropsInInstance', SRC_FLAG_RAID_DROP, 1, ], // dropsinnormal10 [multimoderaid-any] + 148 => [parent::CR_CALLBACK, 'cbDropsInInstance', SRC_FLAG_RAID_DROP, 2, ], // dropsinnormal25 [multimoderaid-any] + 149 => [parent::CR_CALLBACK, 'cbDropsInInstance', SRC_FLAG_RAID_DROP, 4, ], // dropsinheroic10 [heroicraid-any] + 150 => [parent::CR_CALLBACK, 'cbDropsInInstance', SRC_FLAG_RAID_DROP, 8, ], // dropsinheroic25 [heroicraid-any] + 151 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true ], // id + 152 => [parent::CR_CALLBACK, 'cbClassRaceSpec', 'requiredClass' ], // classspecific [enum] + 153 => [parent::CR_CALLBACK, 'cbClassRaceSpec', 'requiredRace' ], // racespecific [enum] + 154 => [parent::CR_FLAG, 'flags', ITEM_FLAG_REFUNDABLE ], // refundable + 155 => [parent::CR_FLAG, 'flags', ITEM_FLAG_USABLE_ARENA ], // usableinarenas + 156 => [parent::CR_FLAG, 'flags', ITEM_FLAG_USABLE_SHAPED ], // usablewhenshapeshifted + 157 => [parent::CR_FLAG, 'flags', ITEM_FLAG_SMARTLOOT ], // smartloot + 158 => [parent::CR_CALLBACK, 'cbPurchasableWith', null, null ], // purchasablewithcurrency [enum] + 159 => [parent::CR_FLAG, 'flags', ITEM_FLAG_MILLABLE ], // millable + 160 => [parent::CR_NYI_PH, null, 1, ], // relatedevent [enum] like 169 .. crawl though npc_vendor and loot_templates of event-related spawns + 161 => [parent::CR_CALLBACK, 'cbAvailable', null, null ], // availabletoplayers [yn] + 162 => [parent::CR_FLAG, 'flags', ITEM_FLAG_DEPRECATED ], // deprecated + 163 => [parent::CR_CALLBACK, 'cbDisenchantsInto', null, null ], // disenchantsinto [disenchanting] + 165 => [parent::CR_NUMERIC, 'repairPrice', NUM_CAST_INT, true ], // repaircost + 167 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 168 => [parent::CR_CALLBACK, 'cbFieldHasVal', 'spellId1', LEARN_SPELLS ], // teachesspell [yn] + 169 => [parent::CR_ENUM, 'e.holidayId', true, true ], // requiresevent + 171 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_REDEMPTION, null ], // otredemption [yn] + 172 => [parent::CR_CALLBACK, 'cbObtainedBy', SRC_ACHIEVEMENT, null ], // rewardedbyachievement [yn] + 176 => [parent::CR_STAFFFLAG, 'flags' ], // flags + 177 => [parent::CR_STAFFFLAG, 'flagsExtra' ], // flags2 + ); + + protected static array $inputFields = array( + 'wt' => [parent::V_CALLBACK, 'cbWeightKeyCheck', true ], // weight keys + 'wtv' => [parent::V_RANGE, [1, 999], true ], // weight values + 'jc' => [parent::V_LIST, [1], false], // use jewelcrafter gems for weight calculation + 'gm' => [parent::V_LIST, [2, 3, 4], false], // gem rarity for weight calculation + 'cr' => [parent::V_RANGE, [1, 177], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 99999]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_CRV, true ], // criteria values - only printable chars, no delimiters + 'upg' => [parent::V_REGEX, '/[^\d:]/ui', true ], // upgrade item ids + 'gb' => [parent::V_LIST, [0, 1, 2, 3], false], // search result grouping + 'na' => [parent::V_NAME, false, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'ub' => [parent::V_LIST, [[1, 9], 11], false], // usable by classId + 'qu' => [parent::V_RANGE, [0, 7], true ], // quality ids + 'ty' => [parent::V_CALLBACK, 'cbTypeCheck', true ], // item type - dynamic by current group + 'sl' => [parent::V_CALLBACK, 'cbSlotCheck', true ], // item slot - dynamic by current group + 'si' => [parent::V_LIST, [-SIDE_HORDE, -SIDE_ALLIANCE, SIDE_ALLIANCE, SIDE_HORDE, SIDE_BOTH], false], // side + 'minle' => [parent::V_RANGE, [0, 999], false], // item level min + 'maxle' => [parent::V_RANGE, [0, 999], false], // item level max + 'minrl' => [parent::V_RANGE, [0, MAX_LEVEL], false], // required level min + 'maxrl' => [parent::V_RANGE, [0, MAX_LEVEL], false] // required level max + ); + + public array $extraOpts = []; // score for statWeights + public array $wtCnd = []; + + public function createConditionsForWeights() : array + { + if (empty($this->values['wt'])) + return []; + + $this->wtCnd = []; + $select = []; + $wtSum = 0; + + foreach ($this->values['wt'] as $k => $v) + { + if ($str = Stat::getWeightJson($v)) + { + $qty = intVal($this->values['wtv'][$k]); + + $select[] = '(IFNULL(`is`.`'.$str.'`, 0) * '.$qty.')'; + $this->wtCnd[] = ['is.'.$str, 0, '>']; + $wtSum += $qty; + } + } + + if (count($this->wtCnd) > 1) + array_unshift($this->wtCnd, DB::OR); + else if (count($this->wtCnd) == 1) + $this->wtCnd = $this->wtCnd[0]; + + if ($select) + { + $this->extraOpts['is']['s'][] = ', IF(`is`.`typeId` IS NULL, 0, ('.implode(' + ', $select).') / '.$wtSum.') AS "score"'; + $this->extraOpts['is']['o'][] = 'score DESC'; + $this->extraOpts['i']['o'][] = null; // remove default ordering + } + else + $this->extraOpts['is']['s'][] = ', 0 AS "score"'; // prevent errors + + return $this->wtCnd; + } + + public function getConditions() : array + { + if (!$this->ubFilter) + { + $classes = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `weaponTypeMask` AS "0", `armorTypeMask` AS "1" FROM ::classes'); + foreach ($classes as $cId => [$weaponTypeMask, $armorTypeMask]) + { + // preselect misc subclasses + $this->ubFilter[$cId] = [ITEM_CLASS_WEAPON => [ITEM_SUBCLASS_MISC_WEAPON], ITEM_CLASS_ARMOR => [ITEM_SUBCLASS_MISC_ARMOR]]; + + for ($i = 0; $i < 21; $i++) + if ($weaponTypeMask & (1 << $i)) + $this->ubFilter[$cId][ITEM_CLASS_WEAPON][] = $i; + + for ($i = 0; $i < 11; $i++) + if ($armorTypeMask & (1 << $i)) + $this->ubFilter[$cId][ITEM_CLASS_ARMOR][] = $i; + } + } + + return parent::getConditions(); + } + + protected function createSQLForValues() : array + { + $parts = []; + $_v = $this->values; + + // weights [list] + if ($_v['wt'] && $_v['wtv']) + { + // gm - gem quality (qualityId) + // jc - jc-gems included (bool) + + if ($_ = $this->createConditionsForWeights()) + $parts[] = $_; + + foreach ($_v['wt'] as $_) + $this->fiExtraCols[] = $_; + } + + // upgrade for [list] + if ($_v['upg']) + { + if ($this->upgrades = DB::Aowow()->selectCol('SELECT `id` AS ARRAY_KEY, `slot` FROM ::items WHERE `class` IN %in AND `id` IN %in', [ITEM_CLASS_WEAPON, ITEM_CLASS_GEM, ITEM_CLASS_ARMOR], $_v['upg'])) + $parts[] = ['slot', $this->upgrades]; + else + $_v['upg'] = null; + } + + // name + if ($_v['na']) + { + if ($_ = $this->buildMatchLookup([['na', 'nml.nName']])) + $parts[] = $_; + else if ($_ = $this->buildLikeLookup([['na', 'name_loc'.Lang::getLocale()->value]])) + $parts[] = $_; + } + + // usable-by (not excluded by requiredClass && armor or weapons match mask from ::classes) + if ($_v['ub']) + { + $parts[] = array( + DB::AND, + [DB::OR, ['requiredClass', 0], ['requiredClass', $this->list2Mask((array)$_v['ub']), '&']], + [ + DB::OR, + ['class', [ITEM_CLASS_WEAPON, ITEM_CLASS_ARMOR], '!'], + [DB::AND, ['class', ITEM_CLASS_WEAPON], ['subclassbak', $this->ubFilter[$_v['ub']][ITEM_CLASS_WEAPON]]], + [DB::AND, ['class', ITEM_CLASS_ARMOR], ['subclassbak', $this->ubFilter[$_v['ub']][ITEM_CLASS_ARMOR]]] + ] + ); + } + + // quality [list] + if ($_v['qu']) + $parts[] = ['quality', $_v['qu']]; + + // type [list] + if ($_v['ty']) + $parts[] = ['subclass', $_v['ty']]; + + // slot [list] + if ($_v['sl']) + $parts[] = ['slot', $_v['sl']]; + + // side + if ($_v['si']) + { + $parts[] = match ($_v['si']) + { + SIDE_BOTH => [DB::OR, [['flagsExtra', 0x3, '&'], [0, 3]], ['requiredRace', 0]], + -SIDE_HORDE => [DB::OR, [['flagsExtra', 0x3, '&'], 1], ['requiredRace', ChrRace::MASK_HORDE, '&']], + -SIDE_ALLIANCE => [DB::OR, [['flagsExtra', 0x3, '&'], 2], ['requiredRace', ChrRace::MASK_ALLIANCE, '&']], + SIDE_HORDE => [DB::AND, [['flagsExtra', 0x3, '&'], [0, 1]], [DB::OR, ['requiredRace', 0], ['requiredRace', ChrRace::MASK_HORDE, '&']]], + SIDE_ALLIANCE => [DB::AND, [['flagsExtra', 0x3, '&'], [0, 2]], [DB::OR, ['requiredRace', 0], ['requiredRace', ChrRace::MASK_ALLIANCE, '&']]], + }; + } + + // itemLevel min + if ($_v['minle']) + $parts[] = ['itemLevel', $_v['minle'], '>=']; + + // itemLevel max + if ($_v['maxle']) + $parts[] = ['itemLevel', $_v['maxle'], '<=']; + + // reqLevel min + if ($_v['minrl']) + $parts[] = ['requiredLevel', $_v['minrl'], '>=']; + + // reqLevel max + if ($_v['maxrl']) + $parts[] = ['requiredLevel', $_v['maxrl'], '<=']; + + return $parts; + } + + protected function cbFactionQuestReward(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + 1 => ['src.src4', null, '!'], // Yes + 2 => ['src.src4', SIDE_ALLIANCE], // Alliance + 3 => ['src.src4', SIDE_HORDE], // Horde + 4 => ['src.src4', SIDE_BOTH], // Both + 5 => ['src.src4', null], // No + default => null + }; + } + + protected function cbAvailable(int $cr, int $crs, string $crv) : ?array + { + if ($this->int2Bool($crs)) + return [['cuFlags', CUSTOM_UNAVAILABLE, '&'], 0, $crs ? null : '!']; + + return null; + } + + protected function cbHasSockets(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + // Meta, Red, Yellow, Blue + 1, 2, 3, 4 => [DB::OR, ['socketColor1', 1 << ($crs - 1)], ['socketColor2', 1 << ($crs - 1)], ['socketColor3', 1 << ($crs - 1)]], + 5 => ['is.nsockets', 0, '!'], // Yes + 6 => ['is.nsockets', 0], // No + default => null + }; + } + + protected function cbFitsGemSlot(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + // Meta, Red, Yellow, Blue + 1, 2, 3, 4 => [DB::AND, ['gemEnchantmentId', 0, '!'], ['gemColorMask', 1 << ($crs - 1), '&']], + 5 => ['gemEnchantmentId', 0, '!'], // Yes + 6 => ['gemEnchantmentId', 0], // No + default => null + }; + } + + protected function cbGlyphType(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + // major, minor + 1, 2 => [DB::AND, ['class', ITEM_CLASS_GLYPH], ['subSubClass', $crs]], + default => null + }; + } + + protected function cbHasRandEnchant(int $cr, int $crs, string $crv) : ?array + { + $n = preg_replace(parent::PATTERN_NAME, '', $crv); + if (!$this->tokenizeString($cr, $n)) + return null; + + $where = []; + foreach ($this->inTokens[$cr] ?? [] as $tok) + $where[] = ['name_loc%i LIKE %~like~', Lang::getLocale()->value, $tok]; + foreach ($this->exTokens[$cr] ?? [] as $tok) + $where[] = ['name_loc%i NOT LIKE %~like~', Lang::getLocale()->value, $tok]; + + $randIds = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, ABS(`id`) AS `id`, name_loc%i, `name_loc0` FROM ::itemrandomenchant WHERE %and', Lang::getLocale()->value, $where); + $tplIds = $randIds ? DB::World()->selectAssoc('SELECT `entry`, `ench` FROM item_enchantment_template WHERE `ench` IN %in', array_column($randIds, 'id')) : []; + foreach ($tplIds as &$set) + { + $z = array_column($randIds, 'id'); + $x = array_search($set['ench'], $z); + if (isset($randIds[-$z[$x]])) + { + $set['entry'] *= -1; + $set['ench'] *= -1; + } + + $set['name'] = Util::localizedString($randIds[$set['ench']], 'name', true); + } + + // only enhance search results if enchantment by name is unique (implies only one enchantment per item is available) + if (count(array_unique(array_column($randIds, 'name_loc0'))) == 1) + $this->extraOpts['relEnchant'] = $tplIds; + + if ($tplIds) + return ['randomEnchant', array_column($tplIds, 'entry')]; + else + return [0]; // no results aren't really input errors + } + + protected function cbReqArenaRating(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + $this->fiExtraCols[] = $cr; + + $items = [0]; + if ($costs = DB::Aowow()->selectCol('SELECT `id` FROM ::itemextendedcost WHERE `reqPersonalrating` %SQL %i', $crs, $crv)) + $items = DB::World()->selectCol($this->extCostQuery, $costs, $costs); + + return ['id', $items]; + } + + protected function cbClassRaceSpec(int $cr, int $crs, string $crv, string $field) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if (is_bool($_)) + return $_ ? [$field, 0, '>'] : [$field, 0]; + else if (is_int($_)) + return [$field, 1 << ($_ - 1), '&']; + + return null; + } + + protected function cbDamageType(int $cr, int $crs, string $crv) : ?array + { + if (!$this->checkInput(parent::V_RANGE, [SPELL_SCHOOL_NORMAL, SPELL_SCHOOL_ARCANE], $crs)) + return null; + + return [DB::OR, ['dmgType1', $crs], ['dmgType2', $crs]]; + } + + protected function cbArmorBonus(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_FLOAT) || !$this->int2Op($crs)) + return null; + + $this->fiExtraCols[] = $cr; + return [DB::AND, ['armordamagemodifier', $crv, $crs], ['class', ITEM_CLASS_ARMOR]]; + } + + protected function cbCraftedByProf(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if (is_bool($_)) + return ['src.src1', null, $_ ? '!' : null]; + else if (is_int($_)) + return ['s.skillLine1', $_]; + + return null; + } + + protected function cbQuestRewardIn(int $cr, int $crs, string $crv) : ?array + { + if (in_array($crs, self::$enums[$cr])) + return [DB::AND, ['src.src4', null, '!'], ['src.moreZoneId', $crs]]; + else if ($crs == parent::ENUM_ANY) + return ['src.src4', null, '!']; // well, this seems a bit redundant.. + + return null; + } + + protected function cbDropsInZone(int $cr, int $crs, string $crv) : ?array + { + if (in_array($crs, self::$enums[$cr])) + return [DB::AND, ['src.src2', null, '!'], ['src.moreZoneId', $crs]]; + else if ($crs == parent::ENUM_ANY) + return ['src.src2', null, '!']; // well, this seems a bit redundant.. + + return null; + } + + protected function cbDropsInInstance(int $cr, int $crs, string $crv, int $moreFlag, int $modeBit) : ?array + { + if (in_array($crs, self::$enums[$cr])) + return [DB::AND, ['src.src2', $modeBit, '&'], ['src.moreMask', $moreFlag, '&'], ['src.moreZoneId', $crs]]; + else if ($crs == parent::ENUM_ANY) + return [DB::AND, ['src.src2', $modeBit, '&'], ['src.moreMask', $moreFlag, '&']]; + + return null; + } + + protected function cbPurchasableWith(int $cr, int $crs, string $crv) : ?array + { + if (in_array($crs, self::$enums[$cr])) + $_ = (array)$crs; + else if ($crs == parent::ENUM_ANY) + $_ = self::$enums[$cr]; + else + return null; + + $costs = DB::Aowow()->selectCol( + 'SELECT `id` FROM ::itemextendedcost WHERE `reqItemId1` IN %in OR `reqItemId2` IN %in OR `reqItemId3` IN %in OR `reqItemId4` IN %in OR `reqItemId5` IN %in', + $_, $_, $_, $_, $_ + ); + if ($items = DB::World()->selectCol($this->extCostQuery, $costs, $costs)) + return ['id', $items]; + + return null; + } + + protected function cbSoldByNPC(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT)) + return null; + + if ($iIds = DB::World()->selectCol('SELECT `item` FROM npc_vendor WHERE `entry` = %i UNION SELECT `item` FROM game_event_npc_vendor v JOIN creature c ON c.`guid` = v.`guid` WHERE c.`id` = %i', $crv, $crv)) + return ['i.id', $iIds]; + else + return [0]; + } + + protected function cbAvgBuyout(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + foreach (Profiler::getRealms() as $rId => $__) + { + // todo: do something sensible.. + // // todo (med): get the avgbuyout into the listview + // if ($_ = DB::Characters()->selectAssoc('SELECT ii.itemEntry AS ARRAY_KEY, AVG(ah.buyoutprice / ii.count) AS buyout FROM auctionhouse ah JOIN item_instance ii ON ah.itemguid = ii.guid GROUP BY ii.itemEntry HAVING buyout '.$crs.' %f', $c[1])) + // return ['i.id', array_keys($_)]; + // else + // return [0]; + return [1]; + } + + return [0]; + } + + protected function cbAvgMoneyContent(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + $this->fiExtraCols[] = $cr; + return [DB::AND, ['flags', ITEM_FLAG_OPENABLE, '&'], ['((minMoneyLoot + maxMoneyLoot) / 2)', $crv, $crs]]; + } + + protected function cbCooldown(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + $crv *= 1000; // field supplied in milliseconds + + $this->fiExtraCols[] = $cr; + $this->extraOpts['is']['s'][] = ', GREATEST(`spellCooldown1`, `spellCooldown2`, `spellCooldown3`, `spellCooldown4`, `spellCooldown5`) AS "cooldown"'; + + return [ + DB::OR, + [DB::AND, ['spellTrigger1', SPELL_TRIGGER_USE], ['spellId1', 0, '!'], ['spellCooldown1', 0, '>'], ['spellCooldown1', $crv, $crs]], + [DB::AND, ['spellTrigger2', SPELL_TRIGGER_USE], ['spellId2', 0, '!'], ['spellCooldown2', 0, '>'], ['spellCooldown2', $crv, $crs]], + [DB::AND, ['spellTrigger3', SPELL_TRIGGER_USE], ['spellId3', 0, '!'], ['spellCooldown3', 0, '>'], ['spellCooldown3', $crv, $crs]], + [DB::AND, ['spellTrigger4', SPELL_TRIGGER_USE], ['spellId4', 0, '!'], ['spellCooldown4', 0, '>'], ['spellCooldown4', $crv, $crs]], + [DB::AND, ['spellTrigger5', SPELL_TRIGGER_USE], ['spellId5', 0, '!'], ['spellCooldown5', 0, '>'], ['spellCooldown5', $crv, $crs]], + ]; + } + + protected function cbQuestRelation(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + // any + 1 => ['startQuest', 0, '>'], + // exclude horde only + 2 => [DB::AND, ['startQuest', 0, '>'], [['flagsExtra', 0x3, '&'], SIDE_HORDE]], + // exclude alliance only + 3 => [DB::AND, ['startQuest', 0, '>'], [['flagsExtra', 0x3, '&'], SIDE_ALLIANCE]], + // both + 4 => [DB::AND, ['startQuest', 0, '>'], [['flagsExtra', 0x3, '&'], 0]], + // none + 5 => ['startQuest', 0], + default => null + }; + } + + protected function cbFieldHasVal(int $cr, int $crs, string $crv, string $field, mixed $val) : ?array + { + if ($this->int2Bool($crs)) + return [$field, $val, $crs ? null : '!']; + + return null; + } + + protected function cbObtainedBy(int $cr, int $crs, string $crv, string $field) : ?array + { + if ($this->int2Bool($crs)) + return ['src.src'.$field, null, $crs ? '!' : null]; + + return null; + } + + protected function cbPvpPurchasable(int $cr, int $crs, string $crv, string $field) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + $costs = DB::Aowow()->selectCol('SELECT `id` FROM ::itemextendedcost WHERE %n > 0', $field); + if ($items = DB::World()->selectCol($this->extCostQuery, $costs, $costs)) + return ['id', $items, $crs ? null : '!']; + + return null; + } + + protected function cbDisenchantsInto(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crs, NUM_CAST_INT)) + return null; + + if (!in_array($crs, self::$enums[$cr])) + return null; + + $refResults = []; + $newRefs = DB::World()->selectCol('SELECT `entry` FROM %n WHERE `item` = %i AND `reference` = 0', Loot::REFERENCE, $crs); + while ($newRefs) + { + $refResults += $newRefs; + $newRefs = DB::World()->selectCol('SELECT `entry` FROM %n WHERE `reference` IN %in', Loot::REFERENCE, $newRefs); + } + + $lootIds = DB::World()->selectCol('SELECT `entry` FROM %n', Loot::DISENCHANT, 'WHERE %if', $refResults, '`reference` IN %in OR', $refResults, '%end (`reference` = 0 AND `item` = %i)', $crs); + + return $lootIds ? ['disenchantId', $lootIds] : [0]; + } + + protected function cbObjectiveOfQuest(int $cr, int $crs, string $crv) : ?array + { + $where = match ($crs) + { + // Yes / No + 1, 5 => [1], + // Alliance + 2 => [['`reqRaceMask` & %i', ChrRace::MASK_ALLIANCE], ['(`reqRaceMask` & %i) = 0', ChrRace::MASK_HORDE]], + // Horde + 3 => [['`reqRaceMask` & %i', ChrRace::MASK_HORDE], ['(`reqRaceMask` & %i) = 0', ChrRace::MASK_ALLIANCE]], + // Both + 4 => [[DB::OR, [['`reqRaceMask` = 0'], [DB::AND, [['`reqRaceMask` & %i', ChrRace::MASK_ALLIANCE], ['`reqRaceMask` & %i', ChrRace::MASK_HORDE]]]]]], + default => null + }; + + if (!$where) + return [0]; + + $itemIds = DB::Aowow()->selectCol( + 'SELECT `reqItemId1` FROM ::quests WHERE %and UNION SELECT `reqItemId2` FROM ::quests WHERE %and UNION + SELECT `reqItemId3` FROM ::quests WHERE %and UNION SELECT `reqItemId4` FROM ::quests WHERE %and UNION + SELECT `reqItemId5` FROM ::quests WHERE %and UNION SELECT `reqItemId6` FROM ::quests WHERE %and', + $where, $where, $where, $where, $where, $where + ); + + if ($itemIds) + return ['id', $itemIds, $crs == 5 ? '!' : null]; + + return [0]; + } + + protected function cbReagentForAbility(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if ($_ === null) + return null; + + $ids = []; + $spells = DB::Aowow()->selectAssoc( // todo (med): hmm, selecting all using SpellList would exhaust 128MB of memory :x .. see, that we only select the fields that are really needed + 'SELECT `reagent1`, `reagent2`, `reagent3`, `reagent4`, `reagent5`, `reagent6`, `reagent7`, `reagent8`, + `reagentCount1`, `reagentCount2`, `reagentCount3`, `reagentCount4`, `reagentCount5`, `reagentCount6`, `reagentCount7`, `reagentCount8` + FROM ::spell + WHERE `skillLine1` IN %in', + is_bool($_) ? array_filter(self::$enums[99], "is_numeric") : $_ + ); + foreach ($spells as $spell) + for ($i = 1; $i < 9; $i++) + if ($spell['reagent'.$i] > 0 && $spell['reagentCount'.$i] > 0) + $ids[] = $spell['reagent'.$i]; + + if (empty($ids)) + return [0]; + else if ($_) + return ['id', $ids]; + else + return ['id', $ids, '!']; + } + + protected function cbSource(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if (is_int($_)) // specific + return ['src.src'.$_, null, '!']; + else if ($_) // any + { + $foo = [DB::OR]; + foreach (self::$enums[$cr] as $bar) + if (is_int($bar)) + $foo[] = ['src.src'.$bar, null, '!']; + + return $foo; + } + else // none + return ['src.typeId', null]; + } + + protected function cbTypeCheck(string &$v) : bool + { + if (!$this->parentCats) + return false; + + if (!Util::checkNumeric($v, NUM_CAST_INT)) + return false; + + $c = $this->parentCats; + + if (isset($c[2]) && is_array(Lang::item('cat', $c[0], 1, $c[1]))) + $catList = Lang::item('cat', $c[0], 1, $c[1], 1, $c[2]); + else if (isset($c[1]) && is_array(Lang::item('cat', $c[0]))) + $catList = Lang::item('cat', $c[0], 1, $c[1]); + else + $catList = Lang::item('cat', $c[0]); + + // consumables - always + if ($c[0] == ITEM_CLASS_CONSUMABLE) + return in_array($v, array_keys(Lang::item('cat', 0, 1))); + // weapons - only if parent + else if ($c[0] == ITEM_CLASS_WEAPON && !isset($c[1])) + return in_array($v, array_keys(Lang::spell('weaponSubClass'))); + // armor - only if parent + else if ($c[0] == ITEM_CLASS_ARMOR && !isset($c[1])) + return in_array($v, array_keys(Lang::item('cat', ITEM_CLASS_ARMOR, 1))); + // uh ... other stuff... + else if (!isset($c[1]) && in_array($c[0], [ITEM_CLASS_CONTAINER, ITEM_CLASS_GEM, ITEM_CLASS_TRADEGOOD, ITEM_CLASS_RECIPE, ITEM_CLASS_MISC])) + return in_array($v, array_keys($catList[1])); + + return false; + } + + protected function cbSlotCheck(string &$v) : bool + { + if (!Util::checkNumeric($v, NUM_CAST_INT)) + return false; + + // todo (low): limit to concrete slots + $sl = array_keys(Lang::item('inventoryType')); + $c = $this->parentCats; + + // no selection + if (!isset($c[0])) + return in_array($v, $sl); + + // consumables - any; perm / temp item enhancements + else if ($c[0] == ITEM_CLASS_CONSUMABLE && (!isset($c[1]) || in_array($c[1], [-3, 6]))) + return in_array($v, $sl); + + // weapons - always + else if ($c[0] == ITEM_CLASS_WEAPON) + return in_array($v, $sl); + + // armor - any; any armor + else if ($c[0] == ITEM_CLASS_ARMOR && (!isset($c[1]) || in_array($c[1], [ITEM_SUBCLASS_CLOTH_ARMOR, ITEM_SUBCLASS_LEATHER_ARMOR, ITEM_SUBCLASS_MAIL_ARMOR, ITEM_SUBCLASS_PLATE_ARMOR]))) + return in_array($v, $sl); + + return false; + } + + protected function cbWeightKeyCheck(string &$v) : bool + { + if (preg_match('/\W/i', $v)) + return false; + + return Stat::getIndexFrom(Stat::IDX_FILTER_CR_ID, $v) > 0; + } +} + +?> diff --git a/includes/dbtypes/itemset.class.php b/includes/dbtypes/itemset.class.php new file mode 100644 index 00000000..a80e5886 --- /dev/null +++ b/includes/dbtypes/itemset.class.php @@ -0,0 +1,251 @@ + ['o' => 'maxlevel DESC'], + 'e' => ['j' => ['::events e ON `e`.`id` = `set`.`eventId`', true], 's' => ', e.`holidayId`'], + 'src' => ['j' => ['::source src ON `src`.`typeId` = `set`.`id` AND `src`.`type` = 4', true], 's' => ', `src1`, `src2`, `src3`, `src4`, `src5`, `src6`, `src7`, `src8`, `src9`, `src10`, `src11`, `src12`, `src13`, `src14`, `src15`, `src16`, `src17`, `src18`, `src19`, `src20`, `src21`, `src22`, `src23`, `src24`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + // post processing + foreach ($this->iterate() as &$_curTpl) + { + $_curTpl['classes'] = ChrClass::fromMask($_curTpl['classMask']); + $this->classes = array_merge($this->classes, $_curTpl['classes']); + + $_curTpl['pieces'] = []; + for ($i = 1; $i < 10; $i++) + { + if ($piece = $_curTpl['item'.$i]) + { + $_curTpl['pieces'][] = $piece; + $this->pieceToSet[$piece] = $this->id; + } + } + } + $this->classes = array_unique($this->classes); + } + + public function getListviewData() : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'idbak' => $this->curTpl['refSetId'], + 'name' => (7 - $this->curTpl['quality']).$this->getField('name', true), + 'minlevel' => $this->curTpl['minLevel'], + 'maxlevel' => $this->curTpl['maxLevel'], + 'note' => $this->curTpl['contentGroup'], + 'type' => $this->curTpl['type'], + 'reqclass' => $this->curTpl['classMask'], + 'classes' => $this->curTpl['classes'], + 'pieces' => $this->curTpl['pieces'], + 'heroic' => $this->curTpl['heroic'] + ); + } + + return $data; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array + { + $data = []; + + if ($this->classes && ($addMask & GLOBALINFO_RELATED)) + $data[Type::CHR_CLASS] = array_combine($this->classes, $this->classes); + + if ($this->pieceToSet && ($addMask & GLOBALINFO_SELF)) + $data[Type::ITEM] = array_combine(array_keys($this->pieceToSet), array_keys($this->pieceToSet)); + + if ($addMask & GLOBALINFO_SELF) + foreach ($this->iterate() as $id => $__) + $data[Type::ITEMSET][$id] = ['name' => $this->getField('name', true)]; + + return $data; + } + + public function renderTooltip() : ?string + { + if (!$this->curTpl) + return null; + + $x = '
'; + $x .= ''.$this->getField('name', true).'
'; + + $nCl = 0; + if ($_ = $this->getField('classMask')) + { + $jsg = []; + $cl = Lang::getClassString($_, $jsg); + $t = count($jsg) == 1 ? Lang::game('class') : Lang::game('classes'); + $x .= Util::ucFirst($t).Lang::main('colon').$cl.'
'; + } + + if ($_ = $this->getField('contentGroup')) + $x .= Lang::itemset('notes', $_).($this->getField('heroic') ? ' ('.Lang::item('heroic').')' : '').'
'; + + if (!$nCl || !$this->getField('type')) + $x.= Lang::itemset('types', $this->getField('type')).'
'; + + if ($bonuses = $this->getBonuses()) + { + $x .= ''; + + foreach ($bonuses as [$nItems, , $text]) + $x .= '
'.Lang::itemset('_pieces', [$nItems]).''.$text; + + $x .= '
'; + } + + $x .= '
'; + + return $x; + } + + public function getBonuses() : array + { + $spells = []; + for ($i = 1; $i < 9; $i++) + { + $spl = $this->getField('spell'.$i); + $qty = $this->getField('bonus'.$i); + + // cant use spell as index, would change order + if ($spl && $qty) + $spells[] = [$qty, $spl]; + } + + // sort by required pieces ASC + usort($spells, fn(array $a, array $b) => $a[0] <=> $b[0]); + + $setSpells = new SpellList(array(['s.id', array_column($spells, 1)])); + foreach ($spells as &$s) + { + if ($setSpells->getEntry($s[1])) + $s[2] = $setSpells->parseText('description', $this->getField('reqLevel') ?: MAX_LEVEL)[0]; + else + $s[2] = Lang::spell('unkAura', [$s[1]]); + } + + return $spells; + } +} + + +// missing filter: "Available to Players" +class ItemsetListFilter extends Filter +{ + protected string $type = 'itemsets'; + protected static array $enums = array( + 6 => parent::ENUM_EVENT + ); + + protected static array $genericFilter = array( + 2 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true], // id + 3 => [parent::CR_NUMERIC, 'npieces', NUM_CAST_INT ], // pieces + 4 => [parent::CR_STRING, 'bonusText', STR_LOCALIZED ], // bonustext + 5 => [parent::CR_BOOLEAN, 'heroic' ], // heroic + 6 => [parent::CR_ENUM, 'e.holidayId', true, true], // relatedevent + 8 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 9 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 10 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 12 => [parent::CR_CALLBACK, 'cbAvaliable', ] // available to players [yn] + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_RANGE, [2, 12], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 424]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_CRV, true ], // criteria values - only printable chars, no delimiters + 'na' => [parent::V_NAME, false, false], // name / description - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'qu' => [parent::V_RANGE, [0, 7], true ], // quality + 'ty' => [parent::V_RANGE, [1, 12], true ], // set type + 'minle' => [parent::V_RANGE, [0, 999], false], // min item level + 'maxle' => [parent::V_RANGE, [0, 999], false], // max itemlevel + 'minrl' => [parent::V_RANGE, [0, MAX_LEVEL], false], // min required level + 'maxrl' => [parent::V_RANGE, [0, MAX_LEVEL], false], // max required level + 'cl' => [parent::V_LIST, [[1, 9], 11], false], // class + 'ta' => [parent::V_RANGE, [1, 30], false] // tag / content group + ); + + protected function createSQLForValues() : array + { + $parts = []; + $_v = &$this->values; + + // name [str] + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'name_loc'.Lang::getLocale()->value]])) + $parts[] = $_; + + // quality [enum] + if ($_v['qu']) + $parts[] = ['quality', $_v['qu']]; + + // type [enum] + if ($_v['ty']) + $parts[] = ['type', $_v['ty']]; + + // itemLevel min [int] + if ($_v['minle']) + $parts[] = ['minLevel', $_v['minle'], '>=']; + + // itemLevel max [int] + if ($_v['maxle']) + $parts[] = ['maxLevel', $_v['maxle'], '<=']; + + // reqLevel min [int] + if ($_v['minrl']) + $parts[] = ['reqLevel', $_v['minrl'], '>=']; + + // reqLevel max [int] + if ($_v['maxrl']) + $parts[] = ['reqLevel', $_v['maxrl'], '<=']; + + // class [enum] + if ($_v['cl']) + $parts[] = ['classMask', $this->list2Mask([$_v['cl']]), '&']; + + // tag [enum] + if ($_v['ta']) + $parts[] = ['contentGroup', intVal($_v['ta'])]; + + return $parts; + } + + protected function cbAvaliable(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + 1 => ['src.typeId', null, '!'], // Yes + 2 => ['src.typeId', null], // No + default => null + }; + } +} + +?> diff --git a/includes/types/mail.class.php b/includes/dbtypes/mail.class.php similarity index 57% rename from includes/types/mail.class.php rename to includes/dbtypes/mail.class.php index b20fdf16..0e142428 100644 --- a/includes/types/mail.class.php +++ b/includes/dbtypes/mail.class.php @@ -1,21 +1,23 @@ error) return; @@ -32,13 +34,14 @@ class MailList extends BaseType } } - public static function getName($id) + public static function getName(int $id) : ?LocString { - $n = DB::Aowow()->SelectRow('SELECT subject_loc0, subject_loc2, subject_loc3, subject_loc4, subject_loc6, subject_loc8 FROM ?_mails WHERE id = ?d', $id); - return Util::localizedString($n, 'subject'); + if ($n = DB::Aowow()->SelectRow('SELECT `subject_loc0`, `subject_loc2`, `subject_loc3`, `subject_loc4`, `subject_loc6`, `subject_loc8` FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n, 'subject'); + return null; } - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -57,7 +60,7 @@ class MailList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -68,7 +71,7 @@ class MailList extends BaseType return $data; } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } ?> diff --git a/includes/types/pet.class.php b/includes/dbtypes/pet.class.php similarity index 74% rename from includes/types/pet.class.php rename to includes/dbtypes/pet.class.php index 6a9ecf17..86834fa5 100644 --- a/includes/types/pet.class.php +++ b/includes/dbtypes/pet.class.php @@ -1,24 +1,26 @@ [['ic']], - 'ic' => ['j' => ['?_icons ic ON p.iconId = ic.id', true], 's' => ', ic.name AS iconString'], + 'ic' => ['j' => ['::icons ic ON p.`iconId` = ic.`id`', true], 's' => ', ic.`name` AS "iconString"'], ); - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -50,7 +52,7 @@ class PetList extends BaseType return $data; } - public function getJSGlobals($addMask = GLOBALINFO_ANY) + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array { $data = []; @@ -68,7 +70,7 @@ class PetList extends BaseType return $data; } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } ?> diff --git a/includes/dbtypes/profile.class.php b/includes/dbtypes/profile.class.php new file mode 100644 index 00000000..541e4b1a --- /dev/null +++ b/includes/dbtypes/profile.class.php @@ -0,0 +1,750 @@ +iterate() as $__) + { + if (!$this->isVisibleToUser()) + continue; + + if (($addInfoMask & PROFILEINFO_PROFILE) && !$this->isCustom()) + continue; + + if (($addInfoMask & PROFILEINFO_CHARACTER) && $this->isCustom()) + continue; + + $data[$this->id] = array( + 'id' => $this->getField('id'), + 'name' => $this->getField('name'), + 'race' => $this->getField('race'), + 'classs' => $this->getField('class'), + 'gender' => $this->getField('gender'), + 'level' => $this->getField('level'), + 'faction' => ChrRace::tryFrom($this->getField('race'))?->isAlliance() ? 0 : 1, + 'talenttree1' => $this->getField('talenttree1'), + 'talenttree2' => $this->getField('talenttree2'), + 'talenttree3' => $this->getField('talenttree3'), + 'talentspec' => $this->getField('activespec') + 1, // 0 => 1; 1 => 2 + 'achievementpoints' => $this->getField('achievementpoints'), + 'guild' => $this->curTpl['guildname'] ? '$"'.str_replace ('"', '', $this->curTpl['guildname']).'"' : '', // force this to be a string + 'guildrank' => $this->getField('guildrank'), + 'realm' => Profiler::urlize($this->getField('realmName'), true), + 'realmname' => $this->getField('realmName'), + // 'battlegroup' => Profiler::urlize($this->getField('battlegroup')), // was renamed to subregion somewhere around cata release + // 'battlegroupname' => $this->getField('battlegroup'), + 'gearscore' => $this->getField('gearscore') + ); + + if ($addInfoMask & PROFILEINFO_USER) + $data[$this->id]['published'] = (int)!!($this->getField('cuFlags') & PROFILER_CU_PUBLISHED); + + // for the lv this determins if the link is profile= or profile=.. + if (!$this->isCustom()) + $data[$this->id]['region'] = Profiler::urlize($this->getField('region')); + + if ($addInfoMask & PROFILEINFO_ARENA) + { + $data[$this->id]['rating'] = $this->getField('rating'); + $data[$this->id]['captain'] = $this->getField('captain'); + $data[$this->id]['games'] = $this->getField('seasonGames'); + $data[$this->id]['wins'] = $this->getField('seasonWins'); + } + + // Filter asked for skills - add them + foreach ($reqCols as $col) + $data[$this->id][$col] = $this->getField($col); + + if ($addInfoMask & PROFILEINFO_PROFILE) + { + if ($_ = $this->getField('description')) + $data[$this->id]['description'] = $_; + + if ($_ = $this->getField('icon')) + $data[$this->id]['icon'] = $_; + } + + if ($addInfoMask & PROFILEINFO_CHARACTER) + if ($_ = $this->getField('renameItr')) + $data[$this->id]['renameItr'] = $_; + + if ($this->getField('cuFlags') & PROFILER_CU_PINNED) + $data[$this->id]['pinned'] = 1; + + if ($this->getField('deleted')) + $data[$this->id]['deleted'] = 1; + } + + return $data; + } + + public function renderTooltip() : ?string + { + if (!$this->curTpl) + return null; + + $title = ''; + $name = $this->getField('name'); + if ($_ = $this->getField('title')) + $title = (new TitleList(array(['id', $_])))->getField($this->getField('gender') ? 'female' : 'male', true); + + if ($this->isCustom()) + $name .= Lang::profiler('customProfile'); + else if ($title) + $name = sprintf($title, $name); + + $x = ''; + $x .= ''; + if ($g = $this->getField('guildname')) + $x .= ''; + else if ($d = $this->getField('description')) + $x .= ''; + $x .= ''; + $x .= '
'.$name.'
<'.$g.'>
'.$d.'
'.Lang::game('level').' '.$this->getField('level').' '.Lang::game('ra', $this->getField('race')).' '.Lang::game('cl', $this->getField('class')).'
'; + + return $x; + } + + public function getJSGlobals(int $addMask = 0) : array + { + $data = []; + $realms = Profiler::getRealms(); + + foreach ($this->iterate() as $id => $__) + { + if (($addMask & PROFILEINFO_PROFILE) && $this->isCustom()) + { + $profile = array( + 'id' => $this->getField('id'), + 'name' => $this->getField('name'), + 'race' => $this->getField('race'), + 'classs' => $this->getField('class'), + 'level' => $this->getField('level'), + 'gender' => $this->getField('gender') + ); + + if ($_ = $this->getField('icon')) + $profile['icon'] = $_; + + $data[] = $profile; + + continue; + } + + if ($addMask & PROFILEINFO_CHARACTER && !$this->isCustom()) + { + if (!isset($realms[$this->getField('realm')])) + continue; + + $data[] = array( + 'id' => $this->getField('id'), + 'name' => $this->getField('name'), + 'realmname' => $realms[$this->getField('realm')]['name'], + 'region' => $realms[$this->getField('realm')]['region'], + 'realm' => Profiler::urlize($realms[$this->getField('realm')]['name']), + 'race' => $this->getField('race'), + 'classs' => $this->getField('class'), + 'level' => $this->getField('level'), + 'gender' => $this->getField('gender'), + 'pinned' => $this->getField('cuFlags') & PROFILER_CU_PINNED ? 1 : 0 + ); + } + } + + return $data; + } + + public function isCustom() : bool + { + return $this->getField('custom'); + } + + public function isVisibleToUser() : bool + { + if (!$this->isCustom() || User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + return true; + + if ($this->getField('deleted')) + return false; + + if (User::$id == $this->getField('user')) + return true; + + return (bool)($this->getField('cuFlags') & PROFILER_CU_PUBLISHED); + } + + public function getIcon() : string + { + if ($_ = $this->getField('icon')) + return $_; + + return sprintf('chr_%s_%s_%s%02d', + ChrRace::from($this->getField('race'))->json(), + $this->getField('gender') ? 'female' : 'male', + ChrClass::from($this->getField('class'))->json(), + max(1, floor(($this->getField('level') - 60) / 10) + 2) + ); + } + + public static function getName(int $id) : ?LocString { return null; } +} + + +class ProfileListFilter extends Filter +{ + use TrProfilerFilter; + + protected string $type = 'profiles'; + protected static array $genericFilter = array( + 2 => [parent::CR_NUMERIC, 'gearscore', NUM_CAST_INT ], // gearscore [num] + 3 => [parent::CR_CALLBACK, 'cbAchievs', null, null], // achievementpoints [num] + 5 => [parent::CR_NUMERIC, 'talenttree1', NUM_CAST_INT ], // talenttree1 [num] + 6 => [parent::CR_NUMERIC, 'talenttree2', NUM_CAST_INT ], // talenttree2 [num] + 7 => [parent::CR_NUMERIC, 'talenttree3', NUM_CAST_INT ], // talenttree3 [num] + 9 => [parent::CR_STRING, 'g.name' ], // guildname + 10 => [parent::CR_CALLBACK, 'cbHasGuildRank', null, null], // guildrank + 12 => [parent::CR_CALLBACK, 'cbTeamName', 2, null], // teamname2v2 + 15 => [parent::CR_CALLBACK, 'cbTeamName', 3, null], // teamname3v3 + 18 => [parent::CR_CALLBACK, 'cbTeamName', 5, null], // teamname5v5 + 13 => [parent::CR_CALLBACK, 'cbTeamRating', 2, null], // teamrtng2v2 + 16 => [parent::CR_CALLBACK, 'cbTeamRating', 3, null], // teamrtng3v3 + 19 => [parent::CR_CALLBACK, 'cbTeamRating', 5, null], // teamrtng5v5 + 14 => [parent::CR_NYI_PH, null, 0 /* 2 */ ], // teamcontrib2v2 [num] + 17 => [parent::CR_NYI_PH, null, 0 /* 3 */ ], // teamcontrib3v3 [num] + 20 => [parent::CR_NYI_PH, null, 0 /* 5 */ ], // teamcontrib5v5 [num] + 21 => [parent::CR_CALLBACK, 'cbWearsItems', null, null], // wearingitem [str] + 23 => [parent::CR_CALLBACK, 'cbCompletedAcv', null, null], // completedachievement + 25 => [parent::CR_CALLBACK, 'cbProfession', SKILL_ALCHEMY, null], // alchemy [num] + 26 => [parent::CR_CALLBACK, 'cbProfession', SKILL_BLACKSMITHING, null], // blacksmithing [num] + 27 => [parent::CR_CALLBACK, 'cbProfession', SKILL_ENCHANTING, null], // enchanting [num] + 28 => [parent::CR_CALLBACK, 'cbProfession', SKILL_ENGINEERING, null], // engineering [num] + 29 => [parent::CR_CALLBACK, 'cbProfession', SKILL_HERBALISM, null], // herbalism [num] + 30 => [parent::CR_CALLBACK, 'cbProfession', SKILL_INSCRIPTION, null], // inscription [num] + 31 => [parent::CR_CALLBACK, 'cbProfession', SKILL_JEWELCRAFTING, null], // jewelcrafting [num] + 32 => [parent::CR_CALLBACK, 'cbProfession', SKILL_LEATHERWORKING, null], // leatherworking [num] + 33 => [parent::CR_CALLBACK, 'cbProfession', SKILL_MINING, null], // mining [num] + 34 => [parent::CR_CALLBACK, 'cbProfession', SKILL_SKINNING, null], // skinning [num] + 35 => [parent::CR_CALLBACK, 'cbProfession', SKILL_TAILORING, null], // tailoring [num] + 36 => [parent::CR_CALLBACK, 'cbHasGuild', null, null] // hasguild [yn] + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_RANGE, [1, 36], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 5000]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_CRV, true ], // criteria values + 'ex' => [parent::V_EQUAL, 'on', false], // only match exact - must be defined before 'na' as it's test relies on 'ex's value + 'na' => [parent::V_NAME, true, false], // name - only printable chars, no delimiter + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'si' => [parent::V_LIST, [SIDE_ALLIANCE, SIDE_HORDE], false], // side + 'ra' => [parent::V_LIST, [[1, 8], 10, 11], true ], // race + 'cl' => [parent::V_LIST, [[1, 9], 11], true ], // class + 'minle' => [parent::V_RANGE, [1, MAX_LEVEL], false], // min level + 'maxle' => [parent::V_RANGE, [1, MAX_LEVEL], false], // max level + 'rg' => [parent::V_CALLBACK, 'cbRegionCheck', false], // region + 'bg' => [parent::V_EQUAL, null, false], // battlegroup - unsued here, but var expected by template + 'sv' => [parent::V_CALLBACK, 'cbServerCheck', false] // server + ); + + public bool $useLocalList = false; + public array $extraOpts = []; + + /* heads up! + a couple of filters are too complex to be run against the characters database + if they are selected, force useage of LocalProfileList + */ + + public function __construct(string|array $data, array $opts = []) + { + parent::__construct($data, $opts); + + if (!empty($this->values['cr'])) + if (array_intersect($this->values['cr'], [2, 5, 6, 7, 21])) + $this->useLocalList = true; + } + + protected function createSQLForValues() : array + { + $parts = []; + $_v = $this->values; + + // region (rg), battlegroup (bg) and server (sv) are passed to ProflieList as miscData and handled there + + // table key differs between remote and local :< + $k = $this->useLocalList ? 'p' : 'c'; + + // name [str] + if ($_v['na']) + { + // issue: the table is case sensitive. so we need to alter the tokens for multiple cases + foreach (['inTokens', 'exTokens'] as $prop) + { + if (empty($this->{$prop}['na'])) + continue; + + $this->{$prop}['na'] = array_map(Util::lower(...), $this->{$prop}['na']); + $this->{$prop}['_na'] = array_map(Util::ucWords(...), $this->{$prop}['na']); + }; + + $parts[] = $this->buildLikeLookup([['na', $k.'.name'], ['_na', $k.'.name']], $_v['ex'] == 'on'); + } + + // side [list] + if ($_v['si'] == SIDE_ALLIANCE) + $parts[] = [$k.'.race', ChrRace::fromMask(ChrRace::MASK_ALLIANCE)]; + else if ($_v['si'] == SIDE_HORDE) + $parts[] = [$k.'.race', ChrRace::fromMask(ChrRace::MASK_HORDE)]; + + // race [list] + if ($_v['ra']) + $parts[] = [$k.'.race', $_v['ra']]; + + // class [list] + if ($_v['cl']) + $parts[] = [$k.'.class', $_v['cl']]; + + // min level [int] + if ($_v['minle']) + $parts[] = [$k.'.level', $_v['minle'], '>=']; + + // max level [int] + if ($_v['maxle']) + $parts[] = [$k.'.level', $_v['maxle'], '<=']; + + return $parts; + } + + protected function cbProfession(int $cr, int $crs, string $crv, $skillId) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + $k = 'sk_'.Util::createHash(12); + $col = 'skill-'.$skillId; + + $this->fiExtraCols[$skillId] = $col; + + if ($this->useLocalList) + { + $this->extraOpts[$k] = array( + 'j' => [sprintf('::profiler_completion_skills %1$s ON `%1$s`.`id` = p.`id` AND `%1$s`.`skillId` = %2$d AND `%1$s`.`value` %3$s %4$d', $k, $skillId, $crs, $crv), true], + 's' => [', '.$k.'.`value` AS "'.$col.'"'] + ); + return [$k.'.skillId', null, '!']; + } + else + { + $this->extraOpts[$k] = array( + 'j' => [sprintf('character_skills %1$s ON `%1$s`.`guid` = c.`guid` AND `%1$s`.`skill` = %2$d AND `%1$s`.`value` %3$s %4$d', $k, $skillId, $crs, $crv), true], + 's' => [', '.$k.'.`value` AS "'.$col.'"'] + ); + return [$k.'.skill', null, '!']; + } + } + + protected function cbCompletedAcv(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT)) + return null; + + if (!Type::validateIds(Type::ACHIEVEMENT, $crv)) + return null; + + $k = 'acv_'.Util::createHash(12); + + if ($this->useLocalList) + { + $this->extraOpts[$k] = ['j' => [sprintf('::profiler_completion_achievements %1$s ON `%1$s`.`id` = p.`id` AND `%1$s`.`achievementId` = %2$d', $k, $crv), true]]; + return [$k.'.achievementId', null, '!']; + } + else + { + $this->extraOpts[$k] = ['j' => [sprintf('character_achievement %1$s ON `%1$s`.`guid` = c.`guid` AND `%1$s`.`achievement` = %2$d', $k, $crv), true]]; + return [$k.'.achievement', null, '!']; + } + } + + protected function cbWearsItems(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT)) + return null; + + if (!Type::validateIds(Type::ITEM, $crv)) + return null; + + $k = 'i_'.Util::createHash(12); + + $this->extraOpts[$k] = ['j' => [sprintf('::profiler_items %1$s ON `%1$s`.`id` = p.`id` AND `%1$s`.`item` = %2$d', $k, $crv), true]]; + return [$k.'.item', null, '!']; + } + + protected function cbHasGuild(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($this->useLocalList) + return ['p.guild', null, $crs ? '!' : null]; + else + return ['gm.guildId', null, $crs ? '!' : null]; + } + + protected function cbHasGuildRank(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + if ($this->useLocalList) + return ['p.guildrank', $crv, $crs]; + else + return ['gm.rank', $crv, $crs]; + } + + protected function cbTeamName(int $cr, int $crs, string $crv, $size) : ?array + { + $n = preg_replace(parent::PATTERN_NAME, '', $crv); + if ($this->tokenizeString($cr, $n)) + if ($_ = $this->buildLikeLookup([[$cr, 'at.name']])) + return [DB::AND, ['at.type', $size], $_]; + + return null; + } + + protected function cbTeamRating(int $cr, int $crs, string $crv, $size) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + return [DB::AND, ['at.type', $size], ['at.rating', $crv, $crs]]; + } + + protected function cbAchievs(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + if ($this->useLocalList) + return ['p.achievementpoints', $crv, $crs]; + else + return ['cap.counter', $crv, $crs]; + } +} + + +class RemoteProfileList extends ProfileList +{ + protected string $queryBase = 'SELECT `c`.*, `c`.`guid` AS ARRAY_KEY FROM characters c'; + protected array $queryOpts = array( + 'c' => [['gm', 'g', 'cap']], // 12698: use criteria of Achievement 4496 as shortcut to get total achievement points + 'cap' => ['j' => ['character_achievement_progress cap ON cap.`guid` = c.`guid` AND cap.`criteria` = 12698', true], 's' => ', IFNULL(cap.`counter`, 0) AS "achievementpoints"'], + 'gm' => ['j' => ['guild_member gm ON gm.`guid` = c.`guid`', true], 's' => ', gm.`rank` AS "guildrank"'], + 'g' => ['j' => ['guild g ON g.`guildid` = gm.`guildid`', true], 's' => ', g.`guildid` AS "guild", g.`name` AS "guildname"'], + 'atm' => ['j' => ['arena_team_member atm ON atm.`guid` = c.`guid`', true], 's' => ', atm.`personalRating` AS "rating"'], + 'at' => [['atm'], 'j' => 'arena_team at ON atm.`arenaTeamId` = at.`arenaTeamId`', 's' => ', at.`name` AS "arenateam", IF(at.`captainGuid` = c.`guid`, 1, 0) AS "captain"'] + ); + + private array $rnItr = []; // rename iterator [name => nCharsWithThisName] + + public function __construct(array $conditions = [], array $miscData = []) + { + // select DB by realm + if (!$this->selectRealms($miscData)) + { + trigger_error('RemoteProfileList::__construct - cannot access any realm.', E_USER_WARNING); + return; + } + + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + reset($this->dbNames); // only use when querying single realm + $realmId = key($this->dbNames); + $realms = Profiler::getRealms(); + $talentSpells = []; + $talentLookup = []; + $distrib = []; + + // post processing + foreach ($this->iterate() as $guid => &$curTpl) + { + // battlegroup + $curTpl['battlegroup'] = Cfg::get('BATTLEGROUP'); + + // realm + [$r, $g] = explode(':', $guid); + if (!empty($realms[$r])) + { + $curTpl['realm'] = $r; + $curTpl['realmName'] = $realms[$r]['name']; + $curTpl['region'] = $realms[$r]['region']; + } + else + { + trigger_error('char #'.$guid.' belongs to nonexistent realm #'.$r, E_USER_WARNING); + unset($this->templates[$guid]); + continue; + } + + // empty name + if (!$curTpl['name']) + { + trigger_error('char #'.$guid.' on realm #'.$r.' has empty name.', E_USER_WARNING); + unset($this->templates[$guid]); + continue; + } + + // temp id + $curTpl['id'] = 0; + + // talent points pre + $talentLookup[$r][$g] = []; + $talentSpells[] = $curTpl['class']; + $curTpl['activespec'] = $curTpl['activeTalentGroup']; + + // equalize distribution + if (empty($distrib[$curTpl['realm']])) + $distrib[$curTpl['realm']] = 1; + else + $distrib[$curTpl['realm']]++; + + // char is pending rename + if ($curTpl['at_login'] & 0x1) + { + if (!isset($this->rnItr[$curTpl['name']])) + $this->rnItr[$curTpl['name']] = DB::Aowow()->selectCell('SELECT MAX(`renameItr`) FROM ::profiler_profiles WHERE `realm` = %i AND `custom` = 0 AND `name` = %s', $r, $curTpl['name']) ?: 0; + + // already saved as "pending rename" + if ($rnItr = DB::Aowow()->selectCell('SELECT `renameItr` FROM ::profiler_profiles WHERE `realm` = %i AND `realmGUID` = %i', $r, $g)) + $curTpl['renameItr'] = $rnItr; + // not yet recognized: get max itr + else + $curTpl['renameItr'] = ++$this->rnItr[$curTpl['name']]; + } + else + $curTpl['renameItr'] = 0; + + $curTpl['cuFlags'] = 0; + } + + foreach ($talentLookup as $realm => $chars) + $talentLookup[$realm] = DB::Characters($realm)->selectCol('SELECT `guid` AS ARRAY_KEY, `spell` AS ARRAY_KEY2, `talentGroup` FROM character_talent ct WHERE `guid` IN %in', array_keys($chars)); + + $talentSpells = DB::Aowow()->selectAssoc('SELECT `spell` AS ARRAY_KEY, `tab`, `rank` FROM ::talents WHERE `class` IN %in', array_unique($talentSpells)); + + // equalize subject distribution across realms + $limit = 0; + foreach ($conditions as $c) + if (is_numeric($c)) + $limit = max(0, (int)$c); + + if (!$limit) // int:0 means unlimited, so skip process + $distrib = []; + + $total = array_sum($distrib); + foreach ($distrib as &$d) + $d = ceil($limit * $d / $total); + + foreach ($this->iterate() as $guid => &$curTpl) + { + if ($distrib) + { + if ($limit <= 0 || $distrib[$curTpl['realm']] <= 0) + { + unset($this->templates[$guid]); + continue; + } + + $distrib[$curTpl['realm']]--; + $limit--; + } + + [$r, $g] = explode(':', $guid); + + // talent points post + $curTpl['talenttree1'] = 0; + $curTpl['talenttree2'] = 0; + $curTpl['talenttree3'] = 0; + if (!empty($talentLookup[$r][$g])) + { + $talents = array_filter($talentLookup[$r][$g], function($v) use ($curTpl) { return $curTpl['activespec'] == $v; } ); + foreach (array_intersect_key($talentSpells, $talents) as $spell => $data) + $curTpl['talenttree'.($data['tab'] + 1)] += $data['rank']; + } + } + } + + public function getListviewData(int $addInfoMask = 0, array $reqCols = []) : array + { + $data = parent::getListviewData($addInfoMask, $reqCols); + + // not wanted on server list + foreach ($data as &$d) + unset($d['published']); + + return $data; + } + + public function initializeLocalEntries() : void + { + if (!$this->templates) + return; + + $baseData = $guildData = []; + foreach ($this->iterate() as $guid => $__) + { + $realmId = $this->getField('realm'); + $guildGUID = $this->getField('guild'); + + $baseData['realm'][$guid] = $realmId; + $baseData['realmGUID'][$guid] = $this->getField('guid'); + $baseData['name'][$guid] = $this->getField('name'); + $baseData['renameItr'][$guid] = $this->getField('renameItr'); + $baseData['race'][$guid] = $this->getField('race'); + $baseData['class'][$guid] = $this->getField('class'); + $baseData['level'][$guid] = $this->getField('level'); + $baseData['gender'][$guid] = $this->getField('gender'); + $baseData['guild'][$guid] = $guildGUID ?: null; + $baseData['guildrank'][$guid] = $guildGUID ? $this->getField('guildrank') : null; + $baseData['stub'][$guid] = 1; + + if ($guildGUID) + { + $guildData['realm'][$realmId.'-'.$guildGUID] = $realmId; + $guildData['realmGUID'][$realmId.'-'.$guildGUID] = $guildGUID; + $guildData['name'][$realmId.'-'.$guildGUID] = $this->getField('guildname'); + $guildData['nameUrl'][$realmId.'-'.$guildGUID] = Profiler::urlize($this->getField('guildname')); + $guildData['stub'][$realmId.'-'.$guildGUID] = 1; + } + } + + // basic guild data (satisfying table constraints) + if ($guildData) + { + DB::Aowow()->qry('INSERT INTO ::profiler_guild %m ON DUPLICATE KEY UPDATE `id` = `id`', $guildData); + + // merge back local ids + $localGuilds = DB::Aowow()->selectCol('SELECT `realm` AS ARRAY_KEY, `realmGUID` AS ARRAY_KEY2, `id` FROM ::profiler_guild WHERE `realm` IN %in AND `realmGUID` IN %in', + $guildData['realm'], $guildData['realmGUID'] + ); + + foreach ($baseData['guild'] as $i => &$g) + $g = $localGuilds[$baseData['realm'][$i]][$baseData['guild'][$i]] ?? null; + } + + // basic char data (enough for tooltips) + if ($baseData) + { + // this could have been an INSERT ON DUPLICATE KEY UPDATE if MariaDB and MySQL would behave for once! + $insertOrUpdate = $baseData; + $existing = DB::Aowow()->selectAssoc('SELECT `realm` AS ARRAY_KEY, `realmGUID` AS ARRAY_KEY2, 1 FROM ::profiler_profiles WHERE `realm` IN %in AND `realmGUID` IN %in', $insertOrUpdate['realm'], $insertOrUpdate['realmGUID']); + foreach ($insertOrUpdate['realm'] as $guid => $_) + { + if (!isset($existing[$insertOrUpdate['realm'][$guid]][$insertOrUpdate['realmGUID'][$guid]])) + continue; + + // ... ON DUPLICATE KEY UPDATE + DB::Aowow()->qry('UPDATE ::profiler_profiles SET `name` = %s, `renameItr` = %i WHERE `realm` = %i AND `realmGUID` = %i', $insertOrUpdate['name'][$guid], $insertOrUpdate['renameItr'][$guid], $insertOrUpdate['realm'][$guid], $insertOrUpdate['realmGUID'][$guid]); + foreach($insertOrUpdate as $col => $__) + unset($insertOrUpdate[$col][$guid]); + } + + // INSERT ... + if (current($insertOrUpdate)) + DB::Aowow()->qry('INSERT INTO ::profiler_profiles %m', $insertOrUpdate); + + // merge back local ids + $localIds = DB::Aowow()->selectAssoc('SELECT CONCAT(`realm`, ":", `realmGUID`) AS ARRAY_KEY, `id`, `gearscore` FROM ::profiler_profiles WHERE `custom` = 0 AND `realm` IN %in AND `realmGUID` IN %in', + $baseData['realm'], $baseData['realmGUID'] + ); + + foreach ($this->iterate() as $guid => &$_curTpl) + if (isset($localIds[$guid])) + $_curTpl = array_merge($_curTpl, $localIds[$guid]); + } + } +} + + +class LocalProfileList extends ProfileList +{ + protected string $queryBase = 'SELECT p.*, p.`id` AS ARRAY_KEY FROM ::profiler_profiles p'; + protected array $queryOpts = array( + 'p' => [['g'], 'g' => 'p.`id`'], + 'ap' => ['j' => ['::account_profiles ap ON ap.`profileId` = p.`id`', true], 's' => ', (IFNULL(ap.`extraFlags`, 0) | p.`cuFlags`) AS "cuFlags"'], + 'atm' => ['j' => ['::profiler_arena_team_member atm ON atm.`profileId` = p.`id`', true], 's' => ', atm.`captain`, atm.`personalRating` AS "rating", atm.`seasonGames`, atm.`seasonWins`'], + 'at' => [['atm'], 'j' => ['::profiler_arena_team at ON at.`id` = atm.`arenaTeamId`', true], 's' => ', at.`type`'], + 'g' => ['j' => ['::profiler_guild g ON g.`id` = p.`guild`', true], 's' => ', g.`name` AS "guildname"'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + $realms = Profiler::getRealms(); + + // graft realm selection from miscData onto conditions + $realmIds = []; + if (isset($miscData['sv'])) + $realmIds = array_keys(array_filter($realms, fn($x) => Profiler::urlize($x['name']) == Profiler::urlize($miscData['sv']))); + + if (isset($miscData['rg'])) + $realmIds = array_merge($realmIds, array_keys(array_filter($realms, fn($x) => $x['region'] == $miscData['rg']))); + + if ($conditions && $realmIds) + { + array_unshift($conditions, DB::AND); + $conditions = [DB::AND, ['realm', $realmIds], $conditions]; + } + else if ($realmIds) + $conditions = [['realm', $realmIds]]; + + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + foreach ($this->iterate() as $id => &$curTpl) + { + if (!$curTpl['realm']) // custom profile w/o realminfo + continue; + + if (!isset($realms[$curTpl['realm']])) + { + unset($this->templates[$id]); + continue; + } + + $curTpl['realmName'] = $realms[$curTpl['realm']]['name']; + $curTpl['region'] = $realms[$curTpl['realm']]['region']; + $curTpl['battlegroup'] = Cfg::get('BATTLEGROUP'); + } + } + + public function getProfileUrl() : string + { + $url = '?profile='; + + if ($this->isCustom()) + return $url.$this->getField('id'); + + return $url.implode('.', array( + $this->getField('region'), + Profiler::urlize($this->getField('realmName'), true), + urlencode($this->getField('name')) + )); + } +} + + +?> diff --git a/includes/dbtypes/quest.class.php b/includes/dbtypes/quest.class.php new file mode 100644 index 00000000..c24c2573 --- /dev/null +++ b/includes/dbtypes/quest.class.php @@ -0,0 +1,727 @@ + [], + 'nml' => ['j' => '::quests_search nml ON nml.`id` = q.`id` AND nml.`locale` = DB_LOC_I'], + 'rsc' => ['j' => '::spell rsc ON q.`rewardSpellCast` = rsc.`id`'], // limit rewardSpellCasts + 'qse' => ['j' => '::quests_startend qse ON q.`id` = qse.`questId`', 's' => ', qse.`method`'], // groupConcat..? + 'e' => ['j' => ['::events e ON e.`id` = q.`eventId`', true], 's' => ', e.`holidayId`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + // i don't like this very much + $currencies = DB::Aowow()->selectCol('SELECT `id` AS ARRAY_KEY, `itemId` FROM ::currencies'); + + // post processing + foreach ($this->iterate() as $id => &$_curTpl) + { + $_curTpl['cat1'] = $_curTpl['questSortId']; // should probably be in a method... + $_curTpl['cat2'] = 0; + + foreach (Game::QUEST_CLASSES as $k => $arr) + { + if (in_array($_curTpl['cat1'], $arr)) + { + $_curTpl['cat2'] = $k; + break; + } + } + + // store requirements + $requires = []; + for ($i = 1; $i < 7; $i++) + { + if ($_ = $_curTpl['reqItemId'.$i]) + $requires[Type::ITEM][] = $_; + + if ($i > 4) + continue; + + if ($_curTpl['reqNpcOrGo'.$i] > 0) + $requires[Type::NPC][] = $_curTpl['reqNpcOrGo'.$i]; + else if ($_curTpl['reqNpcOrGo'.$i] < 0) + $requires[Type::OBJECT][] = -$_curTpl['reqNpcOrGo'.$i]; + + if ($_ = $_curTpl['reqSourceItemId'.$i]) + $requires[Type::ITEM][] = $_; + } + if ($requires) + $this->requires[$id] = $requires; + + // store rewards + $rewards = []; + $choices = []; + + if ($_ = $_curTpl['rewardTitleId']) + $rewards[Type::TITLE][] = $_; + + if ($_ = $_curTpl['rewardHonorPoints']) + $rewards[Type::CURRENCY][CURRENCY_HONOR_POINTS] = $_; + + if ($_ = $_curTpl['rewardArenaPoints']) + $rewards[Type::CURRENCY][CURRENCY_ARENA_POINTS] = $_; + + for ($i = 1; $i < 7; $i++) + { + if ($_ = $_curTpl['rewardChoiceItemId'.$i]) + $choices[Type::ITEM][$_] = $_curTpl['rewardChoiceItemCount'.$i]; + + if ($i > 5) + continue; + + if ($_ = $_curTpl['rewardFactionId'.$i]) + $rewards[Type::FACTION][$_] = $_curTpl['rewardFactionValue'.$i]; + + if ($i > 4) + continue; + + if ($_ = $_curTpl['rewardItemId'.$i]) + { + $qty = $_curTpl['rewardItemCount'.$i]; + if (in_array($_, $currencies)) + $rewards[Type::CURRENCY][array_search($_, $currencies)] = $qty; + else + $rewards[Type::ITEM][$_] = $qty; + } + } + if ($rewards) + $this->rewards[$id] = $rewards; + + if ($choices) + $this->choices[$id] = $choices; + } + } + + public function isRepeatable() : bool + { + return $this->curTpl['specialFlags'] & QUEST_FLAG_SPECIAL_REPEATABLE; + } + + public function isDaily() : int + { + if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) + return 1; + + if ($this->curTpl['flags'] & QUEST_FLAG_WEEKLY) + return 2; + + if ($this->curTpl['specialFlags'] & QUEST_FLAG_SPECIAL_MONTHLY) + return 3; + + return 0; + } + + public function isAutoAccept() : bool + { + return $this->curTpl['flags'] & QUEST_FLAG_AUTO_ACCEPT || $this->curTpl['specialFlags'] & QUEST_FLAG_SPECIAL_AUTO_ACCEPT; + } + + // by TC definition + public function isSeasonal() : bool + { + return in_array($this->getField('questSortIdBak'), [-22, -284, -366, -369, -370, -376, -374]) && !$this->isRepeatable(); + } + + public function getSourceData(int $id = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if ($id && $id != $this->id) + continue; + + $data[$this->id] = array( + "n" => $this->getField('name', true), + "t" => Type::QUEST, + "ti" => $this->id, + "c" => $this->curTpl['cat1'], + "c2" => $this->curTpl['cat2'] + ); + } + + return $data; + } + + public function getSOMData(int $side = SIDE_BOTH) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if (!(ChrRace::sideFromMask($this->curTpl['reqRaceMask']) & $side)) + continue; + + [$series, $first] = DB::Aowow()->SelectRow( + 'SELECT IF(prev.`id` OR cur.`nextQuestIdChain`, 1, 0) AS "0", IF(prev.`id` IS NULL AND cur.`nextQuestIdChain`, 1, 0) AS "1" FROM ::quests cur LEFT JOIN ::quests prev ON prev.`nextQuestIdChain` = cur.`id` WHERE cur.`id` = %i', + $this->id + ); + + $data[$this->id] = array( + 'level' => $this->curTpl['level'] < 0 ? MAX_LEVEL : $this->curTpl['level'], + 'name' => $this->getField('name', true), + 'category' => $this->curTpl['cat1'], + 'category2' => $this->curTpl['cat2'], + 'series' => $series, + 'first' => $first + ); + + if ($this->isDaily()) + $data[$this->id]['daily'] = 1; + } + + return $data; + } + + public function getListviewData(int $extraFactionId = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'category' => $this->curTpl['cat1'], + 'category2' => $this->curTpl['cat2'], + 'id' => $this->id, + 'level' => $this->curTpl['level'], + 'reqlevel' => $this->curTpl['minLevel'], + 'name' => Lang::unescapeUISequences($this->getField('name', true), Lang::FMT_RAW), + 'side' => ChrRace::sideFromMask($this->curTpl['reqRaceMask']), + 'wflags' => 0x0, + 'xp' => $this->curTpl['rewardXP'] + ); + + if (!empty($this->rewards[$this->id][Type::CURRENCY])) + foreach ($this->rewards[$this->id][Type::CURRENCY] as $iId => $qty) + $data[$this->id]['currencyrewards'][] = [$iId, $qty]; + + if (!empty($this->rewards[$this->id][Type::ITEM])) + foreach ($this->rewards[$this->id][Type::ITEM] as $iId => $qty) + $data[$this->id]['itemrewards'][] = [$iId, $qty]; + + if (!empty($this->choices[$this->id][Type::ITEM])) + foreach ($this->choices[$this->id][Type::ITEM] as $iId => $qty) + $data[$this->id]['itemchoices'][] = [$iId, $qty]; + + if ($_ = $this->curTpl['rewardTitleId']) + $data[$this->id]['titlereward'] = $_; + + if ($_ = $this->curTpl['questInfoId']) + $data[$this->id]['type'] = $_; + + if ($_ = $this->curTpl['reqClassMask']) + $data[$this->id]['reqclass'] = $_; + + if ($_ = ($this->curTpl['reqRaceMask'] & ChrRace::MASK_ALL)) + if ((($_ & ChrRace::MASK_ALLIANCE) != ChrRace::MASK_ALLIANCE) && (($_ & ChrRace::MASK_HORDE) != ChrRace::MASK_HORDE)) + $data[$this->id]['reqrace'] = $_; + + if ($_ = $this->curTpl['rewardOrReqMoney']) + if ($_ > 0) + $data[$this->id]['money'] = $_; + + // todo (med): also get disables + if ($this->curTpl['flags'] & QUEST_FLAG_UNAVAILABLE) + $data[$this->id]['historical'] = true; + + // if ($this->isRepeatable()) // dafuque..? says repeatable and is used as 'disabled'..? + // $data[$this->id]['wflags'] |= QUEST_CU_REPEATABLE; + if ($this->curTpl['cuFlags'] & (CUSTOM_UNAVAILABLE | CUSTOM_DISABLED)) + $data[$this->id]['wflags'] |= QUEST_CU_REPEATABLE; + + if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) + { + $data[$this->id]['wflags'] |= QUEST_CU_DAILY; + $data[$this->id]['daily'] = true; + } + + if ($this->curTpl['flags'] & QUEST_FLAG_WEEKLY) + { + $data[$this->id]['wflags'] |= QUEST_CU_WEEKLY; + $data[$this->id]['weekly'] = true; + } + + if ($this->isSeasonal()) + $data[$this->id]['wflags'] |= QUEST_CU_SEASONAL; + + if ($this->curTpl['flags'] & QUEST_FLAG_TRACKING) // not shown in log + $data[$this->id]['wflags'] |= QUEST_CU_SKIP_LOG; + + if ($this->isAutoAccept()) + $data[$this->id]['wflags'] |= QUEST_CU_AUTO_ACCEPT; + + if ($this->curTpl['flags'] & QUEST_FLAG_FLAGS_PVP) // this flag is only displayed if auto-accept is also set. not sure why. + $data[$this->id]['wflags'] |= QUEST_CU_PVP_ENABLED; + + $data[$this->id]['reprewards'] = []; + for ($i = 1; $i < 6; $i++) + { + $foo = $this->curTpl['rewardFactionId'.$i]; + $bar = $this->curTpl['rewardFactionValue'.$i]; + if ($foo && $bar) + { + $data[$this->id]['reprewards'][] = [$foo, $bar]; + + if ($extraFactionId == $foo) + $data[$this->id]['reputation'] = $bar; + } + } + } + + return $data; + } + + public function parseText(string $type = 'objectives', bool $jsEscaped = true) : string + { + $text = $this->getField($type, true); + if (!$text) + return ''; + + $text = Util::parseHtmlText($text); + + if ($jsEscaped) + $text = Util::jsEscape($text); + + return $text; + } + + public function renderTooltip() : ?string + { + if (!$this->curTpl) + return null; + + $title = Lang::unescapeUISequences(Util::htmlEscape($this->getField('name', true)), Lang::FMT_HTML); + $level = $this->curTpl['level']; + if ($level < 0) + $level = 0; + + $x = ''; + if ($level) + { + $level = sprintf(Lang::quest('questLevel'), $level); + + if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) // daily + $level .= ' '.Lang::quest('daily'); + + $x .= '
'.$title.''.$level.'
'; + } + else + $x .= '
'.$title.'
'; + + + $x .= '

'.$this->parseText('objectives', false); + + + $xReq = ''; + for ($i = 1; $i < 5; $i++) + { + $ot = $this->getField('objectiveText'.$i, true); + $rng = $this->curTpl['reqNpcOrGo'.$i]; + $rngQty = $this->curTpl['reqNpcOrGoCount'.$i]; + + if (!$ot && ($rngQty < 1 || !$rng)) + continue; + + if ($ot) + $name = $ot; + else if ($rng > 0) + $name = CreatureList::getName($rng); + else if ($rng < 0) + $name = Lang::unescapeUISequences(GameObjectList::getName(-$rng), Lang::FMT_HTML); + + if (!$name) + $name = Util::ucFirst(Lang::game($rng > 0 ? 'npc' : 'object')).' #'.abs($rng); + + $xReq .= '
- '.$name.($rngQty > 1 ? ' x '.$rngQty : ''); + } + + for ($i = 1; $i < 7; $i++) + { + $ri = $this->curTpl['reqItemId'.$i]; + $riQty = $this->curTpl['reqItemCount'.$i]; + + if (!$ri || $riQty < 1) + continue; + + $name = Lang::unescapeUISequences(ItemList::getName($ri), Lang::FMT_HTML) ?: Util::ucFirst(Lang::game('item')).' #'.$ri; + + $xReq .= '
- '.$name.($riQty > 1 ? ' x '.$riQty : ''); + } + + if ($et = $this->getField('end', true)) + $xReq .= '
- '.$et; + + if ($_ = $this->getField('rewardOrReqMoney')) + if ($_ < 0) + $xReq .= '
- '.Lang::quest('money').Lang::main('colon').Util::formatMoney(abs($_)); + + if ($xReq) + $x .= '

'.Lang::quest('requirements').Lang::main('colon').''.$xReq; + + $x .= '
'; + + return $x; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_ANY) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if ($addMask & GLOBALINFO_REWARDS) + { + // items + for ($i = 1; $i < 5; $i++) + if ($this->curTpl['rewardItemId'.$i] > 0) + $data[Type::ITEM][$this->curTpl['rewardItemId'.$i]] = $this->curTpl['rewardItemId'.$i]; + + for ($i = 1; $i < 7; $i++) + if ($this->curTpl['rewardChoiceItemId'.$i] > 0) + $data[Type::ITEM][$this->curTpl['rewardChoiceItemId'.$i]] = $this->curTpl['rewardChoiceItemId'.$i]; + + // spells + if ($this->curTpl['rewardSpell'] > 0) + $data[Type::SPELL][$this->curTpl['rewardSpell']] = $this->curTpl['rewardSpell']; + + if ($this->curTpl['rewardSpellCast'] > 0) + $data[Type::SPELL][$this->curTpl['rewardSpellCast']] = $this->curTpl['rewardSpellCast']; + + // titles + if ($this->curTpl['rewardTitleId'] > 0) + $data[Type::TITLE][$this->curTpl['rewardTitleId']] = $this->curTpl['rewardTitleId']; + + // currencies + if (!empty($this->rewards[$this->id][Type::CURRENCY])) + foreach ($this->rewards[$this->id][Type::CURRENCY] as $id => $__) + $data[Type::CURRENCY][$id] = $id; + } + + if ($addMask & GLOBALINFO_SELF) + { + $data[Type::QUEST][$this->id] = ['name' => $this->getField('name', true)]; + + if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) + $data[Type::QUEST][$this->id]['daily'] = true; + + if ($this->curTpl['flags'] & QUEST_FLAG_WEEKLY) + $data[Type::QUEST][$this->id]['weekly'] = true; + } + } + + return $data; + } +} + + +class QuestListFilter extends Filter +{ + protected string $type = 'quests'; + protected static array $enums = array( + 37 => parent::ENUM_CLASSS, // classspecific + 38 => parent::ENUM_RACE, // racespecific + 9 => parent::ENUM_FACTION, // objectiveearnrepwith + 33 => parent::ENUM_EVENT, // relatedevent + 43 => parent::ENUM_CURRENCY, // currencyrewarded + 1 => parent::ENUM_FACTION, // increasesrepwith + 10 => parent::ENUM_FACTION // decreasesrepwith + ); + + protected static array $genericFilter = array( + 1 => [parent::CR_CALLBACK, 'cbReputation', '>', null], // increasesrepwith + 2 => [parent::CR_NUMERIC, 'rewardXP', NUM_CAST_INT ], // experiencegained + 3 => [parent::CR_NUMERIC, 'rewardOrReqMoney', NUM_CAST_INT ], // moneyrewarded + 4 => [parent::CR_CALLBACK, 'cbSpellRewards', null, null], // spellrewarded [yn] + 5 => [parent::CR_FLAG, 'flags', QUEST_FLAG_SHARABLE ], // sharable + 6 => [parent::CR_NUMERIC, 'timeLimit', NUM_CAST_INT ], // timer + 7 => [parent::CR_FLAG, 'cuFlags', QUEST_CU_FIRST_SERIES ], // firstquestseries + 9 => [parent::CR_CALLBACK, 'cbEarnReputation', null, null], // objectiveearnrepwith [enum] + 10 => [parent::CR_CALLBACK, 'cbReputation', '<', null], // decreasesrepwith + 11 => [parent::CR_NUMERIC, 'suggestedPlayers', NUM_CAST_INT ], // suggestedplayers + 15 => [parent::CR_FLAG, 'cuFlags', QUEST_CU_LAST_SERIES ], // lastquestseries + 16 => [parent::CR_FLAG, 'cuFlags', QUEST_CU_PART_OF_SERIES ], // partseries + 18 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 19 => [parent::CR_CALLBACK, 'cbQuestRelation', 0x1, null], // startsfrom [enum] + 21 => [parent::CR_CALLBACK, 'cbQuestRelation', 0x2, null], // endsat [enum] + 22 => [parent::CR_CALLBACK, 'cbItemRewards', null, null], // itemrewards [op] [int] + 23 => [parent::CR_CALLBACK, 'cbItemChoices', null, null], // itemchoices [op] [int] + 24 => [parent::CR_CALLBACK, 'cbLacksStartEnd', null, null], // lacksstartend [yn] + 25 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 27 => [parent::CR_FLAG, 'flags', QUEST_FLAG_DAILY ], // daily + 28 => [parent::CR_FLAG, 'flags', QUEST_FLAG_WEEKLY ], // weekly + 29 => [parent::CR_FLAG, 'specialFlags', QUEST_FLAG_SPECIAL_REPEATABLE], // repeatable + 30 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true], // id + 33 => [parent::CR_ENUM, 'e.holidayId', true, true], // relatedevent + 34 => [parent::CR_CALLBACK, 'cbAvailable', null, null], // availabletoplayers [yn] + 36 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 37 => [parent::CR_CALLBACK, 'cbClassSpec', null, null], // classspecific [enum] + 38 => [parent::CR_CALLBACK, 'cbRaceSpec', null, null], // racespecific [enum] + 42 => [parent::CR_STAFFFLAG, 'flags' ], // flags + 43 => [parent::CR_CALLBACK, 'cbCurrencyReward', null, null], // currencyrewarded [enum] + 44 => [parent::CR_CALLBACK, 'cbLoremaster', null, null], // countsforloremaster_stc [yn] + 45 => [parent::CR_BOOLEAN, 'rewardTitleId' ], // titlerewarded + 47 => [parent::CR_FLAG, 'flags', QUEST_FLAG_FLAGS_PVP ] // setspvpflag + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_RANGE, [1, 47], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 99999]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_INT, true ], // criteria values - only numerals + 'na' => [parent::V_NAME, false, false], // name / text - only printable chars, no delimiter + 'ex' => [parent::V_EQUAL, 'on', false], // also match subname + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'minle' => [parent::V_RANGE, [0, 99], false], // min quest level + 'maxle' => [parent::V_RANGE, [0, 99], false], // max quest level + 'minrl' => [parent::V_RANGE, [0, 99], false], // min required level + 'maxrl' => [parent::V_RANGE, [0, 99], false], // max required level + 'si' => [parent::V_LIST, [-SIDE_HORDE, -SIDE_ALLIANCE, SIDE_ALLIANCE, SIDE_HORDE, SIDE_BOTH], false], // side + 'ty' => [parent::V_LIST, [0, 1, 21, 41, 62, [81, 85], 88, 89], true ] // type + ); + + public array $extraOpts = []; + + protected function createSQLForValues() : array + { + $parts = []; + $_v = $this->values; + + // name + if ($_v['na']) + { + $f = [['na', ['nml.nName', 'nml.nObjectives', 'nml.nDetails']]]; + if ($_v['ex'] != 'on') + $f = [['na', 'nml.nName']]; + + if ($_ = $this->buildMatchLookup($f)) + $parts[] = $_; + else + { + $f = [['na', 'name_loc'.Lang::getLocale()->value], ['na', 'objectives_loc'.Lang::getLocale()->value], ['na', 'details_loc'.Lang::getLocale()->value]]; + if ($_v['ex'] != 'on') + $f = [$f[0]]; + + if ($_ = $this->buildLikeLookup($f)) + $parts[] = $_; + } + } + + // level min + if ($_v['minle']) + $parts[] = ['level', $_v['minle'], '>=']; // not considering quests that are always at player level (-1) + + // level max + if ($_v['maxle']) + $parts[] = ['level', $_v['maxle'], '<=']; + + // reqLevel min + if ($_v['minrl']) + $parts[] = ['minLevel', $_v['minrl'], '>=']; // ignoring maxLevel + + // reqLevel max + if ($_v['maxrl']) + $parts[] = ['minLevel', $_v['maxrl'], '<=']; // ignoring maxLevel + + // side + if ($_v['si']) + { + $excl = [['reqRaceMask', ChrRace::MASK_ALL, '&'], ChrRace::MASK_ALL, '!']; + $incl = [DB::OR, ['reqRaceMask', 0], [['reqRaceMask', ChrRace::MASK_ALL, '&'], ChrRace::MASK_ALL]]; + + $parts[] = match ($_v['si']) + { + SIDE_BOTH => $incl, + SIDE_HORDE => [DB::OR, $incl, ['reqRaceMask', ChrRace::MASK_HORDE, '&']], + -SIDE_HORDE => [DB::AND, $excl, ['reqRaceMask', ChrRace::MASK_HORDE, '&']], + SIDE_ALLIANCE => [DB::OR, $incl, ['reqRaceMask', ChrRace::MASK_ALLIANCE, '&']], + -SIDE_ALLIANCE => [DB::AND, $excl, ['reqRaceMask', ChrRace::MASK_ALLIANCE, '&']] + }; + } + + // questInfoId [list] + if ($_v['ty']) + $parts[] = ['questInfoId', $_v['ty']]; + + return $parts; + } + + protected function cbReputation(int $cr, int $crs, string $crv, string $sign) : ?array + { + if (!Util::checkNumeric($crs, NUM_CAST_INT)) + return null; + + if (!in_array($crs, self::$enums[$cr])) + return null; + + if ($_ = DB::Aowow()->selectRow('SELECT * FROM ::factions WHERE `id` = %i', $crs)) + $this->fiReputationCols[] = [$crs, Util::localizedString($_, 'name')]; + + return [ + DB::OR, + [DB::AND, ['rewardFactionId1', $crs], ['rewardFactionValue1', 0, $sign]], + [DB::AND, ['rewardFactionId2', $crs], ['rewardFactionValue2', 0, $sign]], + [DB::AND, ['rewardFactionId3', $crs], ['rewardFactionValue3', 0, $sign]], + [DB::AND, ['rewardFactionId4', $crs], ['rewardFactionValue4', 0, $sign]], + [DB::AND, ['rewardFactionId5', $crs], ['rewardFactionValue5', 0, $sign]] + ]; + } + + protected function cbQuestRelation(int $cr, int $crs, string $crv, $flags) : ?array + { + return match ($crs) + { + Type::NPC, + Type::OBJECT, + Type::ITEM => [DB::AND, ['qse.type', $crs], ['qse.method', $flags, '&']], + default => null + }; + } + + protected function cbCurrencyReward(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crs, NUM_CAST_INT)) + return null; + + if (!in_array($crs, self::$enums[$cr])) + return null; + + return [ + DB::OR, + ['rewardItemId1', $crs], ['rewardItemId2', $crs], ['rewardItemId3', $crs], ['rewardItemId4', $crs], + ['rewardChoiceItemId1', $crs], ['rewardChoiceItemId2', $crs], ['rewardChoiceItemId3', $crs], ['rewardChoiceItemId4', $crs], ['rewardChoiceItemId5', $crs], ['rewardChoiceItemId6', $crs] + ]; + } + + protected function cbAvailable(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [['cuFlags', CUSTOM_UNAVAILABLE | CUSTOM_DISABLED, '&'], 0]; + else + return ['cuFlags', CUSTOM_UNAVAILABLE | CUSTOM_DISABLED, '&']; + } + + protected function cbItemChoices(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + $this->extraOpts['q']['s'][] = ', (IF(`rewardChoiceItemId1`, 1, 0) + IF(`rewardChoiceItemId2`, 1, 0) + IF(`rewardChoiceItemId3`, 1, 0) + IF(`rewardChoiceItemId4`, 1, 0) + IF(`rewardChoiceItemId5`, 1, 0) + IF(`rewardChoiceItemId6`, 1, 0)) AS "numChoices"'; + $this->extraOpts['q']['h'][] = '`numChoices` '.$crs.' '.$crv; + return [1]; + } + + protected function cbItemRewards(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + $this->extraOpts['q']['s'][] = ', (IF(`rewardItemId1`, 1, 0) + IF(`rewardItemId2`, 1, 0) + IF(`rewardItemId3`, 1, 0) + IF(`rewardItemId4`, 1, 0)) AS "numRewards"'; + $this->extraOpts['q']['h'][] = '`numRewards` '.$crs.' '.$crv; + return [1]; + } + + protected function cbLoremaster(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [DB::AND, ['questSortId', 0, '>'], [['flags', QUEST_FLAG_DAILY | QUEST_FLAG_WEEKLY, '&'], 0], [['specialFlags', QUEST_FLAG_SPECIAL_REPEATABLE | QUEST_FLAG_SPECIAL_MONTHLY, '&'], 0]]; + else + return [DB::OR, ['questSortId', 0, '<'], ['flags', QUEST_FLAG_DAILY | QUEST_FLAG_WEEKLY, '&'], ['specialFlags', QUEST_FLAG_SPECIAL_REPEATABLE | QUEST_FLAG_SPECIAL_MONTHLY, '&']]; + } + + protected function cbSpellRewards(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [DB::OR, ['sourceSpellId', 0, '>'], ['rewardSpell', 0, '>'], ['rsc.effect1Id', SpellList::EFFECTS_TEACH], ['rsc.effect2Id', SpellList::EFFECTS_TEACH], ['rsc.effect3Id', SpellList::EFFECTS_TEACH]]; + else + return [DB::AND, ['sourceSpellId', 0], ['rewardSpell', 0], ['rewardSpellCast', 0]]; + } + + protected function cbEarnReputation(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crs, NUM_CAST_INT)) + return null; + + if ($crs == parent::ENUM_ANY) + return [DB::OR, ['reqFactionId1', 0, '>'], ['reqFactionId2', 0, '>']]; + else if ($crs == parent::ENUM_NONE) + return [DB::AND, ['reqFactionId1', 0], ['reqFactionId2', 0]]; + else if (in_array($crs, self::$enums[$cr])) + return [DB::OR, ['reqFactionId1', $crs], ['reqFactionId2', $crs]]; + + return null; + } + + protected function cbClassSpec(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if ($_ === true) + return [DB::AND, ['reqClassMask', 0, '!'], [['reqClassMask', ChrClass::MASK_ALL, '&'], ChrClass::MASK_ALL, '!']]; + else if ($_ === false) + return [DB::OR, ['reqClassMask', 0], [['reqClassMask', ChrClass::MASK_ALL, '&'], ChrClass::MASK_ALL]]; + else if (is_int($_)) + return [DB::AND, ['reqClassMask', ChrClass::from($_)->toMask(), '&'], [['reqClassMask', ChrClass::MASK_ALL, '&'], ChrClass::MASK_ALL, '!']]; + + return null; + } + + protected function cbRaceSpec(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if ($_ === true) + return [DB::AND, ['reqRaceMask', 0, '!'], [['reqRaceMask', ChrRace::MASK_ALL, '&'], ChrRace::MASK_ALL, '!'], [['reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], ChrRace::MASK_ALLIANCE, '!'], [['reqRaceMask', ChrRace::MASK_HORDE, '&'], ChrRace::MASK_HORDE, '!']]; + else if ($_ === false) + return [DB::OR, ['reqRaceMask', 0], ['reqRaceMask', ChrRace::MASK_ALL], ['reqRaceMask', ChrRace::MASK_ALLIANCE], ['reqRaceMask', ChrRace::MASK_HORDE]]; + else if (is_int($_)) + return [DB::AND, ['reqRaceMask', ChrRace::from($_)->toMask(), '&'], [['reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], ChrRace::MASK_ALLIANCE, '!'], [['reqRaceMask', ChrRace::MASK_HORDE, '&'], ChrRace::MASK_HORDE, '!']]; + + return null; + } + + protected function cbLacksStartEnd(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + $missing = DB::Aowow()->selectCol('SELECT `questId`, BIT_OR(`method`) AS "se" FROM ::quests_startend GROUP BY `questId` HAVING "se" <> 3'); + if ($crs) + return ['id', $missing]; + else + return ['id', $missing, '!']; + } +} + + +?> diff --git a/includes/types/skill.class.php b/includes/dbtypes/skill.class.php similarity index 56% rename from includes/types/skill.class.php rename to includes/dbtypes/skill.class.php index 101dfda0..9aafbcb1 100644 --- a/includes/types/skill.class.php +++ b/includes/dbtypes/skill.class.php @@ -1,24 +1,26 @@ [['ic']], - 'ic' => ['j' => ['?_icons ic ON ic.id = sl.iconId', true], 's' => ', ic.name AS iconString'], + 'ic' => ['j' => ['::icons ic ON ic.`id` = sl.`iconId`', true], 's' => ', ic.`name` AS "iconString"'], ); - public function __construct($conditions = []) + public function __construct(array $conditions = [], array $miscData = []) { - parent::__construct($conditions); + parent::__construct($conditions, $miscData); // post processing foreach ($this->iterate() as &$_curTpl) @@ -27,24 +29,14 @@ class SkillList extends BaseType if (!$_) $_ = [0, 0, 0, 0, 0]; else - { - $_ = explode(' ', $_); - while (count($_) < 5) - $_[] = 0; - } + $_ = array_pad(explode(' ', $_), 5, 0); if (!$_curTpl['iconId']) - $_curTpl['iconString'] = 'inv_misc_questionmark'; + $_curTpl['iconString'] = DEFAULT_ICON; } } - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_skillline WHERE id = ?d', $id); - return Util::localizedString($n, 'name'); - } - - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -65,7 +57,7 @@ class SkillList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -75,7 +67,7 @@ class SkillList extends BaseType return $data; } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } ?> diff --git a/includes/types/sound.class.php b/includes/dbtypes/sound.class.php similarity index 53% rename from includes/types/sound.class.php rename to includes/dbtypes/sound.class.php index 0b7d00cf..f43a8fb3 100644 --- a/includes/types/sound.class.php +++ b/includes/dbtypes/sound.class.php @@ -1,29 +1,28 @@ 'audio/ogg; codecs="vorbis"', - SOUND_TYPE_MP3 => 'audio/mpeg' - ); + private array $fileBuffer = []; + private static array $fileTypes = [SOUND_TYPE_OGG => MIME_TYPE_OGG, SOUND_TYPE_MP3 => MIME_TYPE_MP3]; - public function __construct($conditions = []) + public function __construct(array $conditions = [], array $miscData = []) { - parent::__construct($conditions); + parent::__construct($conditions, $miscData); // post processing foreach ($this->iterate() as $id => &$_curTpl) @@ -43,24 +42,31 @@ class SoundList extends BaseType if ($this->fileBuffer) { - $files = DB::Aowow()->select('SELECT id AS ARRAY_KEY, `id`, `file` AS title, `type`, `path` FROM ?_sounds_files sf WHERE id IN (?a)', array_keys($this->fileBuffer)); + $files = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `id`, `file` AS "title", CAST(`type` AS UNSIGNED) AS "type", `path` FROM ::sounds_files sf WHERE `id` IN %in', array_keys($this->fileBuffer)); foreach ($files as $id => $data) { // 3.3.5 bandaid - need fullpath to play via wow API, remove for cata and later $data['path'] = str_replace('\\', '\\\\', $data['path'] ? $data['path'] . '\\' . $data['title'] : $data['title']); - // skipp file extension + // skip file extension $data['title'] = substr($data['title'], 0, -4); // enum to string $data['type'] = self::$fileTypes[$data['type']]; // get real url - $data['url'] = STATIC_URL . '/wowsounds/' . $data['id']; + $data['url'] = Cfg::get('STATIC_URL') . '/wowsounds/' . $data['id']; // v push v $this->fileBuffer[$id] = $data; } } } - public function getListviewData() + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->SelectRow('SELECT `name` AS "name_loc0" FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n); + return null; + } + + public function getListviewData() : array { $data = []; @@ -77,7 +83,7 @@ class SoundList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -91,37 +97,29 @@ class SoundList extends BaseType return $data; } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } class SoundListFilter extends Filter { - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ty' => [FILTER_V_LIST, [[1, 4], 6, 9, 10, 12, 13, 14, 16, 17, [19, 23], [25, 31], 50, 52, 53], true ] // type + protected string $type = 'sounds'; + protected static array $inputFields = array( + 'na' => [parent::V_NAME, false, false], // name - only printable chars, no delimiter + 'ty' => [parent::V_LIST, [[1, 4], 6, 9, 10, 12, 13, 14, 16, 17, [19, 31], 50, 52, 53], true ] // type ); - // we have no criteria for this one... - protected function createSQLForCriterium(&$cr) - { - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() + protected function createSQLForValues() : array { $parts = []; - $_v = &$this->fiData['v']; + $_v = &$this->values; // name [str] - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name'])) + if ($_v['na']) + if ($_ = $this->buildLikeLookup([['na', 'name']])) $parts[] = $_; // type [list] - if (isset($_v['ty'])) + if ($_v['ty']) $parts[] = ['cat', $_v['ty']]; return $parts; diff --git a/includes/dbtypes/spell.class.php b/includes/dbtypes/spell.class.php new file mode 100644 index 00000000..6aefde19 --- /dev/null +++ b/includes/dbtypes/spell.class.php @@ -0,0 +1,2879 @@ + [ 43, 44, 45, 46, 54, 55, 95, 118, 136, 160, 162, 172, 173, 176, 226, 228, 229, 473], // Weapons + 8 => [293, 413, 414, 415, 433], // Armor + 9 => SKILLS_TRADE_SECONDARY, // sec. Professions + 10 => [ 98, 109, 111, 113, 115, 137, 138, 139, 140, 141, 313, 315, 673, 759], // Languages + 11 => SKILLS_TRADE_PRIMARY // prim. Professions + ); + + public const EFFECTS_SCALING_HEAL = array( // as per Unit::SpellHealingBonusDone() calls in TC + SPELL_EFFECT_HEAL, SPELL_EFFECT_HEAL_PCT, SPELL_EFFECT_HEAL_MECHANICAL, SPELL_EFFECT_HEALTH_LEECH + ); + public const EFFECTS_SCALING_DAMAGE = array( // as per Unit::SpellDamageBonusDone() calls in TC + SPELL_EFFECT_SCHOOL_DAMAGE, SPELL_EFFECT_HEALTH_LEECH, SPELL_EFFECT_POWER_BURN + ); + public const EFFECTS_LDC_SCALING = array( + SPELL_EFFECT_SCHOOL_DAMAGE, SPELL_EFFECT_DUMMY, SPELL_EFFECT_POWER_DRAIN, SPELL_EFFECT_HEALTH_LEECH, SPELL_EFFECT_HEAL, + SPELL_EFFECT_WEAPON_DAMAGE, SPELL_EFFECT_POWER_BURN, SPELL_EFFECT_SCRIPT_EFFECT, SPELL_EFFECT_NORMALIZED_WEAPON_DMG, SPELL_EFFECT_FORCE_CAST_WITH_VALUE, + SPELL_EFFECT_TRIGGER_SPELL_WITH_VALUE, SPELL_EFFECT_TRIGGER_MISSILE_SPELL_WITH_VALUE + ); + public const EFFECTS_ITEM_CREATE = array( + SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_SUMMON_CHANGE_ITEM, SPELL_EFFECT_CREATE_RANDOM_ITEM, SPELL_EFFECT_CREATE_MANA_GEM, SPELL_EFFECT_CREATE_ITEM_2 + ); + public const EFFECTS_TRIGGER = array( + SPELL_EFFECT_DUMMY, SPELL_EFFECT_TRIGGER_MISSILE, SPELL_EFFECT_TRIGGER_SPELL, SPELL_EFFECT_FEED_PET, SPELL_EFFECT_FORCE_CAST, + SPELL_EFFECT_FORCE_CAST_WITH_VALUE, SPELL_EFFECT_TRIGGER_SPELL_WITH_VALUE, SPELL_EFFECT_TRIGGER_MISSILE_SPELL_WITH_VALUE, SPELL_EFFECT_TRIGGER_SPELL_2, SPELL_EFFECT_SUMMON_RAF_FRIEND, + SPELL_EFFECT_TITAN_GRIP, SPELL_EFFECT_FORCE_CAST_2, SPELL_EFFECT_REMOVE_AURA + ); + public const EFFECTS_TEACH = array( + SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_LEARN_PET_SPELL /*SPELL_EFFECT_UNLEARN_SPECIALIZATION*/ + ); + public const EFFECTS_MODEL_OBJECT = array( + SPELL_EFFECT_TRANS_DOOR, SPELL_EFFECT_SUMMON_OBJECT_WILD, SPELL_EFFECT_SUMMON_OBJECT_SLOT1, SPELL_EFFECT_SUMMON_OBJECT_SLOT2, SPELL_EFFECT_SUMMON_OBJECT_SLOT3, + SPELL_EFFECT_SUMMON_OBJECT_SLOT4 + ); + public const EFFECTS_MODEL_NPC = array( + SPELL_EFFECT_SUMMON, SPELL_EFFECT_SUMMON_PET, SPELL_EFFECT_SUMMON_DEMON, SPELL_EFFECT_KILL_CREDIT, SPELL_EFFECT_KILL_CREDIT2 + ); + public const EFFECTS_DIRECT_SCALING = array( // as per Unit::GetCastingTimeForBonus() + SPELL_EFFECT_SCHOOL_DAMAGE, SPELL_EFFECT_ENVIRONMENTAL_DAMAGE, SPELL_EFFECT_POWER_DRAIN, SPELL_EFFECT_HEALTH_LEECH, SPELL_EFFECT_POWER_BURN, + SPELL_EFFECT_HEAL + ); + public const EFFECTS_ENCHANTMENT = array( + SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_ENCHANT_HELD_ITEM, SPELL_EFFECT_ENCHANT_ITEM_PRISMATIC + ); + + public const AURAS_SCALING_HEAL = array( // as per Unit::SpellHealingBonusDone() calls in TC (SPELL_AURA_SCHOOL_ABSORB + SPELL_AURA_MANA_SHIELD priest/mage cases are scripted) + SPELL_AURA_PERIODIC_HEAL, SPELL_AURA_PERIODIC_LEECH, SPELL_AURA_OBS_MOD_HEALTH + ); + public const AURAS_SCALING_DAMAGE = array( // as per Unit::SpellDamageBonusDone() calls in TC + SPELL_AURA_PERIODIC_DAMAGE, SPELL_AURA_PERIODIC_LEECH, SPELL_AURA_DAMAGE_SHIELD, SPELL_AURA_PROC_TRIGGER_DAMAGE + ); + public const AURAS_LDC_SCALING = array( + SPELL_AURA_PERIODIC_DAMAGE, SPELL_AURA_DUMMY, SPELL_AURA_PERIODIC_HEAL, SPELL_AURA_DAMAGE_SHIELD, SPELL_AURA_PROC_TRIGGER_DAMAGE, + SPELL_AURA_PERIODIC_LEECH, SPELL_AURA_PERIODIC_MANA_LEECH, SPELL_AURA_SCHOOL_ABSORB, SPELL_AURA_PERIODIC_TRIGGER_SPELL_WITH_VALUE + ); + public const AURAS_ITEM_CREATE = array( + SPELL_AURA_CHANNEL_DEATH_ITEM + ); + public const AURAS_TRIGGER = array( + SPELL_AURA_DUMMY, SPELL_AURA_PERIODIC_TRIGGER_SPELL, SPELL_AURA_PROC_TRIGGER_SPELL, SPELL_AURA_PERIODIC_TRIGGER_SPELL_FROM_CLIENT, SPELL_AURA_ADD_TARGET_TRIGGER, + SPELL_AURA_PERIODIC_DUMMY, SPELL_AURA_PERIODIC_TRIGGER_SPELL_WITH_VALUE, SPELL_AURA_PROC_TRIGGER_SPELL_WITH_VALUE, SPELL_AURA_CONTROL_VEHICLE, SPELL_AURA_LINKED + ); + public const AURAS_MODEL_NPC = array( + SPELL_AURA_TRANSFORM, SPELL_AURA_MOUNTED, SPELL_AURA_CHANGE_MODEL_FOR_ALL_HUMANOIDS, SPELL_AURA_X_RAY, + SPELL_AURA_MOD_FAKE_INEBRIATE + ); + public const AURAS_PERIODIC_SCALING = array( // as per Unit::GetCastingTimeForBonus() + SPELL_AURA_PERIODIC_DAMAGE, SPELL_AURA_PERIODIC_HEAL, SPELL_AURA_PERIODIC_LEECH + ); + + private array $spellVars = []; + private array $refSpells = []; + private array $tools = []; + private int $interactive = self::INTERACTIVE_EMBEDDED; + private int $charLevel = MAX_LEVEL; + private array $scaling = []; + private array $parsedText = []; + private static array $spellTypes = array( + 6 => 1, + 8 => 2, + 10 => 4 + ); + + protected string $queryBase = 'SELECT s.*, s.`id` AS ARRAY_KEY FROM ::spell s'; + protected array $queryOpts = array( + 's' => [['src', 'sr', 'ic', 'ica']], // 6: Type::SPELL + 'nml' => ['j' => ['::spell_search nml ON nml.`id` = s.`id` AND nml.`locale` = DB_LOC_I']], + 'ic' => ['j' => ['::icons ic ON ic.`id` = s.`iconId`', true], 's' => ', ic.`name` AS "iconString"'], + 'ica' => ['j' => ['::icons ica ON ica.`id` = s.`iconIdAlt`', true], 's' => ', ica.`name` AS "iconStringAlt"'], + 'sr' => ['j' => ['::spellrange sr ON sr.`id` = s.`rangeId`'], 's' => ', sr.`rangeMinHostile`, sr.`rangeMinFriend`, sr.`rangeMaxHostile`, sr.`rangeMaxFriend`, sr.`name_loc0` AS "rangeText_loc0", sr.`name_loc2` AS "rangeText_loc2", sr.`name_loc3` AS "rangeText_loc3", sr.`name_loc4` AS "rangeText_loc4", sr.`name_loc6` AS "rangeText_loc6", sr.`name_loc8` AS "rangeText_loc8"'], + 'src' => ['j' => ['::source src ON `type` = 6 AND `typeId` = s.`id`', true], 's' => ', `moreType`, `moreTypeId`, `moreZoneId`, `moreMask`, `src1`, `src2`, `src3`, `src4`, `src5`, `src6`, `src7`, `src8`, `src9`, `src10`, `src11`, `src12`, `src13`, `src14`, `src15`, `src16`, `src17`, `src18`, `src19`, `src20`, `src21`, `src22`, `src23`, `src24`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + if ($this->error) + return; + + if (isset($miscData['interactive'])) + $this->interactive = $miscData['interactive']; + + if (isset($miscData['charLevel'])) + $this->charLevel = $miscData['charLevel']; + + // post processing + $foo = DB::World()->selectCol('SELECT `perfectItemType` FROM skill_perfect_item_template WHERE `spellId` IN %in', $this->getFoundIDs()); + foreach ($this->iterate() as &$_curTpl) + { + // required for globals + if ($idx = $this->canCreateItem()) + foreach ($idx as $i) + $foo[] = (int)$_curTpl['effect'.$i.'CreateItemId']; + + for ($i = 1; $i <= 8; $i++) + if ($_curTpl['reagent'.$i] > 0) + $foo[] = (int)$_curTpl['reagent'.$i]; + + for ($i = 1; $i <= 2; $i++) + if ($_curTpl['tool'.$i] > 0) + $foo[] = (int)$_curTpl['tool'.$i]; + + // ranks + $this->ranks[$this->id] = $this->getField('rank', true); + + // sources + for ($i = 1; $i < 25; $i++) + { + if ($_ = $_curTpl['src'.$i]) + $this->sources[$this->id][$i][] = $_; + + unset($_curTpl['src'.$i]); + } + + // set full masks to 0 + $_curTpl['reqClassMask'] &= ChrClass::MASK_ALL; + if ($_curTpl['reqClassMask'] == ChrClass::MASK_ALL) + $_curTpl['reqClassMask'] = 0; + + $_curTpl['reqRaceMask'] &= ChrRace::MASK_ALL; + if ($_curTpl['reqRaceMask'] == ChrRace::MASK_ALL) + $_curTpl['reqRaceMask'] = 0; + + // unpack skillLines + $_curTpl['skillLines'] = []; + if ($_curTpl['skillLine1'] < 0) + { + foreach (Game::$skillLineMask[$_curTpl['skillLine1']] as $idx => [, $skillLineId]) + if ($_curTpl['skillLine2OrMask'] & (1 << $idx)) + $_curTpl['skillLines'][] = $skillLineId; + } + else if ($sec = $_curTpl['skillLine2OrMask']) + { + if ($this->id == 818) // and another hack .. basic Campfire (818) has deprecated skill Survival (142) as first skillLine + $_curTpl['skillLines'] = [$sec, $_curTpl['skillLine1']]; + else + $_curTpl['skillLines'] = [$_curTpl['skillLine1'], $sec]; + } + else if ($prim = $_curTpl['skillLine1']) + $_curTpl['skillLines'] = [$prim]; + + unset($_curTpl['skillLine1']); + unset($_curTpl['skillLine2OrMask']); + + // fix missing icons + $_curTpl['iconString'] = $_curTpl['iconString'] ?: DEFAULT_ICON; + + $this->scaling[$this->id] = false; + } + + if ($foo) + $this->relItems = new ItemList(array(['i.id', array_unique($foo)])); + } + + // required for item-comparison + public function getStatGain() : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[$this->id] = new StatsContainer(); + + foreach ($this->canEnchantmentItem() as $i) + $data[$this->id]->fromDB(Type::ENCHANTMENT, $this->curTpl['effect'.$i.'MiscValue']); + + // todo: should enchantments be included here...? + $data[$this->id]->fromSpell($this->curTpl); + } + + return $data; + } + + public function getProfilerMods() : array + { + // weapon hand check: param: slot, class, subclass, value + $whCheck = '$function() { var j, w = _inventory.getInventory()[%d]; if (!w[0] || !g_items[w[0]]) { return 0; } j = g_items[w[0]].jsonequip; return (j.classs == %d && (%d & (1 << (j.subclass)))) ? %d : 0; }'; + + $data = []; // flat gains + foreach ($this->getStatGain() as $id => $spellData) + { + $data[$id] = $spellData->toJson(STAT::FLAG_ITEM | STAT::FLAG_PROFILER, false); + + // apply weapon restrictions + $this->getEntry($id); + $class = $this->getField('equippedItemClass'); + $subClass = $this->getField('equippedItemSubClassMask'); + $slot = $subClass & 0x5000C ? 18 : 16; + if ($class != ITEM_CLASS_WEAPON || !$subClass) + continue; + + foreach ($data[$id] as $key => $amt) + $data[$id][$key] = [1, 'functionOf', sprintf($whCheck, $slot, $class, $subClass, $amt)]; + } + + // 4 possible modifiers found + // => [0.15, 'functionOf', ] + // => [0.33, 'percentOf', ] + // => [123, 'add'] + // => ... as from getStatGain() + + $modXByStat = function (array &$arr, int $srcStat, ?string $destStat, int $pts) : void + { + match ($srcStat) + { + STAT_STRENGTH => $arr[$destStat ?: 'str'] = [$pts / 100, 'percentOf', 'str'], + STAT_AGILITY => $arr[$destStat ?: 'agi'] = [$pts / 100, 'percentOf', 'agi'], + STAT_STAMINA => $arr[$destStat ?: 'sta'] = [$pts / 100, 'percentOf', 'sta'], + STAT_INTELLECT => $arr[$destStat ?: 'int'] = [$pts / 100, 'percentOf', 'int'], + STAT_SPIRIT => $arr[$destStat ?: 'spi'] = [$pts / 100, 'percentOf', 'spi'] + }; + }; + + $modXBySchool = function (array &$arr, int $srcStat, string $destStat, array|int $val) : void + { + if ($srcStat & (1 << SPELL_SCHOOL_HOLY)) + $arr['hol'.$destStat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'hol'.$destStat]; + if ($srcStat & (1 << SPELL_SCHOOL_FIRE)) + $arr['fir'.$destStat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'fir'.$destStat]; + if ($srcStat & (1 << SPELL_SCHOOL_NATURE)) + $arr['nat'.$destStat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'nat'.$destStat]; + if ($srcStat & (1 << SPELL_SCHOOL_FROST)) + $arr['fro'.$destStat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'fro'.$destStat]; + if ($srcStat & (1 << SPELL_SCHOOL_SHADOW)) + $arr['sha'.$destStat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'sha'.$destStat]; + if ($srcStat & (1 << SPELL_SCHOOL_ARCANE)) + $arr['arc'.$destStat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'arc'.$destStat]; + }; + + $jsonStat = function (int $statId) : string + { + return match ($statId) + { + STAT_STRENGTH => Stat::getJsonString(Stat::STRENGTH), + STAT_AGILITY => Stat::getJsonString(Stat::AGILITY), + STAT_STAMINA => Stat::getJsonString(Stat::STAMINA), + STAT_INTELLECT => Stat::getJsonString(Stat::INTELLECT), + STAT_SPIRIT => Stat::getJsonString(Stat::SPIRIT) + }; + }; + + foreach ($this->iterate() as $id => $__) + { + // Shaman - Spirit Weapons (16268) (parry is normaly stored in g_statistics) + // i should recurse into SPELL_EFFECT_LEARN_SPELL and apply SPELL_EFFECT_PARRY from there + if ($id == 16268) + { + $data[$id]['parrypct'] = [5, 'add']; + continue; + } + + if (!($this->getField('attributes0') & SPELL_ATTR0_PASSIVE)) + continue; + + for ($i = 1; $i < 4; $i++) + { + $pts = $this->calculateAmountForCurrent($i)[1]; + $mv = $this->getField('effect'.$i.'MiscValue'); + $mvB = $this->getField('effect'.$i.'MiscValueB'); + $au = $this->getField('effect'.$i.'AuraId'); + $class = $this->getField('equippedItemClass'); + $subClass = $this->getField('equippedItemSubClassMask'); + + + /* ISSUE! + mods formated like ['' => [, 'percentOf', '']] are applied as multiplier and not + as a flat value (that is equal to the percentage, like they should be). So the stats-table won't show the actual deficit + */ + + switch ($au) + { + case SPELL_AURA_MOD_RESISTANCE_PCT: + case SPELL_AURA_MOD_BASE_RESISTANCE_PCT: + // Armor only if explicitly specified only affects armor from equippment + if ($mv == (1 << SPELL_SCHOOL_NORMAL)) + $data[$id]['armor'] = [$pts / 100, 'percentOf', ['armor', 0]]; + else if ($mv) + $modXBySchool($data[$id], $mv, 'res', $pts); + break; + case SPELL_AURA_MOD_RESISTANCE_OF_STAT_PERCENT: + // Armor only if explicitly specified + if ($mv == (1 << SPELL_SCHOOL_NORMAL)) + $data[$id]['armor'] = [$pts / 100, 'percentOf', $jsonStat($mvB)]; + else if ($mv) + $modXBySchool($data[$id], $mv, 'res', [$pts / 100, 'percentOf', $jsonStat($mvB)]); + break; + case SPELL_AURA_MOD_TOTAL_STAT_PERCENTAGE: + if ($mv > -1) // one stat + $modXByStat($data[$id], $mv, null, $pts); + else if ($mv < 0) // all stats + for ($iMod = ITEM_MOD_AGILITY; $iMod <= ITEM_MOD_STAMINA; $iMod++) + if ($idx = Stat::getIndexFrom(Stat::IDX_ITEM_MOD, $iMod)) + if ($key = Stat::getJsonString($idx)) + $data[$id][$key] = [$pts / 100, 'percentOf', $key]; + break; + case SPELL_AURA_MOD_SPELL_DAMAGE_OF_STAT_PERCENT: + $mv = $mv ?: SPELL_MAGIC_SCHOOLS; + $modXBySchool($data[$id], $mv, 'spldmg', [$pts / 100, 'percentOf', $jsonStat($mvB)]); + break; + case SPELL_AURA_MOD_RANGED_ATTACK_POWER_OF_STAT_PERCENT: + $modXByStat($data[$id], $mv, 'rgdatkpwr', $pts); + break; + case SPELL_AURA_MOD_ATTACK_POWER_OF_STAT_PERCENT: + $modXByStat($data[$id], $mv, 'mleatkpwr', $pts); + break; + case SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT: + $modXByStat($data[$id], $mv, 'splheal', $pts); + break; + case SPELL_AURA_MOD_MANA_REGEN_FROM_STAT: + $modXByStat($data[$id], $mv, 'manargn', $pts); + break; + case SPELL_AURA_MOD_MANA_REGEN_INTERRUPT: + $data[$id]['icmanargn'] = [$pts / 100, 'percentOf', 'oocmanargn']; + break; + case SPELL_AURA_MOD_SPELL_CRIT_CHANCE: + case SPELL_AURA_MOD_SPELL_CRIT_CHANCE_SCHOOL: + $mv = $mv ?: SPELL_MAGIC_SCHOOLS; + $modXBySchool($data[$id], $mv, 'splcritstrkpct', [$pts, 'add']); + if (($mv & SPELL_MAGIC_SCHOOLS) == SPELL_MAGIC_SCHOOLS) + $data[$id]['splcritstrkpct'] = [$pts, 'add']; + break; + case SPELL_AURA_MOD_ATTACK_POWER_OF_ARMOR: + $data[$id]['mleatkpwr'] = [1 / $pts, 'percentOf', 'fullarmor']; + $data[$id]['rgdatkpwr'] = [1 / $pts, 'percentOf', 'fullarmor']; + break; + case SPELL_AURA_MOD_WEAPON_CRIT_PERCENT: + if ($class < 1 || ($class == ITEM_CLASS_WEAPON && ($subClass & 0x5000C))) + $data[$id]['rgdcritstrkpct'] = [1, 'functionOf', sprintf($whCheck, 18, $class, $subClass, $pts)]; + // $data[$id]['rgdcritstrkpct'] = [$pts, 'add']; + if ($class < 1 || ($class == ITEM_CLASS_WEAPON && ($subClass & 0xA5F3))) + $data[$id]['mlecritstrkpct'] = [1, 'functionOf', sprintf($whCheck, 16, $class, $subClass, $pts)]; + // $data[$id]['mlecritstrkpct'] = [$pts, 'add']; + break; + case SPELL_AURA_MOD_PARRY_PERCENT: + $data[$id]['parrypct'] = [$pts, 'add']; + break; + case SPELL_AURA_MOD_DODGE_PERCENT: + $data[$id]['dodgepct'] = [$pts, 'add']; + break; + case SPELL_AURA_MOD_BLOCK_PERCENT: + $data[$id]['blockpct'] = [$pts, 'add']; + break; + case SPELL_AURA_MOD_INCREASE_ENERGY_PERCENT: + if ($mv == POWER_HEALTH) + $data[$id]['health'] = [$pts / 100, 'percentOf', 'health']; + else if ($mv == POWER_ENERGY) + $data[$id]['energy'] = [$pts / 100, 'percentOf', 'energy']; + else if ($mv == POWER_MANA) + $data[$id]['mana'] = [$pts / 100, 'percentOf', 'mana']; + else if ($mv == POWER_RAGE) + $data[$id]['rage'] = [$pts / 100, 'percentOf', 'rage']; + else if ($mv == POWER_RUNIC_POWER) + $data[$id]['runic'] = [$pts / 100, 'percentOf', 'runic']; + break; + case SPELL_AURA_MOD_INCREASE_HEALTH_PERCENT: + $data[$id]['health'] = [$pts / 100, 'percentOf', 'health']; + break; + case SPELL_AURA_MOD_BASE_HEALTH_PCT: // only Tauren - Endurance (20550) ... if you are looking for something elegant, look away! + $data[$id]['health'] = [$pts / 100, 'functionOf', '$(x) => g_statistics.combo[x.classs][x.level][5]']; + break; + case SPELL_AURA_MOD_SHIELD_BLOCKVALUE_PCT: + $data[$id]['block'] = [$pts / 100, 'percentOf', 'block']; + break; + case SPELL_AURA_MOD_CRIT_PCT: + $data[$id]['mlecritstrkpct'] = [$pts, 'add']; + $data[$id]['rgdcritstrkpct'] = [$pts, 'add']; + $data[$id]['splcritstrkpct'] = [$pts, 'add']; + break; + case SPELL_AURA_MOD_SPELL_DAMAGE_OF_ATTACK_POWER: + $mv = $mv ?: SPELL_MAGIC_SCHOOLS; + $modXBySchool($data[$id], $mv, 'spldmg', [$pts / 100, 'percentOf', 'mleatkpwr']); + break; + case SPELL_AURA_MOD_SPELL_HEALING_OF_ATTACK_POWER: + $data[$id]['splheal'] = [$pts / 100, 'percentOf', 'mleatkpwr']; + break; + case SPELL_AURA_MOD_ATTACK_POWER_PCT: // ingame only melee..? + $data[$id]['mleatkpwr'] = [$pts / 100, 'percentOf', 'mleatkpwr']; + break; + case SPELL_AURA_MOD_HEALTH_REGEN_PERCENT: + $data[$id]['healthrgn'] = [$pts / 100, 'percentOf', 'healthrgn']; + break; + } + } + } + + return $data; + } + + // halper + public function getReagentsForCurrent() : array + { + $data = []; + + for ($i = 1; $i <= 8; $i++) + if ($this->curTpl['reagent'.$i] > 0 && $this->curTpl['reagentCount'.$i]) + $data[$this->curTpl['reagent'.$i]] = [$this->curTpl['reagent'.$i], $this->curTpl['reagentCount'.$i]]; + + return $data; + } + + public function getToolsForCurrent() : array + { + if ($this->tools) + return $this->tools; + + $tools = []; + for ($i = 1; $i <= 2; $i++) + { + // TotemCategory + if ($_ = $this->curTpl['toolCategory'.$i]) + { + $tc = DB::Aowow()->selectRow('SELECT * FROM ::totemcategory WHERE `id` = %i', $_); + $tools[$i + 1] = array( + 'id' => $_, + 'name' => Util::localizedString($tc, 'name')); + } + + // Tools + if (!$this->curTpl['tool'.$i]) + continue; + + foreach ($this->relItems->iterate() as $relId => $__) + { + if ($relId != $this->curTpl['tool'.$i]) + continue; + + $tools[$i - 1] = array( + 'itemId' => $relId, + 'name' => $this->relItems->getField('name', true), + 'quality' => $this->relItems->getField('quality') + ); + + break; + } + } + + $this->tools = array_reverse($tools); + + return $this->tools; + } + + public function getModelInfo(int $spellId = 0, int $effIdx = 0) : array + { + $displays = $results = []; + + foreach ($this->iterate() as $id => $__) + { + if ($spellId && $spellId != $id) + continue; + + for ($i = 1; $i < 4; $i++) + { + if ($spellId && $effIdx && $effIdx != $i) + continue; + + $effMV = $this->curTpl['effect'.$i.'MiscValue']; + if (!$effMV) + continue; + + // GO Model from MiscVal + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_MODEL_OBJECT)) + { + if (isset($displays[Type::OBJECT][$id])) + $displays[Type::OBJECT][$id][0][] = $i; + else + $displays[Type::OBJECT][$id] = [[$i], $effMV]; + } + // NPC Model from MiscVal + else if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_MODEL_NPC) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_MODEL_NPC)) + { + if (isset($displays[Type::NPC][$id])) + $displays[Type::NPC][$id][0][] = $i; + else + $displays[Type::NPC][$id] = [[$i], $effMV]; + } + // Shapeshift + else if ($this->curTpl['effect'.$i.'AuraId'] == SPELL_AURA_MOD_SHAPESHIFT) + { + $subForms = array( + 892 => [892, 29407, 29406, 29408, 29405], // Cat - NE + 8571 => [8571, 29410, 29411, 29412], // Cat - Tauren + 2281 => [2281, 29413, 29414, 29416, 29417], // Bear - NE + 2289 => [2289, 29415, 29418, 29419, 29420, 29421] // Bear - Tauren + ); + + if ($st = DB::Aowow()->selectRow('SELECT *, `displayIdA` AS "model1", `displayIdH` AS "model2" FROM ::shapeshiftforms WHERE `id` = %i', $effMV)) + { + foreach ([1, 2] as $j) + if (isset($subForms[$st['model'.$j]])) + $st['model'.$j] = $subForms[$st['model'.$j]][array_rand($subForms[$st['model'.$j]])]; + + $results[$id][$i] = array( + 'type' => Type::NPC, + 'creatureType' => $st['creatureType'], + 'displayId' => $st['model2'] ? $st['model'.rand(1, 2)] : $st['model1'], + 'displayName' => Lang::game('st', $effMV) + ); + } + } + } + } + + if (!empty($displays[Type::NPC])) + { + $nModels = new CreatureList(array(['id', array_column($displays[Type::NPC], 1)])); + foreach ($nModels->iterate() as $nId => $__) + { + foreach ($displays[Type::NPC] as $srcId => [$indizes, $npcId]) + { + if ($npcId == $nId) + { + foreach ($indizes as $idx) + { + $res = array( + 'type' => Type::NPC, + 'typeId' => $nId, + 'displayId' => $nModels->getRandomModelId(), + 'displayName' => $nModels->getField('name', true) + ); + + if ($nModels->getField('humanoid')) + $res['humanoid'] = 1; + + $results[$srcId][$idx] = $res; + } + } + } + } + } + + if (!empty($displays[Type::OBJECT])) + { + $oModels = new GameObjectList(array(['id', array_column($displays[Type::OBJECT], 1)])); + foreach ($oModels->iterate() as $oId => $__) + { + foreach ($displays[Type::OBJECT] as $srcId => [$indizes, $objId]) + { + if ($objId == $oId) + { + foreach ($indizes as $idx) + { + $results[$srcId][$idx] = array( + 'type' => Type::OBJECT, + 'typeId' => $oId, + 'displayId' => $oModels->getField('displayId'), + 'displayName' => $oModels->getField('name', true) + ); + } + } + } + } + } + + if ($spellId && $effIdx) + return $results[$spellId][$effIdx] ?? []; + + if ($spellId) + return $results[$spellId] ?? []; + + return $results; + } + + private function createRangesForCurrent() : string + { + if (!$this->curTpl['rangeMaxHostile']) + return ''; + + if ($this->curTpl['attributes3'] & SPELL_ATTR3_DONT_DISPLAY_RANGE) + return ''; + + // minRange exists; show as range + if ($this->curTpl['rangeMinHostile']) + return Lang::spell('range', [$this->curTpl['rangeMinHostile'].' - '.$this->curTpl['rangeMaxHostile']]); + // friend and hostile differ; do color + else if ($this->curTpl['rangeMaxHostile'] != $this->curTpl['rangeMaxFriend']) + return Lang::spell('range', [''.$this->curTpl['rangeMaxHostile'].' - '.$this->curTpl['rangeMaxFriend']. '']); + // hardcode: "melee range" + else if ($this->curTpl['rangeMaxHostile'] == 5) + return Lang::spell('meleeRange'); + // hardcode "unlimited range" + else if ($this->curTpl['rangeMaxHostile'] == 50000) + return Lang::spell('unlimRange'); + // regular case + else + return Lang::spell('range', [$this->curTpl['rangeMaxHostile']]); + } + + public function createPowerCostForCurrent() : string + { + $str = ''; + + $pt = $this->curTpl['powerType']; + $pc = $this->curTpl['powerCost']; + $pcp = $this->curTpl['powerCostPercent']; + $pps = $this->curTpl['powerPerSecond']; + $pcpl = $this->curTpl['powerCostPerLevel']; + + // some potion effects have this set, but it's not displayed by client (or enforced by core) + if ($pt == POWER_HAPPINESS) + return ''; + + if ($pt == POWER_RAGE || $pt == POWER_RUNIC_POWER) + $pc /= 10; + + if ($pt == POWER_RUNE && ($rCost = ($this->curTpl['powerCostRunes'] & 0x333))) + { // Blood 2|1 - Unholy 2|1 - Frost 2|1 + $runes = []; + + for ($i = 0; $i < 3; $i++) + { + if ($rCost & 0x3) + $runes[] = Lang::spell('powerCostRunes', $i, [$rCost & 0x3]); + + $rCost >>= 4; + } + + $str .= implode(' ', $runes); + } + else if ($pcp > 0) // power cost: pct over static + $str .= $pcp."% ".Lang::spell('pctCostOf', [mb_strtolower(Lang::spell('powerTypes', $pt))]); + else if ($pc > 0 || $pps > 0 || $pcpl > 0) + { + if ($this->curTpl['attributes0'] & SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION) + $str .= ''; + + if (Lang::exist('spell', 'powerCost', $pt)) + $str .= Lang::spell('powerCost', $pt, intVal($pps > 0), [$pc, $pps]); + else + $str .= Lang::spell('powerDisplayCost', intVal($pps > 0), [$pc, Lang::spell('powerTypes', $pt), $pps]); + } + + // append level cost (todo (low): work in as scaling cost) + if ($pcpl > 0) + $str .= Lang::spell('costPerLevel', [$pcpl]); + + return $str; + } + + public function createCastTimeForCurrent(bool $short = true, bool $noInstant = true) : string + { + if (!$this->curTpl['castTime'] && $this->isChanneledSpell()) + return Lang::spell('channeled'); + // SPELL_ATTR0_ABILITY instant ability.. yeah, wording thing only (todo (low): rule is imperfect) + else if (!$this->curTpl['castTime'] && ($this->curTpl['damageClass'] != SPELL_DAMAGE_CLASS_MAGIC || $this->curTpl['attributes0'] & SPELL_ATTR0_ABILITY)) + return Lang::spell('instantPhys'); + // show instant only for player/pet/npc abilities (todo (low): unsure when really hidden (like talent-case)) + else if ($noInstant && !in_array($this->curTpl['typeCat'], [11, 7, -3, -6, -8, 0]) && !($this->curTpl['cuFlags'] & SPELL_CU_TALENTSPELL)) + return ''; + else + return $short ? Lang::formatTime($this->curTpl['castTime'] * 1000, 'spell', 'castTime') : DateTime::formatTimeElapsedFloat($this->curTpl['castTime'] * 1000); + } + + private function createCooldownForCurrent() : string + { + if ($this->curTpl['attributes6'] & SPELL_ATTR6_DONT_DISPLAY_COOLDOWN) + return ''; + else if ($this->curTpl['recoveryTime']) + return Lang::formatTime($this->curTpl['recoveryTime'], 'spell', 'cooldown'); + else if ($this->curTpl['recoveryCategory']) + return Lang::formatTime($this->curTpl['recoveryCategory'], 'spell', 'cooldown'); + + return ''; + } + + // formulae base from TC + private function calculateAmountForCurrent(int $effIdx, int $nTicks = 1) : array + { + $level = $this->charLevel; + $maxBase = 0; + $rppl = $this->getField('effect'.$effIdx.'RealPointsPerLevel'); + $base = $this->getField('effect'.$effIdx.'BasePoints'); + $add = $this->getField('effect'.$effIdx.'DieSides'); + $maxLvl = $this->getField('maxLevel'); + $baseLvl = $this->getField('baseLevel'); + $spellLvl = $this->getField('spellLevel'); + $LDSEffs = $this->canLevelDamageScale(); + $modMin = + $modMax = null; + + if ($rppl) + { + if ($level > $maxLvl && $maxLvl > 0) + $level = $maxLvl; + else if ($level < $baseLvl) + $level = $baseLvl; + + if (!$this->getField('attributes0') & SPELL_ATTR0_PASSIVE) + $level -= $spellLvl; + + $maxBase += (int)(($level - $baseLvl) * $rppl); + $maxBase *= $nTicks; + + } + + $min = $nTicks * ($add ? $base + 1 : $base); + $max = $nTicks * ($add + $base); + + if ($rppl) + { + $modMin = ''; + $modMax = ''; + } + else if ($this->getField('attributes0') & SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION && in_array($effIdx, $LDSEffs) && $spellLvl) + { + $modMin = ''; + $modMax = ''; + } + + return [$min + $maxBase, $max + $maxBase, $modMin, $modMax]; + } + + public function canCreateItem() : array + { + $idx = []; + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_ITEM_CREATE) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_ITEM_CREATE)) + if ($this->curTpl['effect'.$i.'CreateItemId'] > 0) + $idx[] = $i; + + return $idx; + } + + public function canTriggerSpell() : array + { + $idx = []; + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_TRIGGER) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_TRIGGER)) + if ($this->curTpl['effect'.$i.'AuraId'] == SPELL_AURA_DUMMY || $this->curTpl['effect'.$i.'TriggerSpell'] > 0 || ($this->curTpl['effect'.$i.'Id'] == SPELL_EFFECT_TITAN_GRIP && $this->curTpl['effect'.$i.'MiscValue'] > 0)) + $idx[] = $i; + + return $idx; + } + + public function canTeachSpell() : array + { + $idx = []; + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_TEACH)) + if ($this->curTpl['effect'.$i.'TriggerSpell'] > 0) + $idx[] = $i; + + return $idx; + } + + public function canEnchantmentItem() : array + { + $idx = []; + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_ENCHANTMENT)) + $idx[] = $i; + + return $idx; + } + + public function canLevelDamageScale() : array + { + $idx = []; + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_LDC_SCALING) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_LDC_SCALING)) + $idx[] = $i; + + return $idx; + } + + public function isChanneledSpell() : bool + { + return $this->curTpl['attributes1'] & (SPELL_ATTR1_CHANNELED_1 | SPELL_ATTR1_CHANNELED_2); + } + + public function isScalableHealingSpell() : bool + { + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_SCALING_HEAL) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_SCALING_HEAL)) + return true; + + return false; + } + + public function isScalableDamagingSpell() : bool + { + for ($i = 1; $i < 4; $i++) + if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::EFFECTS_SCALING_DAMAGE) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_SCALING_DAMAGE)) + return true; + + return false; + } + + public function periodicEffectsMask() : int + { + $effMask = 0x0; + + for ($i = 1; $i < 4; $i++) + if ($this->curTpl['effect'.$i.'Periode'] > 0) + $effMask |= 1 << ($i - 1); + + return $effMask; + } + + private function dfnText(string $tooltip, string $text) : string + { + if ($this->interactive < self::INTERACTIVE_FULL) + return $text; + + return sprintf(Util::$dfnString, $tooltip, $text); + } + + // description-, buff-parsing component + private function resolveEvaluation(string $formula) : string + { + // see Traits in javascript locales + + $PlayerName = Lang::main('name'); + $pl = $PL = /* playerLevel set manually ? $this->charLevel : */ $this->dfnText('LANG.level', Lang::game('level')); + $ap = $AP = $this->dfnText('LANG.traits.atkpwr[0]', Lang::spell('traitShort', 'atkpwr')); + $rap = $RAP = $this->dfnText('LANG.traits.rgdatkpwr[0]', Lang::spell('traitShort', 'rgdatkpwr')); + $sp = $SP = $this->dfnText('LANG.traits.splpwr[0]', Lang::spell('traitShort', 'splpwr')); + $spa = $SPA = $this->dfnText('LANG.traits.arcsplpwr[0]', Lang::spell('traitShort', 'arcsplpwr')); + $spfi = $SPFI = $this->dfnText('LANG.traits.firsplpwr[0]', Lang::spell('traitShort', 'firsplpwr')); + $spfr = $SPFR = $this->dfnText('LANG.traits.frosplpwr[0]', Lang::spell('traitShort', 'frosplpwr')); + $sph = $SPH = $this->dfnText('LANG.traits.holsplpwr[0]', Lang::spell('traitShort', 'holsplpwr')); + $spn = $SPN = $this->dfnText('LANG.traits.natsplpwr[0]', Lang::spell('traitShort', 'natsplpwr')); + $sps = $SPS = $this->dfnText('LANG.traits.shasplpwr[0]', Lang::spell('traitShort', 'shasplpwr')); + $bh = $BH = $this->dfnText('LANG.traits.splheal[0]', Lang::spell('traitShort', 'splheal')); + $spi = $SPI = $this->dfnText('LANG.traits.spi[0]', Lang::spell('traitShort', 'spi')); + $sta = $STA = $this->dfnText('LANG.traits.sta[0]', Lang::spell('traitShort', 'sta')); + $str = $STR = $this->dfnText('LANG.traits.str[0]', Lang::spell('traitShort', 'str')); + $agi = $AGI = $this->dfnText('LANG.traits.agi[0]', Lang::spell('traitShort', 'agi')); + $int = $INT = $this->dfnText('LANG.traits.int[0]', Lang::spell('traitShort', 'int')); + + // only 'ron test spell', guess its %-dmg mod; no idea what bc2 might be + $pa = '<$PctArcane>'; // %arcane + $pfi = '<$PctFire>'; // %fire + $pfr = '<$PctFrost>'; // %frost + $ph = '<$PctHoly>'; // %holy + $pn = '<$PctNature>'; // %nature + $ps = '<$PctShadow>'; // %shadow + $pbh = '<$PctHeal>'; // %heal + $pbhd = '<$PctHealDone>'; // %heal done + $bc2 = '<$bc2>'; // bc2 + + $HND = $hnd = $this->dfnText('[Hands required by weapon]', 'HND'); // todo (med): localize this one + $MWS = $mws = $this->dfnText('LANG.traits.mlespeed[0]', 'MWS'); + $mw = $this->dfnText('LANG.traits.dmgmin1[0]', 'mw'); + $MW = $this->dfnText('LANG.traits.dmgmax1[0]', 'MW'); + $mwb = $this->dfnText('LANG.traits.mledmgmin[0]', 'mwb'); + $MWB = $this->dfnText('LANG.traits.mledmgmax[0]', 'MWB'); + $rwb = $this->dfnText('LANG.traits.rgddmgmin[0]', 'rwb'); + $RWB = $this->dfnText('LANG.traits.rgddmgmax[0]', 'RWB'); + + $cond = $COND = fn($a, $b, $c) => $a ? $b : $c; + $eq = $EQ = fn($a, $b) => $a == $b; + $gt = $GT = fn($a, $b) => $a > $b; + $gte = $GTE = fn($a, $b) => $a >= $b; + $floor = $FLOOR = fn($a) => floor($a); + $max = $MAX = fn($a, $b) => max($a, $b); + $min = $MIN = fn($a, $b) => min($a, $b); + + if (preg_match_all('/\$\w+\b/i', $formula, $vars)) + { + + $evalable = true; + + foreach ($vars[0] as $var) // oh lord, forgive me this sin .. but is_callable seems to bug out and function_exists doesn't find lambda-functions >.< + { + $var = substr($var, 1); + + if (isset($$var)) + { + $eval = eval('return @$'.$var.';'); // attention: error suppression active here (will be logged anyway) + if (getType($eval) == 'object') + continue; + else if (is_numeric($eval)) + continue; + } + else + $$var = ''; + + $evalable = false; + break; + } + + if (!$evalable) + { + // can't eval constructs because of strings present. replace constructs with strings + $cond = $COND = $this->dfnText('COND(a, b, c)
a ? b : c', 'COND'); + $eq = $EQ = $this->dfnText('EQ(a, b)
a == b', 'EQ'); + $gt = $GT = $this->dfnText('GT(a, b)
a > b', 'GT'); + $gte = $GTE = $this->dfnText('GTE(a, b)
a >= b', 'GTE'); + $floor = $FLOOR = $this->dfnText('FLOOR(a)', 'FLOOR'); + $min = $MIN = $this->dfnText('MIN(a, b)', 'MIN'); + $max = $MAX = $this->dfnText('MAX(a, b)', 'MAX'); + $pl = $PL = $this->dfnText('LANG.level', 'PL'); + + // space out operators for better readability + $formula = preg_replace('/(\+|-|\*|\/)/', ' \1 ', $formula); + + // note the " ! + return eval('return "('.$formula.')";'); + } + else + return eval('return '.$formula.';'); + } + + // since this function may be called recursively, there are cases, where the already evaluated string is tried to be evaled again, throwing parse errors + // todo (med): also quit, if we replaced vars with non-interactive text + if (strstr($formula, '') || strstr($formula, '%s (%s)'; + $statId = $stats[0]; // could be multiple ratings in theory, but not expected to be + } + /* + todo: export to and solve formulas in javascript e.g.: spell 10187 - ${$42213m1*8*$} with $mult = ${${$?s31678[${1.05}][${${$?s31677[${1.04}][${${$?s31676[${1.03}][${${$?s31675[${1.02}][${${$?s31674[${1.01}][${1}]}}]}}]}}]}}]}*${$?s12953[${1.06}][${${$?s12952[${1.04}][${${$?s11151[${1.02}][${1}]}}]}}]}} + else if ($this->interactive == self::INTERACTIVE_FULL && ($modStrMin || $modStrMax)) + { + $this->scaling[$this->id] = true; + $fmtStringMin = $modStrMin.'%s'; + } + */ + $minPoints = ctype_lower($var) ? $min : $max; + break; + case 'n': // ProcCharges + case 'N': + $base = $srcSpell->getField('procCharges'); + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 'o': // TotalAmount for periodic auras (with variance) + case 'O': + $periode = $srcSpell->getField('effect'.$effIdx.'Periode'); + $duration = $srcSpell->getField('duration'); + + if (!$periode) + { + // Mod Power Regeneration & Mod Health Regeneration have an implicit periode of 5sec + $aura = $srcSpell->getField('effect'.$effIdx.'AuraId'); + if ($aura == SPELL_AURA_MOD_REGEN || $aura == SPELL_AURA_MOD_POWER_REGEN) + $periode = 5000; + else + $periode = 3000; + } + + [$min, $max, $modStrMin, $modStrMax] = $srcSpell->calculateAmountForCurrent($effIdx, intVal($duration / $periode)); + + if (in_array($op, $signs) && is_numeric($oparg)) + { + eval("\$min = $min $op $oparg;"); + eval("\$max = $max $op $oparg;"); + } + + if ($this->interactive >= self::INTERACTIVE_EMBEDDED && ($modStrMin || $modStrMax)) + { + $this->scaling[$this->id] = true; + + $fmtStringMin = $modStrMin.'%s'; + $fmtStringMax = $modStrMax.'%s'; + } + + $minPoints = $min; + $maxPoints = $max; + break; + case 'q': // EffectMiscValue + case 'Q': + $base = $srcSpell->getField('effect'.$effIdx.'MiscValue'); + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 'r': // SpellRange + case 'R': + $base = $srcSpell->getField('rangeMaxHostile'); + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 's': // BasePoints (with variance) + case 'S': + [$min, $max, $modStrMin, $modStrMax] = $srcSpell->calculateAmountForCurrent($effIdx); + $mv = $srcSpell->getField('effect'.$effIdx.'MiscValue'); + $aura = $srcSpell->getField('effect'.$effIdx.'AuraId'); + + if (in_array($op, $signs) && is_numeric($oparg)) + { + eval("\$min = $min $op $oparg;"); + eval("\$max = $max $op $oparg;"); + } + // Aura giving combat ratings + $stats = []; + if ($aura == SPELL_AURA_MOD_RATING) + if ($stats = StatsContainer::convertCombatRating($mv)) + $this->scaling[$this->id] = true; + // Aura end + + if ($stats && $this->interactive >= self::INTERACTIVE_EMBEDDED) + { + $fmtStringMin = '%s (%s)'; + $statId = $stats[0]; // could be multiple ratings in theory, but not expected to be + } + else if (($modStrMin || $modStrMax) && $this->interactive == self::INTERACTIVE_FULL) + { + $this->scaling[$this->id] = true; + $fmtStringMin = $modStrMin.'%s'; + $fmtStringMax = $modStrMax.'%s'; + } + + $minPoints = $min; + $maxPoints = $max; + break; + case 't': // Periode + case 'T': + $base = $srcSpell->getField('effect'.$effIdx.'Periode') / 1000; + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 'u': // StackCount + case 'U': + $base = $srcSpell->getField('stackAmount'); + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 'v': // MaxTargetLevel + case 'V': + $base = $srcSpell->getField('MaxTargetLevel'); + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 'x': // ChainTargetCount + case 'X': + $base = $srcSpell->getField('effect'.$effIdx.'ChainTarget'); + + if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) + eval("\$base = $base $op $oparg;"); + + $minPoints = $base; + break; + case 'z': // HomeZone + $fmtStringMin = Lang::spell('home'); + break; + } + + // handle excessively precise floats + if (is_float($minPoints)) + $minPoints = round($minPoints, 2); + if (isset($maxPoints) && is_float($maxPoints)) + $maxPoints = round($maxPoints, 2); + + return [$minPoints, $maxPoints, $fmtStringMin, $fmtStringMax, $statId]; + } + + // description-, buff-parsing component + private function resolveFormulaString(string $formula, int $precision = 0) : array + { + $fSuffix = '%s'; + $fStat = 0; + + // step 1: formula unpacking redux + while (($formStartPos = strpos($formula, '${')) !== false) + { + $formBrktCnt = 0; + $formPrecision = 0; + $formCurPos = $formStartPos; + + $formOutStr = ''; + + while ($formCurPos < strlen($formula)) + { + $char = $formula[$formCurPos]; + + if ($char == '}') + $formBrktCnt--; + + if ($formBrktCnt) + $formOutStr .= $char; + + if ($char == '{') + $formBrktCnt++; + + if (!$formBrktCnt && $formCurPos != $formStartPos) + break; + + $formCurPos++; + } + + if (isset($formula[++$formCurPos]) && $formula[$formCurPos] == '.') + { + $formPrecision = (int)$formula[++$formCurPos]; + ++$formCurPos; // for some odd reason the precision decimal survives if we dont increment further.. + } + + [$formOutStr, $fSuffix, $fStat] = $this->resolveFormulaString($formOutStr, $formPrecision); + + $formula = substr_replace($formula, $formOutStr, $formStartPos, ($formCurPos - $formStartPos)); + } + + // note: broken tooltip on this one + // ${58644m1/-10} gets matched as a formula (ok), 58644m1 has no $ prefixed (not ok) + // the client scraps the m1 and prints -5864 + if ($this->id == 58644) + $formula = '$'.$formula; + + // step 2: resolve variables + $pos = 0; // continue strpos-search from this offset + $str = ''; + while (($npos = strpos($formula, '$', $pos)) !== false) + { + if ($npos != $pos) + $str .= substr($formula, $pos, $npos - $pos); + + $pos = $npos++; + + if ($formula[$pos] == '$') + $pos++; + + $varParts = $this->matchVariableString(substr($formula, $pos), $len); + if (!$varParts) + { + $str .= '#'; // mark as done, reset below + continue; + } + + $pos += $len; + + // we are resolving a formula -> omit ranges + [$minPoints, , $fmtStringMin, , $statId] = $this->resolveVariableString($varParts); + + // time within formula -> rebase to seconds and omit timeUnit + if (strtolower($varParts['var']) == 'd') + { + $minPoints /= 1000; + unset($fmtStringMin); + } + + $str .= $minPoints; + + // overwrite eventually inherited strings + if (isset($fmtStringMin)) + $fSuffix = $fmtStringMin; + + // overwrite eventually inherited stat + if (isset($statId)) + $fStat = $statId; + } + $str .= substr($formula, $pos); + $str = str_replace('#', '$', $str); // reset markers + + // step 3: try to evaluate result + $evaled = $this->resolveEvaluation($str); + + $return = is_numeric($evaled) ? round($evaled, $precision) : $evaled; + + return [$return, $fSuffix, $fStat]; + } + + // should probably used only once to create ::spell. come to think of it, it yields the same results every time.. it absolutely has to! + // although it seems to be pretty fast, even on those pesky test-spells with extra complex tooltips (Ron Test Spell X)) + public function parseText(string $type = 'description', int $level = MAX_LEVEL) : array + { + // oooo..kaaayy.. parsing text in 6 or 7 easy steps + // we don't use the internal iterator here. This func has to be called for the individual template. + // otherwise it will get a bit messy, when we iterate, while we iterate *yo dawg!* + + /* + documentation .. sort of + bracket use + ${}.x - formulas; .x is optional; x:[0-9] .. max-precision of a floatpoint-result; default: 0 + $[] - conditionals ... like $?condition[true][false]; alternative $?!(cond1|cond2)[true]$?cond3[elseTrue][false]; ?a40120: has aura 40120; ?s40120: knows spell 40120(%s) + $<> - variables + () - regular use for function-like calls + + variables in use .. caseSensitive + + game variables (optionally replace with textVars) + $PlayerName - Cpt. Obvious + $PL / $pl - PlayerLevel + $STR - Strength Attribute (not seen) + $AGI - Agility Attribute (not seen) + $STA - Stamina Attribute (not seen) + $INT - Intellect Attribute (not seen) + $SPI - Spirit Attribute + $AP - Atkpwr + $RAP - RngAtkPwr + $HND - hands used by weapon (1H, 2H) => (1, 2) + $MWS - MainhandWeaponSpeed + $mw / $MW - MainhandWeaponDamage Min/Max + $rwb / $RWB - RangedWeapon..Bonus? Min/Max + $sp - Spellpower + $spa - Spellpower Arcane + $spfi - Spellpower Fire + $spfr - Spellpower Frost + $sph - Spellpower Holy + $spn - Spellpower Nature + $sps - Spellpower Shadow + $bh - Bonus Healing + $pa - %-ArcaneDmg (as float) // V seems broken + $pfi - %-FireDmg (as float) + $pfr - %-FrostDmg (as float) + $ph - %-HolyDmg (as float) + $pn - %-NatureDmg (as float) + $ps - %-ShadowDmg (as float) + $pbh - %-HealingBonus (as float) + $pbhd - %-Healing Done (as float) // all above seem broken + $bc2 - baseCritChance? always 3.25 (unsure) + + spell variables (the stuff we can actually parse) rounding... >5 up? + $a - SpellRadius; per EffectIdx + $b - PointsPerComboPoint; per EffectIdx + $d / $D - SpellDuration; appended timeShorthand; d/D maybe base/max duration?; interpret "0" as "until canceled" + $e - EffectValueMultiplier; per EffectIdx + $f / $F - EffectDamageMultiplier; per EffectIdx + $g / $G - Gender-Switch $Gmale:female; + $h / $H - ProcChance + $i - MaxAffectedTargets + $l - LastValue-Switch; last value as condition $Ltrue:false; + $m / $M - BasePoints; per EffectIdx; m/M +1/+effectDieSides + $n - ProcCharges + $o - TotalAmount (for periodic auras); per EffectIdx + $q - EffectMiscValue; per EffectIdx + $r - SpellRange (hostile) + $s / $S - BasePoints; per EffectIdx; as Range, if applicable + $t / $T - EffectPeriode; per EffectIdx + $u - StackAmount + $v - MaxTargetLevel + $x - MaxAffectedTargets + $z - no place like + + deviations from standard procedures + division - example: $/10;2687s1 => $2687s1/10 + - also: $61829/5;s1 => $61829s1/5 + + functions in use .. caseInsensitive + $cond(a, b, c) - like SQL, if A is met use B otherwise use C + $eq(a, b) - a == b + $floor(a) - floor() + $gt(a, b) - a > b + $gte(a, b) - a >= b + $min(a, b) - min() + $max(a, b) - max() + */ + + $this->charLevel = $level; + + // step -1: already handled? + if (isset($this->parsedText[$this->id][$type][Lang::getLocale()->value][$this->charLevel][$this->interactive])) + return $this->parsedText[$this->id][$type][Lang::getLocale()->value][$this->charLevel][$this->interactive]; + + // step 0: get text + $data = $this->getField($type, true); + if (empty($data) || $data == "[]") // empty tooltip shouldn't be displayed anyway + return ['', [], false]; + + // step 1: if the text is supplemented with text-variables, get and replace them + if ($this->curTpl['spellDescriptionVariableId'] > 0) + { + if (empty($this->spellVars[$this->id])) + { + $spellVars = DB::Aowow()->SelectCell('SELECT `vars` FROM ::spellvariables WHERE `id` = %i', $this->curTpl['spellDescriptionVariableId']); + $spellVars = explode("\n", $spellVars); + foreach ($spellVars as $sv) + if (preg_match('/\$(\w*\d*)=(.*)/i', trim($sv), $matches)) + $this->spellVars[$this->id][$matches[1]] = $matches[2]; + } + + // replace self-references + $reset = true; + while ($reset) + { + $reset = false; + foreach ($this->spellVars[$this->id] as $k => $sv) + { + if (preg_match('/\$<(\w*\d*)>/i', $sv, $matches)) + { + $this->spellVars[$this->id][$k] = str_replace('$<'.$matches[1].'>', '${'.$this->spellVars[$this->id][$matches[1]].'}', $sv); + $reset = true; + } + } + } + + // finally, replace SpellDescVars + foreach ($this->spellVars[$this->id] as $k => $sv) + $data = str_replace('$<'.$k.'>', $sv, $data); + } + + // step 2: resolving conditions + // aura- or spell-conditions cant be resolved for our purposes, so force them to false for now (todo (low): strg+f "know" in aowowPower.js ^.^) + + /* sequences + a) simple - $?cond[A][B] // simple case of b) + b) elseif - $?cond[A]?cond[B]..[C] // can probably be repeated as often as you wanted + c) recursive - $?cond[A][$?cond[B][..]] // can probably be stacked as deep as you wanted + + only case a) can be used for KNOW-parameter + */ + + $relSpells = []; + $data = $this->handleConditions($data, $relSpells, true); + + // step 3: unpack formulas ${ .. }.X + $data = $this->handleFormulas($data, true); + + // step 4: find and eliminate regular variables + $data = $this->handleVariables($data, true); + + // step 5: variable-dependent variable-text + // special case $lONE:ELSE[:ELSE2]; or $|ONE:ELSE[:ELSE2]; + while (preg_match('/([\d\.]+)([^\d]*)(\$[l|]:*)([^:]*):([^;]*);/i', $data, $m)) + { + $plurals = explode(':', $m[5]); + $replace = ''; + + if (count($plurals) == 2) // special case: ruRU + { + switch (substr($m[1], -1)) // check last digit of number + { + case 1: + // but not 11 (teen number) + if (!in_array($m[1], [11])) + { + $replace = $m[4]; + break; + } + case 2: + case 3: + case 4: + // but not 12, 13, 14 (teen number) [11 is passthrough] + if (!in_array($m[1], [11, 12, 13, 14])) + { + $replace = $plurals[0]; + break; + } + break; + default: + $replace = $plurals[1]; + } + + } + else + $replace = ($m[1] == 1 ? $m[4] : $plurals[0]); + + $data = str_ireplace($m[1].$m[2].$m[3].$m[4].':'.$m[5].';', $m[1].$m[2].$replace, $data); + } + + // step 6: HTMLize + // colors + $data = preg_replace('/\|cff([a-f0-9]{6})(.+?)\|r/i', '$2', $data); + + // line endings + $data = strtr($data, ["\r" => '', "\n" => '
']); + + // cache result + $this->parsedText[$this->id][$type][Lang::getLocale()->value][$this->charLevel][$this->interactive] = [$data, $relSpells, $this->scaling[$this->id]]; + + return [$data, $relSpells, $this->scaling[$this->id]]; + } + + private function handleFormulas(string $data, bool $topLevel = false) : string + { + // they are stacked recursively but should be balanced .. hf + while (($formStartPos = strpos($data, '${')) !== false) + { + $formBrktCnt = 0; + $formPrecision = 0; + $formCurPos = $formStartPos; + + $formOutStr = ''; + + while ($formCurPos < strlen($data)) // only hard-exit condition, we'll hit those breaks eventually^^ + { + $char = $data[$formCurPos]; + + if ($char == '}') + $formBrktCnt--; + + if ($formBrktCnt) + $formOutStr .= $char; + + if ($char == '{') + $formBrktCnt++; + + if (!$formBrktCnt && $formCurPos != $formStartPos) + break; + + // advance position + $formCurPos++; + } + + $formCurPos++; + + // check for precision-modifiers + if ($formCurPos + 1 < strlen($data) && $data[$formCurPos] == '.' && is_numeric($data[$formCurPos + 1])) + { + $formPrecision = $data[$formCurPos + 1]; + $formCurPos += 2; + } + [$formOutVal, $formOutStr, $statId] = $this->resolveFormulaString($formOutStr, $formPrecision ?: ($topLevel ? 0 : 10)); + + if ($statId && Util::checkNumeric($formOutVal)) + $resolved = sprintf($formOutStr, $statId, abs($formOutVal), Util::setRatingLevel($this->charLevel, $statId, abs($formOutVal), $this->interactive == self::INTERACTIVE_FULL)); + else + $resolved = sprintf($formOutStr, Util::checkNumeric($formOutVal) ? abs($formOutVal) : $formOutVal); + + $data = substr_replace($data, $resolved, $formStartPos, ($formCurPos - $formStartPos)); + } + + return $data; + } + + private function handleVariables(string $data, bool $topLevel = false) : string + { + $pos = 0; // continue strpos-search from this offset + $str = ''; + while (($npos = strpos($data, '$', $pos)) !== false) + { + if ($npos != $pos) + $str .= substr($data, $pos, $npos - $pos); + + $pos = $npos++; + + if ($data[$pos] == '$') + $pos++; + + $varParts = $this->matchVariableString(substr($data, $pos), $len); + if (!$varParts) + { + $str .= '#'; // mark as done, reset below + continue; + } + + $pos += $len; + + [$minPoints, $maxPoints, $fmtStringMin, $fmtStringMax, $statId] = $this->resolveVariableString($varParts); + $resolved = is_numeric($minPoints) ? abs($minPoints) : $minPoints; + if (isset($fmtStringMin)) + { + if (isset($statId)) + $resolved = sprintf($fmtStringMin, $statId, abs($minPoints), Util::setRatingLevel($this->charLevel, $statId, abs($minPoints), $this->interactive == self::INTERACTIVE_FULL)); + else + $resolved = sprintf($fmtStringMin, $resolved); + } + + if (isset($maxPoints) && $minPoints != $maxPoints && !isset($statId)) + { + $_ = is_numeric($maxPoints) ? abs($maxPoints) : $maxPoints; + $resolved .= Lang::game('valueDelim'); + $resolved .= isset($fmtStringMax) ? sprintf($fmtStringMax, $_) : $_; + } + + $str .= $resolved; + } + $str .= substr($data, $pos); + $str = str_replace('#', '$', $str); // reset marker + + return $str; + } + + private function handleConditions(string $data, array &$relSpells, bool $topLevel = false) : string + { + while (($condStartPos = strpos($data, '$?')) !== false) + { + $condBrktCnt = 0; + $condCurPos = $condStartPos + 2; // after the '$?' + $targetPart = 3; // we usually want the second pair of brackets + $curPart = 0; // parts: $? 0 [ 1 ] 2 [ 3 ] 4 ... + $condParts = []; + $isLastElse = false; + + while ($condCurPos < strlen($data)) // only hard-exit condition, we'll hit those breaks eventually^^ + { + $char = $data[$condCurPos]; + + // advance position + $condCurPos++; + + if ($char == '[') + { + $condBrktCnt++; + + if ($condBrktCnt == 1) + $curPart++; + + // previously there was no condition -> last else + if ($condBrktCnt == 1) + if (($curPart && ($curPart % 2)) && (!isset($condParts[$curPart - 1]) || empty(trim($condParts[$curPart - 1])))) + $isLastElse = true; + + if (empty($condParts[$curPart])) + continue; + } + + if (empty($condParts[$curPart])) + $condParts[$curPart] = $char; + else + $condParts[$curPart] .= $char; + + if ($char == ']') + { + $condBrktCnt--; + + if (!$condBrktCnt) + { + $condParts[$curPart] = substr($condParts[$curPart], 0, -1); + $curPart++; + } + + if ($condBrktCnt) + continue; + + if ($isLastElse) + break; + } + } + + // this should be 0 if all went well + if ($condBrktCnt > 0) + { + trigger_error('SpellList::handleConditions() - string contains unbalanced condition', E_USER_WARNING); + $condParts[3] = $condParts[3] ?? ''; + } + + // check if it is know-compatible + $know = 0; + if (preg_match('/\(?(\!?)[as](\d+)\)?$/i', $condParts[0], $m)) + { + if (!strstr($condParts[1], '$?')) + if (!strstr($condParts[3], '$?')) + if (!isset($condParts[5])) + $know = $m[2]; + + // found a negation -> switching condition target + if ($m[1] == '!') + $targetPart = 1; + } + // if not, what part of the condition should be used? + else if (preg_match('/(([\W\D]*[as]\d+)|([^\[]*))/i', $condParts[0], $m) && !empty($m[3])) + { + $cnd = $this->resolveEvaluation($m[3]); + if ((is_numeric($cnd) || is_bool($cnd)) && $cnd) // only case, deviating from normal; positive result -> use [true] + $targetPart = 1; + } + + // recursive conditions + if (strstr($condParts[$targetPart], '$?')) + $condParts[$targetPart] = $this->handleConditions($condParts[$targetPart], $relSpells); + + if ($know && $topLevel) + { + foreach ([1, 3] as $pos) + { + if (strstr($condParts[$pos], '${')) + $condParts[$pos] = $this->handleFormulas($condParts[$pos]); + + if (strstr($condParts[$pos], '$')) + $condParts[$pos] = $this->handleVariables($condParts[$pos]); + } + + // false condition first + if (!isset($relSpells[$know])) + $relSpells[$know] = []; + + $relSpells[$know][] = [$condParts[3], $condParts[1]]; + + $data = substr_replace($data, ''.$condParts[$targetPart].'', $condStartPos, ($condCurPos - $condStartPos)); + } + else + $data = substr_replace($data, $condParts[$targetPart], $condStartPos, ($condCurPos - $condStartPos)); + } + + return $data; + } + + public function renderBuff(int $level = MAX_LEVEL, int $interactive = self::INTERACTIVE_EMBEDDED, ?array &$buffSpells = []) : ?string + { + $buffSpells = []; + + if (!$this->curTpl) + return null; + + // doesn't have a buff + if (!$this->getField('buff', true)) + return null; + + $this->interactive = $interactive; + $this->charLevel = $level; + + $x = ''; + + // spellName + $x .= ''; + + // dispelType (if applicable) + if ($this->curTpl['dispelType']) + if ($dispel = Lang::game('dt', $this->curTpl['dispelType'])) + $x .= ''; + + $x .= '
'.$this->getField('name', true).''.$dispel.'
'; + + $x .= '
'; + + // parse Buff-Text + [$buffTT, $buffSp, ] = $this->parseText('buff'); + + $buffSpells = Util::parseHtmlText($buffSp); + + $x .= $buffTT.'
'; + + // duration + if ($this->curTpl['duration'] > 0 && !($this->curTpl['attributes5'] & SPELL_ATTR5_HIDE_DURATION)) + $x .= ''.Lang::formatTime($this->curTpl['duration'], 'spell', 'timeRemaining').''; + + $x .= '
'; + + $min = $this->scaling[$this->id] ? ($this->getField('baseLevel') ?: 1) : 1; + $max = $this->scaling[$this->id] ? MAX_LEVEL : 1; + // scaling information - spellId:min:max:curr + $x .= ''; + + return $x; + } + + public function renderTooltip(int $level = MAX_LEVEL, int $interactive = self::INTERACTIVE_EMBEDDED, ?array &$ttSpells = []) : ?string + { + $ttSpells = []; + + if (!$this->curTpl) + return null; + + $this->interactive = $interactive; + $this->charLevel = $level; + + // fetch needed texts + $name = $this->getField('name', true); + $rank = $this->getField('rank', true); + $tools = $this->getToolsForCurrent(); + $cool = $this->createCooldownForCurrent(); + $cast = $this->createCastTimeForCurrent(); + $cost = $this->createPowerCostForCurrent(); + $range = $this->createRangesForCurrent(); + + [$desc, $spells, ] = $this->parseText('description'); + + $ttSpells = Util::parseHtmlText($spells); + + // get reagents + $reagents = $this->getReagentsForCurrent(); + foreach ($reagents as $k => $r) + { + if ($item = $this->relItems->getEntry($r[0])) + $reagents[$k] += [2 => new LocString($item), 3 => true]; + else + $reagents[$k] += [2 => 'Item #'.$r[0], 3 => false]; + } + + $reagents = array_reverse($reagents); + + // get stances + $stances = ''; + if ($this->curTpl['stanceMask'] && !($this->curTpl['attributes2'] & SPELL_ATTR2_NOT_NEED_SHAPESHIFT)) + $stances = Lang::game('requires2').' '.Lang::getStances($this->curTpl['stanceMask']); + + // get item requirement (skip for professions) + $reqItems = ''; + if ($this->curTpl['typeCat'] != 11) + { + $class = $this->getField('equippedItemClass'); + $mask = $this->getField('equippedItemSubClassMask'); + $reqItems = Lang::getRequiredItems($class, $mask); + } + + // get created items (may need improvement) + $createItem = ''; + if (in_array($this->curTpl['typeCat'], [9, 11])) // only Professions + { + foreach ($this->canCreateItem() as $idx) + { + // has createItem Scroll of Enchantment + if ($this->curTpl['effect'.$idx.'Id'] == SPELL_EFFECT_ENCHANT_ITEM) + continue; + + foreach ($this->relItems->iterate() as $cId => $__) + { + if ($cId != $this->curTpl['effect'.$idx.'CreateItemId']) + continue; + + $createItem = $this->relItems->renderTooltip(true, $this->id); + break 2; + } + } + } + + $x = ''; + $x .= '
'; + + // name & rank + if ($rank) + $x .= '
'.$name.''.$rank.'
'; + else + $x .= ''.$name.'
'; + + // powerCost & ranges + if ($range && $cost) + $x .= '
'.$cost.''.$range.'
'; + else if ($cost || $range) + $x .= $range.$cost.'
'; + + // castTime & cooldown + if ($cast && $cool) // tabled layout + { + $x .= ''; + $x .= ''; + if ($stances) + $x.= ''; + + $x .= '
'.$cast.''.$cool.'
'.$stances.'
'; + } + else if ($cast || $cool) // line-break layout + { + $x .= $cast.$cool; + + if ($stances) + $x .= '
'.$stances; + } + + $x .= '
'; + + $xTmp = []; + + if ($tools) + { + $_ = Lang::spell('tools').':
'; + while ($tool = array_pop($tools)) + { + if (isset($tool['itemId'])) + $_ .= ''.$tool['name'].''; + else if (isset($tool['id'])) + $_ .= ''.$tool['name'].''; + else + $_ .= $tool['name']; + + if (!empty($tools)) + $_ .= ', '; + else + $_ .= '
'; + } + + $xTmp[] = $_.'
'; + } + + if ($reagents) + { + $_ = Lang::spell('reagents').':
'; + while ([$iId, $qty, $text, $exists] = array_pop($reagents)) + { + $_ .= $exists ? ''.$text.'' : $text; + if ($qty > 1) + $_ .= ' ('.$qty.')'; + + $_ .= empty($reagents) ? '
' : ', '; + } + + $xTmp[] = $_.'
'; + } + + if ($reqItems) + $xTmp[] = Lang::game('requires2').' '.$reqItems; + + if ($desc) + $xTmp[] = ''.$desc.''; + + if ($createItem) + $xTmp[] = $createItem; + + if ($xTmp) + $x .= '
'.implode('
', $xTmp).'
'; + + // scaling information - spellId:min:max:curr[:scalingDistribution:ScalingFlags] + $scalingInfo = array( + $this->id, + $this->scaling[$this->id] ? ($this->getField('baseLevel') ?: 1) : 1, + $this->scaling[$this->id] ? ($this->getField('maxLevel') ?: MAX_LEVEL) : 1 + ); + + if ($this->getField('attributes0') & SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION) + { + $scalingInfo[] = $this->getField('spellLevel') ?: 1; + $scalingInfo[] = 1; // in 4.x+ proper scaling information; for us just to flag a npc spell as level damage scaling + $scalingInfo[] = 1; + } + else + $scalingInfo[] = min($this->charLevel, $scalingInfo[2]); + + $x .= ''; + + return $x; + } + + public function getTalentHeadForCurrent() : string + { + // power cost: pct over static + $cost = $this->createPowerCostForCurrent(); + + // ranges + $range = $this->createRangesForCurrent(); + + // cast times + $cast = $this->createCastTimeForCurrent(); + + // cooldown or categorycooldown + $cool = $this->createCooldownForCurrent(); + + // assemble parts + // upper: cost :: range + // lower: time :: cooldown + $x = ''; + + // upper + if ($cost && $range) + $x .= '
'.$cost.''.$range.'
'; + else + $x .= $cost.$range; + + if (($cost xor $range) && ($cast xor $cool)) + $x .= '
'; + + // lower + if ($cast && $cool) + $x .= '
'.$cast.''.$cool.'
'; + else + $x .= $cast.$cool; + + return $x; + } + + public function getColorsForCurrent() : array + { + $gry = $this->curTpl['skillLevelGrey']; + $ylw = $this->curTpl['skillLevelYellow']; + $grn = (int)(($ylw + $gry) / 2); + $org = $this->curTpl['learnedAt']; + + if ($ylw < $org) + $ylw = 0; + + if ($grn < $org || $grn < $ylw) + $grn = 0; + + if ($org >= $ylw || $org >= $grn || $org >= $gry) + $org = 0; + + return $gry > 1 ? [$org, $ylw, $grn, $gry] : []; + } + + public function getListviewData(int $addInfoMask = 0x0) : array + { + $data = []; + + if ($addInfoMask & ITEMINFO_MODEL) + $modelInfo = $this->getModelInfo(); + + foreach ($this->iterate() as $__) + { + $quality = ($this->curTpl['cuFlags'] & SPELL_CU_QUALITY_MASK) >> 8; + $talent = $this->curTpl['cuFlags'] & (SPELL_CU_TALENT | SPELL_CU_TALENTSPELL) && $this->curTpl['spellLevel'] <= 1; + + $data[$this->id] = array( + 'id' => $this->id, + 'name' => ($quality ?: '@').$this->getField('name', true), + 'icon' => $this->curTpl['iconStringAlt'] ?: $this->curTpl['iconString'], + 'level' => $talent ? $this->curTpl['talentLevel'] : $this->curTpl['spellLevel'], + 'school' => $this->curTpl['schoolMask'], + 'cat' => $this->curTpl['typeCat'], + 'trainingcost' => $this->curTpl['trainingCost'], + 'skill' => count($this->curTpl['skillLines']) > 4 ? array_merge(array_splice($this->curTpl['skillLines'], 0, 4), [-1]): $this->curTpl['skillLines'], // display max 4 skillLines (fills max three lines in listview) + 'reagents' => array_values($this->getReagentsForCurrent()), + 'source' => [] + // 'talentspec' => $this->curTpl['skillLines'][0] not used: g_chr_specs has the wrong structure for it; also setting .cat and .type does the same + ); + + // Sources + if ($this->getSources($s, $sm)) + { + $data[$this->id]['source'] = $s; + if ($sm) + $data[$this->id]['sourcemore'] = $sm; + } + + // Proficiencies + if ($this->curTpl['typeCat'] == -11) + foreach (self::$spellTypes as $cat => $type) + if (in_array($this->curTpl['skillLines'][0], self::$skillLines[$cat])) + $data[$this->id]['type'] = $type; + + // creates item + foreach ($this->canCreateItem() as $idx) + { + $max = $this->curTpl['effect'.$idx.'DieSides'] + $this->curTpl['effect'.$idx.'BasePoints']; + $min = $this->curTpl['effect'.$idx.'DieSides'] > 1 ? 1 : $max; + + $data[$this->id]['creates'] = [$this->curTpl['effect'.$idx.'CreateItemId'], $min, $max]; + break; + } + + // Profession + if (in_array($this->curTpl['typeCat'], [9, 11])) + { + if ($la = $this->curTpl['learnedAt']) + $data[$this->id]['learnedat'] = $la; + else if (($la = $this->curTpl['reqSkillLevel']) > 1) + $data[$this->id]['learnedat'] = $la; + + $data[$this->id]['colors'] = $this->getColorsForCurrent(); + } + + // glyph + if ($this->curTpl['typeCat'] == -13) + $data[$this->id]['glyphtype'] = $this->curTpl['cuFlags'] & SPELL_CU_GLYPH_MAJOR ? 1 : 2; + + if ($r = $this->getField('rank', true)) + $data[$this->id]['rank'] = $r; + + if ($mask = $this->curTpl['reqClassMask']) + $data[$this->id]['reqclass'] = $mask; + + if ($mask = $this->curTpl['reqRaceMask']) + $data[$this->id]['reqrace'] = $mask; + + if ($addInfoMask & ITEMINFO_MODEL) + { + // may have multiple models set, in this case i've no idea what should be picked + for ($i = 1; $i < 4; $i++) + { + if (!empty($modelInfo[$this->id][$i])) + { + $data[$this->id]['npcId'] = $modelInfo[$this->id][$i]['typeId']; + $data[$this->id]['displayId'] = $modelInfo[$this->id][$i]['displayId']; + $data[$this->id]['displayName'] = $modelInfo[$this->id][$i]['displayName']; + break; + } + } + } + } + + return $data; + } + + public function getJSGlobals(int $addMask = GLOBALINFO_SELF, ?array &$extra = []) : array + { + $data = []; + + if ($this->relItems && ($addMask & GLOBALINFO_RELATED)) + $data = $this->relItems->getJSGlobals(); + + foreach ($this->iterate() as $id => $__) + { + if ($addMask & GLOBALINFO_RELATED) + { + foreach (ChrClass::fromMask($this->curTpl['reqClassMask']) as $cId) + $data[Type::CHR_CLASS][$cId] = $cId; + + foreach (ChrRace::fromMask($this->curTpl['reqRaceMask']) as $rId) + $data[Type::CHR_RACE][$rId] = $rId; + + // play sound effect + for ($i = 1; $i < 4; $i++) + if ($this->getField('effect'.$i.'Id') == SPELL_EFFECT_PLAY_SOUND || $this->getField('effect'.$i.'Id') == SPELL_EFFECT_PLAY_MUSIC) + $data[Type::SOUND][$this->getField('effect'.$i.'MiscValue')] = $this->getField('effect'.$i.'MiscValue'); + } + + if ($addMask & GLOBALINFO_SELF) + { + $data[Type::SPELL][$id] = array( + 'icon' => $this->curTpl['iconStringAlt'] ?: $this->curTpl['iconString'], + 'name' => $this->getField('name', true) + ); + + if (($_ = $this->curTpl['typeCat']) && in_array($_, [-5, -6, 9, 11])) + $data[Type::SPELL][$id]['completion_category'] = $_; + } + + if ($addMask & GLOBALINFO_EXTRA) + { + $buff = $this->renderBuff(MAX_LEVEL, true, $buffSpells); + $tTip = $this->renderTooltip(MAX_LEVEL, true, $spells); + + foreach ($spells as $relId => $_) + if (empty($data[Type::SPELL][$relId])) + $data[Type::SPELL][$relId] = $relId; + + foreach ($buffSpells as $relId => $_) + if (empty($data[Type::SPELL][$relId])) + $data[Type::SPELL][$relId] = $relId; + + $extra[$id] = array( + // 'id' => $id, + 'tooltip' => $tTip, + 'buff' => $buff ?: null, + 'spells' => $spells, + 'buffspells' => $buffSpells ?: null + ); + } + } + + return $data; + } + + // mostly similar to TC + public function getCastingTimeForBonus(bool $asDOT = false) : int + { + $areaTargets = [7, 8, 15, 16, 20, 24, 30, 31, 33, 34, 37, 54, 56, 59, 104, 108]; + $castingTime = $this->IsChanneledSpell() ? $this->curTpl['duration'] : ($this->curTpl['castTime'] * 1000); + + if (!$castingTime) + return 3500; + + if ($castingTime > 7000) + $castingTime = 7000; + + if ($castingTime < 1500) + $castingTime = 1500; + + if ($asDOT && !$this->isChanneledSpell()) + $castingTime = 3500; + + $overTime = 0; + $nEffects = 0; + $isDirect = false; + $isArea = false; + + for ($i = 1; $i <= 3; $i++) + { + if (in_array($this->curTpl['effect'.$i.'Id'], SpellLIst::EFFECTS_DIRECT_SCALING)) + $isDirect = true; + else if (in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::AURAS_PERIODIC_SCALING)) + if ($_ = $this->curTpl['duration']) + $overTime = $_; + else if ($this->curTpl['effect'.$i.'AuraId']) + $nEffects++; + + if (in_array($this->curTpl['effect'.$i.'ImplicitTargetA'], $areaTargets) || in_array($this->curTpl['effect'.$i.'ImplicitTargetB'], $areaTargets)) + $isArea = true; + } + + // Combined Spells with Both Over Time and Direct Damage + if ($overTime > 0 && $castingTime > 0 && $isDirect) + { + // mainly for DoTs which are 3500 here otherwise + $originalCastTime = $this->curTpl['castTime'] * 1000; + if ($this->curTpl['attributes0'] & SPELL_ATTR0_REQ_AMMO) + $originalCastTime += 500; + + if ($originalCastTime > 7000) + $originalCastTime = 7000; + + if ($originalCastTime < 1500) + $originalCastTime = 1500; + + // Portion to Over Time + $PtOT = ($overTime / 15000) / (($overTime / 15000) + ($originalCastTime / 3500)); + + if ($asDOT) + $castingTime = $castingTime * $PtOT; + else if ($PtOT < 1) + $castingTime = $castingTime * (1 - $PtOT); + else + $castingTime = 0; + } + + // Area Effect Spells receive only half of bonus + if ($isArea) + $castingTime /= 2; + + // -5% of total per any additional effect + $castingTime -= ($nEffects * 175); + if ($castingTime < 0) + $castingTime = 0; + + return $castingTime; + } + + public function getSourceData(int $id = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + if ($id && $id != $this->id) + continue; + + $data[$this->id] = array( + 'n' => $this->getField('name', true), + 't' => Type::SPELL, + 'ti' => $this->id, + 's' => empty($this->curTpl['skillLines']) ? 0 : $this->curTpl['skillLines'][0], + 'c' => $this->curTpl['typeCat'], + 'icon' => $this->curTpl['iconStringAlt'] ?: $this->curTpl['iconString'], + ); + } + + return $data; + } +} + + +class SpellListFilter extends Filter +{ + const MAX_SPELL_EFFECT = 167; + const MAX_SPELL_AURA = 316; + + public static array $attributesFilter = array( // attrFieldId => [attrBit => cr, ...]; if cr < 0 ? filter is negated + 0 => array( + SPELL_ATTR0_REQ_AMMO => 48, + SPELL_ATTR0_ON_NEXT_SWING => 49, + SPELL_ATTR0_PASSIVE => 50, + SPELL_ATTR0_HIDDEN_CLIENTSIDE => 51, + SPELL_ATTR0_HIDE_IN_COMBAT_LOG => 84, + SPELL_ATTR0_ON_NEXT_SWING_2 => 52, + SPELL_ATTR0_DAYTIME_ONLY => 53, + SPELL_ATTR0_NIGHT_ONLY => 54, + SPELL_ATTR0_INDOORS_ONLY => 55, + SPELL_ATTR0_OUTDOORS_ONLY => 56, + SPELL_ATTR0_NOT_SHAPESHIFT => -31, + SPELL_ATTR0_ONLY_STEALTHED => 38, + SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION => 58, + SPELL_ATTR0_STOP_ATTACK_TARGET => 59, + SPELL_ATTR0_IMPOSSIBLE_DODGE_PARRY_BLOCK => 60, + SPELL_ATTR0_CASTABLE_WHILE_DEAD => 61, + SPELL_ATTR0_CASTABLE_WHILE_MOUNTED => 62, + SPELL_ATTR0_DISABLED_WHILE_ACTIVE => 63, + SPELL_ATTR0_NEGATIVE_1 => 69, + SPELL_ATTR0_CASTABLE_WHILE_SITTING => 64, + SPELL_ATTR0_CANT_USED_IN_COMBAT => -33, + SPELL_ATTR0_UNAFFECTED_BY_INVULNERABILITY => 46, + SPELL_ATTR0_CANT_CANCEL => 57 + ), + 1 => array( + SPELL_ATTR1_DRAIN_ALL_POWER => 65, + SPELL_ATTR1_CHANNELED_1 => 27, // general filter + SPELL_ATTR1_NOT_BREAK_STEALTH => 68, + SPELL_ATTR1_CHANNELED_2 => 66, // attributes filter + SPELL_ATTR1_CANT_BE_REFLECTED => 67, // WH - 69: all effects are harmful points here + SPELL_ATTR1_CANT_TARGET_IN_COMBAT => 70, + SPELL_ATTR1_NO_THREAT => 71, + SPELL_ATTR1_IS_PICKPOCKET => 72, + SPELL_ATTR1_DISPEL_AURAS_ON_IMMUNITY => 73, + SPELL_ATTR1_UNAFFECTED_BY_SCHOOL_IMMUNE => 47, + SPELL_ATTR1_IS_FISHING => 74 + ), + 2 => array( + SPELL_ATTR2_CANT_TARGET_TAPPED => 75, + SPELL_ATTR2_PRESERVE_ENCHANT_IN_ARENA => 76, + SPELL_ATTR2_NOT_NEED_SHAPESHIFT => 77, + SPELL_ATTR2_CANT_CRIT => -34, + SPELL_ATTR2_FOOD_BUFF => 78 + ), + 3 => array( + SPELL_ATTR3_ONLY_TARGET_PLAYERS => 79, + SPELL_ATTR3_MAIN_HAND => 80, + SPELL_ATTR3_BATTLEGROUND => 43, + SPELL_ATTR3_NO_INITIAL_AGGRO => 81, + SPELL_ATTR3_DEATH_PERSISTENT => 36, + SPELL_ATTR3_IGNORE_HIT_RESULT => -35, + SPELL_ATTR3_REQ_WAND => 82, // unused attribute + SPELL_ATTR3_REQ_OFFHAND => 83 + ), + 4 => array( + SPELL_ATTR4_FADES_WHILE_LOGGED_OUT => 85, + SPELL_ATTR4_NOT_STEALABLE => -39, + SPELL_ATTR4_NOT_USABLE_IN_ARENA => -44, + SPELL_ATTR4_USABLE_IN_ARENA => 44 + ), + 5 => array( + SPELL_ATTR5_USABLE_WHILE_STUNNED => 42, + SPELL_ATTR5_SINGLE_TARGET_SPELL => 86, + SPELL_ATTR5_START_PERIODIC_AT_APPLY => 87, + SPELL_ATTR5_USABLE_WHILE_FEARED => 89, + SPELL_ATTR5_USABLE_WHILE_CONFUSED => 88 + ), + 6 => array( + SPELL_ATTR6_ONLY_IN_ARENA => 90, // unused attribute + SPELL_ATTR6_NOT_IN_RAID_INSTANCE => 91 + ), + 7 => array( + SPELL_ATTR7_DISABLE_AURA_WHILE_DEAD => 92, // aka Paladin Aura + SPELL_ATTR7_SUMMON_PLAYER_TOTEM => 93 + ) + ); + + protected string $type = 'spells'; + protected static array $enums = array( + 9 => array( // sources index + 1 => true, // Any + 2 => false, // None + 3 => SRC_CRAFTED, + 4 => SRC_DROP, + 6 => SRC_QUEST, + 7 => SRC_VENDOR, + 8 => SRC_TRAINER, + 9 => SRC_DISCOVERY, + 10 => SRC_TALENT + ), + 22 => array( + 1 => true, // Weapons + 2 => true, // Armor + 3 => true, // Armor Proficiencies + 4 => true, // Armor Specializations + 5 => true // Languages + ), + 40 => array( // damage class index + 1 => 0, // none + 2 => 1, // magic + 3 => 2, // melee + 4 => 3 // ranged + ), + 45 => array( // power type index + // 1 => ??, // burning embers + // 2 => ??, // chi + // 3 => ??, // demonic fury + 4 => POWER_ENERGY, // energy + 5 => POWER_FOCUS, // focus + 6 => POWER_HEALTH, // health + // 7 => ??, // holy power + 8 => POWER_MANA, // mana + 9 => POWER_RAGE, // rage + 10 => POWER_RUNE, // runes + 11 => POWER_RUNIC_POWER, // runic power + // 12 => ??, // shadow orbs + // 13 => ??, // soul shard + 14 => POWER_HAPPINESS, // happiness v custom v + 15 => -1, // ammo + 16 => -41, // pyrite + 17 => -61, // steam pressure + 18 => -101, // heat + 19 => -121, // ooze + 20 => -141, // blood power + 21 => -142 // wrath + ) + ); + + protected static array $genericFilter = array( + 1 => [parent::CR_CALLBACK, 'cbCost', ], // costAbs [op] [int] + 2 => [parent::CR_NUMERIC, 'powerCostPercent', NUM_CAST_INT ], // prcntbasemanarequired + 3 => [parent::CR_BOOLEAN, 'spellFocusObject' ], // requiresnearbyobject + 4 => [parent::CR_NUMERIC, 'trainingcost', NUM_CAST_INT ], // trainingcost + 5 => [parent::CR_BOOLEAN, 'reqSpellId' ], // requiresprofspec + 8 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots + 9 => [parent::CR_CALLBACK, 'cbSource', ], // source [enum] + 10 => [parent::CR_FLAG, 'cuFlags', SPELL_CU_FIRST_RANK ], // firstrank + 11 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments + 12 => [parent::CR_FLAG, 'cuFlags', SPELL_CU_LAST_RANK ], // lastrank + 13 => [parent::CR_NUMERIC, 'rankNo', NUM_CAST_INT ], // rankno + 14 => [parent::CR_NUMERIC, 'id', NUM_CAST_INT, true ], // id + 15 => [parent::CR_STRING, 'ic.name', ], // icon + 17 => [parent::CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos + 19 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION ], // scaling + 20 => [parent::CR_CALLBACK, 'cbReagents', ], // has Reagents [yn] + 22 => [parent::CR_CALLBACK, 'cbProficiency', null, null ], // proficiencytype [proficiencytype] + // 26 => [parent::CR_NUMERIC, 'startRecoveryCategory', NUM_CAST_INT, false ], // gcd-cat + 25 => [parent::CR_BOOLEAN, 'skillLevelYellow' ], // rewardsskillups + 27 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_CHANNELED_1, true ], // channeled [yn] + 28 => [parent::CR_NUMERIC, 'castTime', NUM_CAST_FLOAT ], // casttime [num] + 29 => [parent::CR_CALLBACK, 'cbAuraNames', ], // appliesaura [effectauranames] + 31 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_NOT_SHAPESHIFT ], // usablewhenshapeshifted [yn] + 33 => [parent::CR_CALLBACK, 'cbInverseFlag', 'attributes0', SPELL_ATTR0_CANT_USED_IN_COMBAT], // combatcastable [yn] + 34 => [parent::CR_CALLBACK, 'cbInverseFlag', 'attributes2', SPELL_ATTR2_CANT_CRIT ], // chancetocrit [yn] + 35 => [parent::CR_CALLBACK, 'cbInverseFlag', 'attributes3', SPELL_ATTR3_IGNORE_HIT_RESULT ], // chancetomiss [yn] + 36 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_DEATH_PERSISTENT ], // persiststhroughdeath [yn] + 38 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_ONLY_STEALTHED ], // requiresstealth [yn] + 39 => [parent::CR_FLAG, 'attributes4', SPELL_ATTR4_NOT_STEALABLE ], // spellstealable [yn] + 40 => [parent::CR_ENUM, 'damageClass' ], // damagetype [damagetype] + 41 => [parent::CR_FLAG, 'stanceMask', (1 << (22 - 1)) ], // requiresmetamorphosis [yn] + 42 => [parent::CR_FLAG, 'attributes5', SPELL_ATTR5_USABLE_WHILE_STUNNED ], // usablewhenstunned [yn] + 43 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_BATTLEGROUND ], // usableinbgs [yn] + 44 => [parent::CR_FLAG, 'attributes4', SPELL_ATTR4_USABLE_IN_ARENA ], // usableinarenas [yn] + 45 => [parent::CR_ENUM, 'powerType' ], // resourcetype [resourcetype] + 46 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_UNAFFECTED_BY_INVULNERABILITY ], // disregardimmunity [yn] + 47 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_UNAFFECTED_BY_SCHOOL_IMMUNE ], // disregardschoolimmunity [yn] + 48 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_REQ_AMMO ], // reqrangedweapon [yn] + 49 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_ON_NEXT_SWING ], // onnextswingplayers [yn] + 50 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_PASSIVE ], // passivespell [yn] + 51 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_DONT_DISPLAY_IN_AURA_BAR ], // hiddenaura [yn] + 52 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_ON_NEXT_SWING_2 ], // onnextswingnpcs [yn] + 53 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_DAYTIME_ONLY ], // daytimeonly [yn] + 54 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_NIGHT_ONLY ], // nighttimeonly [yn] + 55 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_INDOORS_ONLY ], // indoorsonly [yn] + 56 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_OUTDOORS_ONLY ], // outdoorsonly [yn] + 57 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_CANT_CANCEL ], // uncancellableaura [yn] + 58 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION ], // damagedependsonlevel [yn] + 59 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_STOP_ATTACK_TARGET ], // stopsautoattack [yn] + 60 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_IMPOSSIBLE_DODGE_PARRY_BLOCK ], // cannotavoid [yn] + 61 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_CASTABLE_WHILE_DEAD ], // usabledead [yn] + 62 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_CASTABLE_WHILE_MOUNTED ], // usablemounted [yn] + 63 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_DISABLED_WHILE_ACTIVE ], // delayedrecoverystarttime [yn] + 64 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_CASTABLE_WHILE_SITTING ], // usablesitting [yn] + 65 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_DRAIN_ALL_POWER ], // usesallpower [yn] + 66 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_CHANNELED_2 ], // channeled [yn] + 67 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_CANT_BE_REFLECTED ], // cannotreflect [yn] + 68 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_NOT_BREAK_STEALTH ], // usablestealthed [yn] + 69 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_NEGATIVE_1 ], // harmful [yn] - WH interprets attributes1 0x80 as "all effects are harmful", but it really is CANT_BE_REFLECTED. So here is an approximation. + 70 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_CANT_TARGET_IN_COMBAT ], // targetnotincombat [yn] + 71 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_NO_THREAT ], // nothreat [yn] + 72 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_IS_PICKPOCKET ], // pickpocket [yn] + 73 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_DISPEL_AURAS_ON_IMMUNITY ], // dispelauraonimmunity [yn] + 74 => [parent::CR_FLAG, 'attributes1', SPELL_ATTR1_IS_FISHING ], // reqfishingpole [yn] + 75 => [parent::CR_FLAG, 'attributes2', SPELL_ATTR2_CANT_TARGET_TAPPED ], // requntappedtarget [yn] + 76 => [parent::CR_FLAG, 'attributes2', SPELL_ATTR2_PRESERVE_ENCHANT_IN_ARENA ], // targetownitem [yn + 77 => [parent::CR_FLAG, 'attributes2', SPELL_ATTR2_NOT_NEED_SHAPESHIFT ], // doesntreqshapeshift [yn] + 78 => [parent::CR_FLAG, 'attributes2', SPELL_ATTR2_FOOD_BUFF ], // foodbuff [yn] + 79 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_ONLY_TARGET_PLAYERS ], // targetonlyplayer [yn] + 80 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_MAIN_HAND ], // reqmainhand [yn] + 81 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_NO_INITIAL_AGGRO ], // doesntengagetarget [yn] + 82 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_REQ_WAND ], // reqwand [yn] + 83 => [parent::CR_FLAG, 'attributes3', SPELL_ATTR3_REQ_OFFHAND ], // reqoffhand [yn] + 84 => [parent::CR_FLAG, 'attributes0', SPELL_ATTR0_HIDE_IN_COMBAT_LOG ], // nolog [yn] + 85 => [parent::CR_FLAG, 'attributes4', SPELL_ATTR4_FADES_WHILE_LOGGED_OUT ], // auratickswhileloggedout [yn] + 86 => [parent::CR_FLAG, 'attributes5', SPELL_ATTR5_SINGLE_TARGET_SPELL ], // onlyaffectsonetarget [yn] + 87 => [parent::CR_FLAG, 'attributes5', SPELL_ATTR5_START_PERIODIC_AT_APPLY ], // startstickingatapplication [yn] + 88 => [parent::CR_FLAG, 'attributes5', SPELL_ATTR5_USABLE_WHILE_CONFUSED ], // usableconfused [yn] + 89 => [parent::CR_FLAG, 'attributes5', SPELL_ATTR5_USABLE_WHILE_FEARED ], // usablefeared [yn] + 90 => [parent::CR_FLAG, 'attributes6', SPELL_ATTR6_ONLY_IN_ARENA ], // onlyarena [yn] + 91 => [parent::CR_FLAG, 'attributes6', SPELL_ATTR6_NOT_IN_RAID_INSTANCE ], // notinraid [yn] + 92 => [parent::CR_FLAG, 'attributes7', SPELL_ATTR7_DISABLE_AURA_WHILE_DEAD ], // paladinaura [yn] + 93 => [parent::CR_FLAG, 'attributes7', SPELL_ATTR7_SUMMON_PLAYER_TOTEM ], // totemspell [yn] + 95 => [parent::CR_CALLBACK, 'cbBandageSpell' ], // bandagespell [yn] - was that an attribute at one point? + 96 => [parent::CR_STAFFFLAG, 'attributes0' ], // flags1 [flags] + 97 => [parent::CR_STAFFFLAG, 'attributes1' ], // flags2 [flags] + 98 => [parent::CR_STAFFFLAG, 'attributes2' ], // flags3 [flags] + 99 => [parent::CR_STAFFFLAG, 'attributes3' ], // flags4 [flags] + 100 => [parent::CR_STAFFFLAG, 'attributes4' ], // flags5 [flags] + 101 => [parent::CR_STAFFFLAG, 'attributes5' ], // flags6 [flags] + 102 => [parent::CR_STAFFFLAG, 'attributes6' ], // flags7 [flags] + 103 => [parent::CR_STAFFFLAG, 'attributes7' ], // flags8 [flags] + 104 => [parent::CR_STAFFFLAG, 'targets' ], // flags9 [flags] + 105 => [parent::CR_STAFFFLAG, 'stanceMaskNot' ], // flags10 [flags] + 106 => [parent::CR_STAFFFLAG, 'spellFamilyFlags1' ], // flags11 [flags] + 107 => [parent::CR_STAFFFLAG, 'spellFamilyFlags2' ], // flags12 [flags] + 108 => [parent::CR_STAFFFLAG, 'spellFamilyFlags3' ], // flags13 [flags] + 109 => [parent::CR_CALLBACK, 'cbEffectNames', ], // effecttype [effecttype] + // 110 => [parent::CR_NYI_PH, null, null, null ], // scalingap [yn] // unreasonably complex for now + // 111 => [parent::CR_NYI_PH, null, null, null ], // scalingsp [yn] // unreasonably complex for now + 114 => [parent::CR_CALLBACK, 'cbReqFaction' ], // requiresfaction [side] + 116 => [parent::CR_BOOLEAN, 'startRecoveryTime' ] // onGlobalCooldown [yn] + ); + + protected static array $inputFields = array( + 'cr' => [parent::V_RANGE, [1, 116], true ], // criteria ids + 'crs' => [parent::V_LIST, [parent::ENUM_NONE, parent::ENUM_ANY, [0, 99999]], true ], // criteria operators + 'crv' => [parent::V_REGEX, parent::PATTERN_CRV, true ], // criteria values - only printable chars, no delimiters + 'na' => [parent::V_NAME, false, false], // name / text - only printable chars, no delimiter + 'ex' => [parent::V_EQUAL, 'on', false], // extended name search + 'ma' => [parent::V_EQUAL, 1, false], // match any / all filter + 'minle' => [parent::V_RANGE, [0, 99], false], // spell level min + 'maxle' => [parent::V_RANGE, [0, 99], false], // spell level max + 'minrs' => [parent::V_RANGE, [0, 999], false], // required skill level min + 'maxrs' => [parent::V_RANGE, [0, 999], false], // required skill level max + 'ra' => [parent::V_LIST, [[1, 8], 10, 11], false], // races + 'cl' => [parent::V_CALLBACK, 'cbClasses', true ], // classes + 'gl' => [parent::V_CALLBACK, 'cbGlyphs', true ], // glyph type + 'sc' => [parent::V_RANGE, [0, 6], true ], // magic schools + 'dt' => [parent::V_LIST, [[1, 6], 9], false], // dispel types + 'me' => [parent::V_RANGE, [1, 31], false] // mechanics + ); + + protected function createSQLForValues() : array + { + $parts = []; + $_v = &$this->values; + + // string (extended) + if ($_v['na']) + { + $f = [['na', ['nml.nName', 'nml.nBuff', 'nml.nDescription']]]; + if ($_v['ex'] != 'on') + $f = [['na', 'nml.nName']]; + + if ($_ = $this->buildMatchLookup($f)) + $parts[] = $_; + else + { + $f = [['na', 'name_loc'.Lang::getLocale()->value], ['na', 'buff_loc'.Lang::getLocale()->value], ['na', 'description_loc'.Lang::getLocale()->value]]; + if ($_v['ex'] != 'on') + $f = [$f[0]]; + + if ($_ = $this->buildLikeLookup($f)) + $parts[] = $_; + } + } + + // spellLevel min todo (low): talentSpells (typeCat -2) commonly have spellLevel 1 (and talentLevel >1) -> query is inaccurate + if ($_v['minle']) + $parts[] = ['spellLevel', $_v['minle'], '>=']; + + // spellLevel max + if ($_v['maxle']) + $parts[] = ['spellLevel', $_v['maxle'], '<=']; + + // skillLevel min + if ($_v['minrs']) + $parts[] = ['learnedAt', $_v['minrs'], '>=']; + + // skillLevel max + if ($_v['maxrs']) + $parts[] = ['learnedAt', $_v['maxrs'], '<=']; + + // race + if ($_v['ra']) + $parts[] = [DB::AND, [['reqRaceMask', ChrRace::MASK_ALL, '&'], ChrRace::MASK_ALL, '!'], ['reqRaceMask', $this->list2Mask([$_v['ra']]), '&']]; + + // class [list] + if ($_v['cl']) + $parts[] = ['reqClassMask', $this->list2Mask($_v['cl']), '&']; + + // school [list] + if ($_v['sc']) + $parts[] = ['schoolMask', $this->list2Mask($_v['sc'], true), '&']; + + // glyph type [list] wonky, admittedly, but consult SPELL_CU_* in defines and it makes sense + if ($_v['gl']) + $parts[] = ['cuFlags', ($this->list2Mask($_v['gl']) << 6), '&']; + + // dispel type + if ($_v['dt']) + $parts[] = ['dispelType', $_v['dt']]; + + // mechanic + if ($_v['me']) + $parts[] = [DB::OR, ['mechanic', $_v['me']], ['effect1Mechanic', $_v['me']], ['effect2Mechanic', $_v['me']], ['effect3Mechanic', $_v['me']]]; + + return $parts; + } + + protected function cbClasses(string &$val) : bool + { + if (!$this->parentCats || !in_array($this->parentCats[0], [-13, -2, 7])) + return false; + + if (!Util::checkNumeric($val, NUM_CAST_INT)) + return false; + + $type = parent::V_LIST; + $valid = ChrClass::fromMask(ChrClass::MASK_ALL); + + return $this->checkInput($type, $valid, $val); + } + + protected function cbGlyphs(string &$val) : bool + { + if (!$this->parentCats || $this->parentCats[0] != -13) + return false; + + if (!Util::checkNumeric($val, NUM_CAST_INT)) + return false; + + $type = parent::V_LIST; + $valid = [1, 2]; + + return $this->checkInput($type, $valid, $val); + } + + protected function cbCost(int $cr, int $crs, string $crv) : ?array + { + if (!Util::checkNumeric($crv, NUM_CAST_INT) || !$this->int2Op($crs)) + return null; + + return [DB::OR, + [DB::AND, ['powerType', [POWER_RAGE, POWER_RUNIC_POWER]], ['powerCost', (10 * $crv), $crs]], + [DB::AND, ['powerType', [POWER_RAGE, POWER_RUNIC_POWER], '!'], ['powerCost', $crv, $crs]] + ]; + } + + protected function cbSource(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $_ = self::$enums[$cr][$crs]; + if (is_int($_)) // specific + return ['src.src'.$_, null, '!']; + else if ($_) // any + { + $foo = [DB::OR]; + foreach (self::$enums[$cr] as $bar) + if (is_int($bar)) + $foo[] = ['src.src'.$bar, null, '!']; + + return $foo; + } + else // none + return ['src.typeId', null]; + + return null; + } + + protected function cbReagents(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [DB::OR, ['reagent1', 0, '>'], ['reagent2', 0, '>'], ['reagent3', 0, '>'], ['reagent4', 0, '>'], ['reagent5', 0, '>'], ['reagent6', 0, '>'], ['reagent7', 0, '>'], ['reagent8', 0, '>']]; + else + return [DB::AND, ['reagent1', 0], ['reagent2', 0], ['reagent3', 0], ['reagent4', 0], ['reagent5', 0], ['reagent6', 0], ['reagent7', 0], ['reagent8', 0]]; + } + + protected function cbAuraNames(int $cr, int $crs, string $crv) : ?array + { + if (!$this->checkInput(parent::V_RANGE, [1, self::MAX_SPELL_AURA], $crs)) + return null; + + return [DB::OR, ['effect1AuraId', $crs], ['effect2AuraId', $crs], ['effect3AuraId', $crs]]; + } + + protected function cbEffectNames(int $cr, int $crs, string $crv) : ?array + { + if (!$this->checkInput(parent::V_RANGE, [1, self::MAX_SPELL_EFFECT], $crs)) + return null; + + return [DB::OR, ['effect1Id', $crs], ['effect2Id', $crs], ['effect3Id', $crs]]; + } + + protected function cbInverseFlag(int $cr, int $crs, string $crv, string $field, int $flag) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [[$field, $flag, '&'], 0]; + else + return [$field, $flag, '&']; + } + + protected function cbSpellstealable(int $cr, int $crs, string $crv, string $field, int $flag) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [DB::AND, [[$field, $flag, '&'], 0], ['dispelType', SPELL_DAMAGE_CLASS_MAGIC]]; + else + return [DB::OR, [$field, $flag, '&'], ['dispelType', SPELL_DAMAGE_CLASS_MAGIC, '!']]; + } + + protected function cbReqFaction(int $cr, int $crs, string $crv) : ?array + { + return match ($crs) + { + // yes + 1 => ['reqRaceMask', 0, '!'], + // alliance + 2 => [DB::AND, [['reqRaceMask', ChrRace::MASK_HORDE, '&'], 0], ['reqRaceMask', ChrRace::MASK_ALLIANCE, '&']], + // horde + 3 => [DB::AND, [['reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], 0], ['reqRaceMask', ChrRace::MASK_HORDE, '&']], + // both + 4 => [DB::AND, ['reqRaceMask', ChrRace::MASK_ALLIANCE, '&'], ['reqRaceMask', ChrRace::MASK_HORDE, '&']], + // no + 5 => ['reqRaceMask', 0], + default => null + }; + } + + /* unused - for reference: attribute flag or item class mask */ + protected function cbEquippedWeapon(int $cr, int $crs, string $crv, int $mask, bool $useInvType) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + $field = $useInvType ? 'equippedItemInventoryTypeMask' : 'equippedItemSubClassMask'; + + if ($crs) + return [DB::AND, ['equippedItemClass', ITEM_CLASS_WEAPON], [$field, $mask, '&']]; + else + return [DB::OR, ['equippedItemClass', ITEM_CLASS_WEAPON, '!'], [[$field, $mask, '&'], 0]]; + } + + /* unused - for reference: attribute flag or cooldown time constraint */ + protected function cbUsableInArena(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) + return [DB::AND, + [['attributes4', SPELL_ATTR4_NOT_USABLE_IN_ARENA, '&'], 0], + [DB::OR, ['recoveryTime', 10 * MINUTE * 1000, '<='], ['attributes4', SPELL_ATTR4_USABLE_IN_ARENA, '&']] + ]; + else + return [DB::OR, + ['attributes4', SPELL_ATTR4_NOT_USABLE_IN_ARENA, '&'], + [DB::AND, ['recoveryTime', 10 * MINUTE * 1000, '>'], [['attributes4', SPELL_ATTR4_USABLE_IN_ARENA, '&'], 0]] + ]; + } + + protected function cbBandageSpell(int $cr, int $crs, string $crv) : ?array + { + if (!$this->int2Bool($crs)) + return null; + + if ($crs) // match exact, not as flag + return [DB::AND, ['attributes1', SPELL_ATTR1_CHANNELED_1 | SPELL_ATTR1_CHANNELED_2 | SPELL_ATTR1_CHANNEL_TRACK_TARGET], ['effect1ImplicitTargetA', 21]]; + else + return [DB::OR, ['attributes1', SPELL_ATTR1_CHANNELED_1 | SPELL_ATTR1_CHANNELED_2 | SPELL_ATTR1_CHANNEL_TRACK_TARGET, '!'], ['effect1ImplicitTargetA', 21, '!']]; + } + + protected function cbProficiency(int $cr, int $crs, string $crv) : ?array + { + if (!isset(self::$enums[$cr][$crs])) + return null; + + $skill1Ids = []; + $skill2Mask = 0x0; + + switch($crs) + { + case 1: // Weapons + foreach (Game::$skillLineMask[-3] as $bit => $_) + $skill2Mask |= (1 << $bit); + $skill1Ids = DB::Aowow()->selectCol('SELECT `id` FROM ::skillline WHERE `typeCat` = 6'); + break; + case 2: // Armor (Proficiencies + Specializations: so for us it's the same) + case 3: // Armor Proficiencies + $skill1Ids = DB::Aowow()->selectCol('SELECT `id` FROM ::skillline WHERE `typeCat` = 8'); + break; + case 4: // Armor Specializations + return [0]; // 4.x+ feature where using purely one type of armor increases your primary stat + case 5: // Languages + $skill1Ids = DB::Aowow()->selectCol('SELECT `id` FROM ::skillline WHERE `typeCat` = 10'); + break; + } + + if (!$skill1Ids) + return [0]; + + $cnd = ['skillLine1', $skill1Ids]; + if ($skill2Mask) + $cnd = [DB::OR, $cnd, [DB::AND, ['skillLine1', -3], ['skillLine2OrMask', $skill2Mask, '&']]]; + + return $cnd; + } +} + +?> diff --git a/includes/dbtypes/title.class.php b/includes/dbtypes/title.class.php new file mode 100644 index 00000000..b583a290 --- /dev/null +++ b/includes/dbtypes/title.class.php @@ -0,0 +1,180 @@ + [['src']], // 11: Type::TITLE + 'src' => ['j' => ['::source src ON `type` = 11 AND `typeId` = t.`id`', true], 's' => ', `src13`, `moreType`, `moreTypeId`'] + ); + + public function __construct(array $conditions = [], array $miscData = []) + { + parent::__construct($conditions, $miscData); + + // post processing + foreach ($this->iterate() as &$_curTpl) + { + // preparse sources - notice: under this system titles can't have more than one source (or two for achivements), which is enough for standard TC cases but may break custom cases + if ($_curTpl['moreType'] == Type::ACHIEVEMENT) + $this->sources[$this->id][SRC_ACHIEVEMENT][] = $_curTpl['moreTypeId']; + else if ($_curTpl['moreType'] == Type::QUEST) + $this->sources[$this->id][SRC_QUEST][] = $_curTpl['moreTypeId']; + else if ($_curTpl['src13']) + $this->sources[$this->id][SRC_CUSTOM_STRING][] = $_curTpl['src13']; + + // titles display up to two achievements at once + if ($_curTpl['src12Ext']) + $this->sources[$this->id][SRC_ACHIEVEMENT][] = $_curTpl['src12Ext']; + + unset($_curTpl['src12Ext']); + unset($_curTpl['moreType']); + unset($_curTpl['moreTypeId']); + unset($_curTpl['src3']); + + // shorthand for more generic access; required by CommunityContent to determine subject + foreach (Locale::cases() as $loc) + if ($loc->validate()) + $_curTpl['name'] = new LocString($_curTpl, 'male', fn($x) => trim(str_replace('%s', '', $x))); + // $_curTpl['name_loc'.$loc->value] = trim(str_replace('%s', '', $_curTpl['male_loc'.$loc->value])); + } + } + + public static function getName(int $id) : ?LocString + { + if ($n = DB::Aowow()->SelectRow('SELECT `male_loc0`, `male_loc2`, `male_loc3`, `male_loc4`, `male_loc6`, `male_loc8` FROM %n WHERE `id` = %i', self::$dataTable, $id)) + return new LocString($n, 'male', fn($x) => trim(str_replace('%s', '', $x))); + return null; + } + + public function getListviewData() : array + { + $data = []; + $this->createSource(); + + foreach ($this->iterate() as $__) + { + $data[$this->id] = array( + 'id' => $this->id, + 'name' => $this->getField('male', true), + 'namefemale' => $this->getField('female', true), + 'side' => $this->curTpl['side'], + 'gender' => $this->curTpl['gender'], + 'expansion' => $this->curTpl['expansion'], + 'category' => $this->curTpl['category'] + ); + + if (!empty($this->curTpl['source'])) + $data[$this->id]['source'] = $this->curTpl['source']; + } + + return $data; + } + + public function getJSGlobals(int $addMask = 0) : array + { + $data = []; + + foreach ($this->iterate() as $__) + { + $data[Type::TITLE][$this->id]['name'] = $this->getField('male', true); + + if ($_ = $this->getField('female', true)) + $data[Type::TITLE][$this->id]['namefemale'] = $_; + } + + return $data; + } + + private function createSource() : void + { + $sources = array( + SRC_QUEST => [], + SRC_ACHIEVEMENT => [], + SRC_CUSTOM_STRING => [] + ); + + foreach ($this->iterate() as $__) + { + if (empty($this->sources[$this->id])) + continue; + + foreach (array_keys($sources) as $srcKey) + if (isset($this->sources[$this->id][$srcKey])) + $sources[$srcKey] = array_merge($sources[$srcKey], $this->sources[$this->id][$srcKey]); + } + + // fill in the details + if (!empty($sources[SRC_QUEST])) + $sources[SRC_QUEST] = (new QuestList(array(['id', $sources[SRC_QUEST]])))->getSourceData(); + + if (!empty($sources[SRC_ACHIEVEMENT])) + $sources[SRC_ACHIEVEMENT] = (new AchievementList(array(['id', $sources[SRC_ACHIEVEMENT]])))->getSourceData(); + + foreach ($this->sources as $Id => $src) + { + $tmp = []; + + // Quest-source + if (isset($src[SRC_QUEST])) + { + foreach ($src[SRC_QUEST] as $s) + { + if (isset($sources[SRC_QUEST][$s]['s'])) + $this->faction2Side($sources[SRC_QUEST][$s]['s']); + + $tmp[SRC_QUEST][] = $sources[SRC_QUEST][$s]; + } + } + + // Achievement-source + if (isset($src[SRC_ACHIEVEMENT])) + { + foreach ($src[SRC_ACHIEVEMENT] as $s) + { + if (isset($sources[SRC_ACHIEVEMENT][$s]['s'])) + $this->faction2Side($sources[SRC_ACHIEVEMENT][$s]['s']); + + $tmp[SRC_ACHIEVEMENT][] = $sources[SRC_ACHIEVEMENT][$s]; + } + } + + // other source (only one item possible, so no iteration needed) + if (isset($src[SRC_CUSTOM_STRING])) + $tmp[SRC_CUSTOM_STRING] = [Lang::game('pvpSources', $Id)]; + + $this->templates[$Id]['source'] = $tmp; + } + } + + public function getHtmlizedName(int $gender = GENDER_MALE) : string + { + $field = $gender == GENDER_FEMALE ? 'female' : 'male'; + return str_replace('%s', '<'.Util::ucFirst(Lang::main('name')).'>', $this->getField($field, true)); + } + + public function renderTooltip() : ?string { return null; } + + private function faction2Side(int &$faction) : void // thats weird.. and hopefully unique to titles + { + if ($faction == 2) // Horde + $faction = 0; + else if ($faction != 1) // Alliance + $faction = -1; // Both + } +} + +?> diff --git a/includes/dbtypes/user.class.php b/includes/dbtypes/user.class.php new file mode 100644 index 00000000..18122f6b --- /dev/null +++ b/includes/dbtypes/user.class.php @@ -0,0 +1,89 @@ + [['r']], + 'r' => ['j' => ['::account_reputation r ON r.`userId` = a.`id`', true], 's' => ', IFNULL(SUM(r.`amount`), 0) AS "reputation"', 'g' => 'a.`id`'] + ); + + public function getJSGlobals(int $addMask = 0) : array + { + $data = []; + + foreach ($this->iterate() as $userId => $__) + { + $data[$this->curTpl['username']] = array( + 'border' => $this->getPremiumborder(), + 'roles' => $this->curTpl['userGroups'], + 'joined' => date(Util::$dateFormatInternal, $this->curTpl['joinDate']), + 'posts' => 0, // forum posts + // 'gold' => 0, // achievement system + // 'silver' => 0, // achievement system + // 'copper' => 0, // achievement system + 'reputation' => $this->curTpl['reputation'] + ); + + // custom titles (only seen on user page..?) + if ($_ = $this->curTpl['title']) + $data[$this->curTpl['username']]['title'] = $_; + + switch ($this->curTpl['avatar']) + { + case 1: + $data[$this->curTpl['username']]['avatar'] = $this->curTpl['avatar']; + $data[$this->curTpl['username']]['avatarmore'] = $this->curTpl['wowicon']; + break; + case 2: + if ($this->isPremium()) + { + if ($av = DB::Aowow()->selectCell('SELECT `id` FROM ::account_avatars WHERE `userId` = %i AND `current` = 1 AND `status` <> %i', $userId, AvatarMgr::STATUS_REJECTED)) + { + $data[$this->curTpl['username']]['avatar'] = $this->curTpl['avatar']; + $data[$this->curTpl['username']]['avatarmore'] = $av; + } + } + break; + } + + // more optional data + // sig: markdown formated string (only used in forum?) + } + + return [Type::USER => $data]; + } + + // seen as null|1|3 .. changes the border around the avatar (chosen from account > premium tab?) + // changed at the end of MoP. No longer a jsBool but index to Icon.premiumBorderClasses + private function getPremiumBorder() : int + { + if (!$this->isPremium() || !$this->curTpl['avatar']) + return 2; // 2 is "none" + + return $this->curTpl['avatarborder']; + } + + public function isPremium() : bool + { + return $this->curTpl['userGroups'] & U_GROUP_PREMIUM || $this->curTpl['reputation'] >= Cfg::get('REP_REQ_PREMIUM'); + } + + public function getListviewData() : array { return []; } + public function renderTooltip() : ?string { return null; } + + public static function getName($id) : ?LocString { return null; } +} + +?> diff --git a/includes/types/worldevent.class.php b/includes/dbtypes/worldevent.class.php similarity index 55% rename from includes/types/worldevent.class.php rename to includes/dbtypes/worldevent.class.php index 1c25969e..399d6083 100644 --- a/includes/types/worldevent.class.php +++ b/includes/dbtypes/worldevent.class.php @@ -1,24 +1,27 @@ [['h']], - 'h' => ['j' => ['?_holidays h ON e.holidayId = h.id', true], 'o' => '-e.id ASC'] + protected string $queryBase = 'SELECT e.`holidayId`, e.`cuFlags`, e.`startTime`, e.`endTime`, e.`occurence`, e.`length`, e.`requires`, e.`description` AS "nameINT", e.`id` AS "eventId", e.`id` AS ARRAY_KEY FROM ::events e'; + protected array $queryOpts = array( + 'e' => [['h', 'ic']], + 'h' => ['j' => ['::holidays h ON e.`holidayId` = h.`id`', true], 's' => ', h.*', 'o' => '-e.`id` ASC'], + 'ic' => ['j' => ['::icons ic ON ic.`id` = h.`iconId`', true], 's' => ', ic.`name` AS "iconString"'] ); - public function __construct($conditions = []) + public function __construct(array $conditions = [], array $miscData = []) { - parent::__construct($conditions); + parent::__construct($conditions, $miscData); // unseting elements while we iterate over the array will cause the pointer to reset $replace = []; @@ -60,70 +63,58 @@ class WorldEventList extends BaseType foreach ($replace as $old => $data) { unset($this->templates[$old]); - $this->templates[$data['id']] = $data; + $this->templates[$data['eventId']] = $data; } } - public static function getName($id) + public static function getName(int $id) : ?LocString { - $row = DB::Aowow()->SelectRow(' - SELECT - IFNULL(h.name_loc0, e.description) AS name_loc0, - h.name_loc2, - h.name_loc3, - h.name_loc4, - h.name_loc6, - h.name_loc8 - FROM - ?_events e - LEFT JOIN - ?_holidays h ON e.holidayId = h.id - WHERE - e.id = ?d', + $row = DB::Aowow()->SelectRow( + 'SELECT IFNULL(h.`name_loc0`, e.`description`) AS "name_loc0", h.`name_loc2`, h.`name_loc3`, h.`name_loc4`, h.`name_loc6`, h.`name_loc8` + FROM ::events e + LEFT JOIN ::holidays h ON e.`holidayId` = h.`id` + WHERE e.`id` = %i', $id ); - return Util::localizedString($row, 'name'); + return $row ? new LocString($row) : null; } - public static function updateDates($date = null) + public static function updateDates(?array $date = null, ?int &$start = null, ?int &$end = null, ?int &$rec = null) : bool { if (!$date || empty($date['firstDate']) || empty($date['length'])) - { - return array( - 'start' => 0, - 'end' => 0, - 'rec' => 0 - ); - } + return false; - // Convert everything to seconds - $firstDate = intVal($date['firstDate']); - $lastDate = !empty($date['lastDate']) ? intVal($date['lastDate']) : 5000000000; // in the far far FAR future..; - $interval = !empty($date['rec']) ? intVal($date['rec']) : -1; - $length = intVal($date['length']); + $start = $date['firstDate']; + $end = $date['firstDate'] + $date['length']; + $rec = $date['rec'] ?: -1; // interval - $curStart = $firstDate; - $curEnd = $firstDate + $length; - $nextStart = $curStart + $interval; - $nextEnd = $curEnd + $interval; + if ($rec < 0 || $date['lastDate'] < time()) + return true; - while ($interval > 0 && $nextEnd <= $lastDate && $curEnd < time()) - { - $curStart = $nextStart; - $curEnd = $nextEnd; - $nextStart = $curStart + $interval; - $nextEnd = $curEnd + $interval; - } + $nIntervals = (int)ceil((time() - $end) / $rec); - return array( - 'start' => $curStart, - 'end' => $curEnd, - 'rec' => $interval - ); + $start += $nIntervals * $rec; + $end += $nIntervals * $rec; + + return true; } - public function getListviewData($forNow = false) + public static function updateListview(Listview &$listview) : void + { + foreach ($listview->iterate() as &$row) + { + WorldEventList::updateDates($row['_date'] ?? null, $start, $end, $rec); + + $row['startDate'] = $start ? date(Util::$dateFormatInternal, $start) : null; + $row['endDate'] = $end ? date(Util::$dateFormatInternal, $end - 1) : null; + $row['rec'] = $rec; + + unset($row['_date']); + } + } + + public function getListviewData() : array { $data = []; @@ -142,22 +133,10 @@ class WorldEventList extends BaseType ); } - if ($forNow) - { - foreach ($data as &$d) - { - $u = self::updateDates($d['_date']); - unset($d['_date']); - $d['startDate'] = $u['start']; - $d['endDate'] = $u['end']; - $d['rec'] = $u['rec']; - } - } - return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -167,7 +146,7 @@ class WorldEventList extends BaseType return $data; } - public function renderTooltip() + public function renderTooltip() : ?string { if (!$this->curTpl) return null; @@ -179,9 +158,9 @@ class WorldEventList extends BaseType // use string-placeholder for dates // start - $x .= Lang::event('start').Lang::main('colon').'%s
'; + $x .= Lang::event('start').'%s
'; // end - $x .= Lang::event('end').Lang::main('colon').'%s'; + $x .= Lang::event('end').'%s'; $x .= ''; diff --git a/includes/types/zone.class.php b/includes/dbtypes/zone.class.php similarity index 78% rename from includes/types/zone.class.php rename to includes/dbtypes/zone.class.php index ea67a238..668eac6e 100644 --- a/includes/types/zone.class.php +++ b/includes/dbtypes/zone.class.php @@ -1,20 +1,22 @@ selectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_zones WHERE id = ?d', $id ); - return Util::localizedString($n, 'name'); - } - - public function getListviewData() + public function getListviewData() : array { $data = []; @@ -95,7 +90,7 @@ class ZoneList extends BaseType return $data; } - public function getJSGlobals($addMask = 0) + public function getJSGlobals(int $addMask = 0) : array { $data = []; @@ -105,7 +100,7 @@ class ZoneList extends BaseType return $data; } - public function renderTooltip() { } + public function renderTooltip() : ?string { return null; } } ?> diff --git a/includes/defines.php b/includes/defines.php index e2673f8e..4075085a 100644 --- a/includes/defines.php +++ b/includes/defines.php @@ -1,5 +1,7 @@ 10min -define('SPELL_ATTR4_AREA_TARGET_CHAIN', 0x00040000); // Chain area targets DESCRIPTION [NYI] Hits area targets over time instead of all at once -define('SPELL_ATTR4_UNK19', 0x00080000); // Unknown attribute 19@Attr4 -define('SPELL_ATTR4_NOT_CHECK_SELFCAST_POWER', 0x00100000); // Allow self-cast to override stronger aura (client only) -define('SPELL_ATTR4_UNK21', 0x00200000); // Keep when entering arena -define('SPELL_ATTR4_UNK22', 0x00400000); // Unknown attribute 22@Attr4 -define('SPELL_ATTR4_CANT_TRIGGER_ITEM_SPELLS', 0x00800000); // Cannot trigger item spells -define('SPELL_ATTR4_UNK24', 0x01000000); // Unknown attribute 24@Attr4 DESCRIPTION Shoot-type spell? -define('SPELL_ATTR4_IS_PET_SCALING', 0x02000000); // Pet Scaling aura -define('SPELL_ATTR4_CAST_ONLY_IN_OUTLAND', 0x04000000); // Only in Outland/Northrend -define('SPELL_ATTR4_INHERIT_CRIT_FROM_AURA', 0x08000000); // Inherit critical chance from triggering aura -define('SPELL_ATTR4_UNK28', 0x10000000); // Unknown attribute 28@Attr4 -define('SPELL_ATTR4_UNK29', 0x20000000); // Unknown attribute 29@Attr4 -define('SPELL_ATTR4_UNK30', 0x40000000); // Unknown attribute 30@Attr4 -define('SPELL_ATTR4_UNK31', 0x80000000); // Unknown attribute 31@Attr4 +// learn trigger spells on items - 483: learn recipe; 55884: learn mount/pet +define('LEARN_SPELLS', [483, 55884]); -define('SPELL_ATTR5_CAN_CHANNEL_WHEN_MOVING', 0x00000001); // Can be channeled while moving -define('SPELL_ATTR5_NO_REAGENT_WHILE_PREP', 0x00000002); // No reagents during arena preparation -define('SPELL_ATTR5_REMOVE_ON_ARENA_ENTER', 0x00000004); // Remove when entering arena DESCRIPTION Force this aura to be removed on entering arena, regardless of other properties -define('SPELL_ATTR5_USABLE_WHILE_STUNNED', 0x00000008); // Usable while stunned -define('SPELL_ATTR5_UNK4', 0x00000010); // Unknown attribute 4@Attr5 -define('SPELL_ATTR5_SINGLE_TARGET_SPELL', 0x00000020); // Single-target aura DESCRIPTION Remove previous application to another unit if applied -define('SPELL_ATTR5_UNK6', 0x00000040); // Unknown attribute 6@Attr5 -define('SPELL_ATTR5_UNK7', 0x00000080); // Unknown attribute 7@Attr5 -define('SPELL_ATTR5_UNK8', 0x00000100); // Unknown attribute 8@Attr5 -define('SPELL_ATTR5_START_PERIODIC_AT_APPLY', 0x00000200); // Immediately do periodic tick on apply -define('SPELL_ATTR5_HIDE_DURATION', 0x00000400); // Do not send aura duration to client -define('SPELL_ATTR5_ALLOW_TARGET_OF_TARGET_AS_TARGET', 0x00000800); // Auto-target target of target (client only) -define('SPELL_ATTR5_UNK12', 0x00001000); // Unknown attribute 12@Attr5 DESCRIPTION Cleave related? -define('SPELL_ATTR5_HASTE_AFFECT_DURATION', 0x00002000); // Duration scales with Haste Rating -define('SPELL_ATTR5_UNK14', 0x00004000); // Unknown attribute 14@Attr5 -define('SPELL_ATTR5_UNK15', 0x00008000); // Unknown attribute 15@Attr5 DESCRIPTION Related to multi-target spells? -define('SPELL_ATTR5_UNK16', 0x00010000); // Unknown attribute 16@Attr5 -define('SPELL_ATTR5_USABLE_WHILE_FEARED', 0x00020000); // Usable while feared -define('SPELL_ATTR5_USABLE_WHILE_CONFUSED', 0x00040000); // Usable while confused -define('SPELL_ATTR5_DONT_TURN_DURING_CAST', 0x00080000); // Do not auto-turn while casting -define('SPELL_ATTR5_UNK20', 0x00100000); // Unknown attribute 20@Attr5 -define('SPELL_ATTR5_UNK21', 0x00200000); // Unknown attribute 21@Attr5 -define('SPELL_ATTR5_UNK22', 0x00400000); // Unknown attribute 22@Attr5 -define('SPELL_ATTR5_UNK23', 0x00800000); // Unknown attribute 23@Attr5 -define('SPELL_ATTR5_UNK24', 0x01000000); // Unknown attribute 24@Attr5 -define('SPELL_ATTR5_UNK25', 0x02000000); // Unknown attribute 25@Attr5 -define('SPELL_ATTR5_SKIP_CHECKCAST_LOS_CHECK', 0x04000000); // Ignore line of sight checks -define('SPELL_ATTR5_DONT_SHOW_AURA_IF_SELF_CAST', 0x08000000); // Don't show aura if self-cast (client only) -define('SPELL_ATTR5_DONT_SHOW_AURA_IF_NOT_SELF_CAST', 0x10000000); // Don't show aura unless self-cast (client only) -define('SPELL_ATTR5_UNK29', 0x20000000); // Unknown attribute 29@Attr5 -define('SPELL_ATTR5_UNK30', 0x40000000); // Unknown attribute 30@Attr5 -define('SPELL_ATTR5_UNK31', 0x80000000); // Unknown attribute 31@Attr5 DESCRIPTION Forces nearby enemies to attack caster? +define('SPELL_ATTR0_PROC_FAILURE_BURNS_CHARGE', 0x00000001); // [WoWDev Wiki] The spell will consume a charge that is natural or procced even if it fails to apply it's effect. +define('SPELL_ATTR0_REQ_AMMO', 0x00000002); // Treat as ranged attack DESCRIPTION Use ammo, ranged attack range modifiers, ranged haste, etc. +define('SPELL_ATTR0_ON_NEXT_SWING', 0x00000004); // On next melee (type 1) DESCRIPTION Both "on next swing" attributes have identical handling in server & client +define('SPELL_ATTR0_IS_REPLENISHMENT', 0x00000008); // Replenishment (client only) +define('SPELL_ATTR0_ABILITY', 0x00000010); // Treat as ability DESCRIPTION Cannot be reflected, not affected by cast speed modifiers, etc. +define('SPELL_ATTR0_TRADESPELL', 0x00000020); // Trade skill recipe DESCRIPTION Displayed in recipe list, not affected by cast speed modifiers +define('SPELL_ATTR0_PASSIVE', 0x00000040); // Passive spell DESCRIPTION Spell is automatically cast on self by core +define('SPELL_ATTR0_HIDDEN_CLIENTSIDE', 0x00000080); // Hidden in UI (client only) DESCRIPTION Not visible in spellbook or aura bar +define('SPELL_ATTR0_HIDE_IN_COMBAT_LOG', 0x00000100); // Hidden in combat log (client only) DESCRIPTION Spell will not appear in combat logs +define('SPELL_ATTR0_TARGET_MAINHAND_ITEM', 0x00000200); // Auto-target mainhand item (client only) DESCRIPTION Client will automatically select main-hand item as cast target +define('SPELL_ATTR0_ON_NEXT_SWING_2', 0x00000400); // On next melee (type 2) DESCRIPTION Both "on next swing" attributes have identical handling in server & client +define('SPELL_ATTR0_WEARER_CASTS_PROC_TRIGGER', 0x00000800); // [WoWDev Wiki] Marker attribute to show auras that trigger another spell (either directly or with a script). +define('SPELL_ATTR0_DAYTIME_ONLY', 0x00001000); // Only usable during daytime (unused) +define('SPELL_ATTR0_NIGHT_ONLY', 0x00002000); // Only usable during nighttime (unused) +define('SPELL_ATTR0_INDOORS_ONLY', 0x00004000); // Only usable indoors +define('SPELL_ATTR0_OUTDOORS_ONLY', 0x00008000); // Only usable outdoors +define('SPELL_ATTR0_NOT_SHAPESHIFT', 0x00010000); // Not usable while shapeshifted +define('SPELL_ATTR0_ONLY_STEALTHED', 0x00020000); // Only usable in stealth +define('SPELL_ATTR0_DONT_AFFECT_SHEATH_STATE', 0x00040000); // Don't shealthe weapons (client only) +define('SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION', 0x00080000); // Scale with caster level DESCRIPTION For non-player casts, scale impact and power cost with caster's level +define('SPELL_ATTR0_STOP_ATTACK_TARGET', 0x00100000); // Stop attacking after cast DESCRIPTION After casting this, the current auto-attack will be interrupted +define('SPELL_ATTR0_IMPOSSIBLE_DODGE_PARRY_BLOCK', 0x00200000); // Prevent physical avoidance DESCRIPTION Spell cannot be dodged, parried or blocked +define('SPELL_ATTR0_CAST_TRACK_TARGET', 0x00400000); // Automatically face target during cast (client only) +define('SPELL_ATTR0_CASTABLE_WHILE_DEAD', 0x00800000); // Can be cast while dead DESCRIPTION Spells without this flag cannot be cast by dead units in non-triggered contexts +define('SPELL_ATTR0_CASTABLE_WHILE_MOUNTED', 0x01000000); // Can be cast while mounted +define('SPELL_ATTR0_DISABLED_WHILE_ACTIVE', 0x02000000); // Cooldown starts on expiry DESCRIPTION Spell is unusable while already active, and cooldown does not begin until the effects have worn off +define('SPELL_ATTR0_NEGATIVE_1', 0x04000000); // Is negative spell DESCRIPTION Forces the spell to be treated as a negative spell. Ex. Aura is shown in the debuff bar. +define('SPELL_ATTR0_CASTABLE_WHILE_SITTING', 0x08000000); // Can be cast while sitting +define('SPELL_ATTR0_CANT_USED_IN_COMBAT', 0x10000000); // Cannot be used in combat +define('SPELL_ATTR0_UNAFFECTED_BY_INVULNERABILITY', 0x20000000); // Pierce invulnerability DESCRIPTION Allows spell to pierce invulnerability, unless the invulnerability spell also has this attribute +define('SPELL_ATTR0_HEARTBEAT_RESIST_CHECK', 0x40000000); // Periodic resistance checks DESCRIPTION Periodically re-rolls against resistance to potentially expire aura early +define('SPELL_ATTR0_CANT_CANCEL', 0x80000000); // Aura cannot be cancelled DESCRIPTION Prevents the player from voluntarily canceling a positive aura -define('SPELL_ATTR6_DONT_DISPLAY_COOLDOWN', 0x00000001); // Don't display cooldown (client only) -define('SPELL_ATTR6_ONLY_IN_ARENA', 0x00000002); // Only usable in arena -define('SPELL_ATTR6_IGNORE_CASTER_AURAS', 0x00000004); // Ignore all preventing caster auras -define('SPELL_ATTR6_ASSIST_IGNORE_IMMUNE_FLAG', 0x00000008); // Ignore immunity flags when assisting -define('SPELL_ATTR6_UNK4', 0x00000010); // Unknown attribute 4@Attr6 -define('SPELL_ATTR6_DONT_CONSUME_PROC_CHARGES', 0x00000020); // Don't consume proc charges -define('SPELL_ATTR6_USE_SPELL_CAST_EVENT', 0x00000040); // Generate spell_cast event instead of aura_start (client only) -define('SPELL_ATTR6_UNK7', 0x00000080); // Unknown attribute 7@Attr6 -define('SPELL_ATTR6_CANT_TARGET_CROWD_CONTROLLED', 0x00000100); // Do not implicitly target in CC DESCRIPTION Implicit targeting (chaining and area targeting) will not impact crowd controlled targets -define('SPELL_ATTR6_UNK9', 0x00000200); // Unknown attribute 9@Attr6 -define('SPELL_ATTR6_CAN_TARGET_POSSESSED_FRIENDS', 0x00000400); // Can target possessed friends DESCRIPTION [NYI] -define('SPELL_ATTR6_NOT_IN_RAID_INSTANCE', 0x00000800); // Unusable in raid instances -define('SPELL_ATTR6_CASTABLE_WHILE_ON_VEHICLE', 0x00001000); // Castable while caster is on vehicle -define('SPELL_ATTR6_CAN_TARGET_INVISIBLE', 0x00002000); // Can target invisible units -define('SPELL_ATTR6_UNK14', 0x00004000); // Unknown attribute 14@Attr6 -define('SPELL_ATTR6_UNK15', 0x00008000); // Unknown attribute 15@Attr6 -define('SPELL_ATTR6_UNK16', 0x00010000); // Unknown attribute 16@Attr6 -define('SPELL_ATTR6_UNK17', 0x00020000); // Unknown attribute 17@Attr6 DESCRIPTION Mount related? -define('SPELL_ATTR6_CAST_BY_CHARMER', 0x00040000); // Spell is cast by charmer DESCRIPTION Client will prevent casting if not possessed, charmer will be caster for all intents and purposes -define('SPELL_ATTR6_UNK19', 0x00080000); // Unknown attribute 19@Attr6 -define('SPELL_ATTR6_ONLY_VISIBLE_TO_CASTER', 0x00100000); // Only visible to caster (client only) -define('SPELL_ATTR6_CLIENT_UI_TARGET_EFFECTS', 0x00200000); // Client UI target effects (client only) -define('SPELL_ATTR6_UNK22', 0x00400000); // Unknown attribute 22@Attr6 -define('SPELL_ATTR6_UNK23', 0x00800000); // Unknown attribute 23@Attr6 -define('SPELL_ATTR6_CAN_TARGET_UNTARGETABLE', 0x01000000); // Can target untargetable units -define('SPELL_ATTR6_NOT_RESET_SWING_IF_INSTANT', 0x02000000); // Do not reset swing timer if cast time is instant -define('SPELL_ATTR6_UNK26', 0x04000000); // Unknown attribute 26@Attr6 DESCRIPTION Player castable buff? -define('SPELL_ATTR6_LIMIT_PCT_HEALING_MODS', 0x08000000); // Limit applicable %healing modifiers DESCRIPTION This prevents certain healing modifiers from applying - see implementation if you really care about details -define('SPELL_ATTR6_UNK28', 0x10000000); // Unknown attribute 28@Attr6 DESCRIPTION Death grip? -define('SPELL_ATTR6_LIMIT_PCT_DAMAGE_MODS', 0x20000000); // Limit applicable %damage modifiers DESCRIPTION This prevents certain damage modifiers from applying - see implementation if you really care about details -define('SPELL_ATTR6_UNK30', 0x40000000); // Unknown attribute 30@Attr6 -define('SPELL_ATTR6_IGNORE_CATEGORY_COOLDOWN_MODS', 0x80000000); // Ignore cooldown modifiers for category cooldown +define('SPELL_ATTR1_DISMISS_PET', 0x00000001); // Dismiss Pet on cast DESCRIPTION Without this attribute, summoning spells will fail if caster already has a pet +define('SPELL_ATTR1_DRAIN_ALL_POWER', 0x00000002); // Drain all power DESCRIPTION Ignores listed power cost and drains entire pool instead +define('SPELL_ATTR1_CHANNELED_1', 0x00000004); // Channeled (type 1) DESCRIPTION Both "channeled" attributes have identical handling in server & client +define('SPELL_ATTR1_CANT_BE_REDIRECTED', 0x00000008); // Ignore redirection effects DESCRIPTION Spell will not be attracted by SPELL_MAGNET auras (Grounding Totem) - NOTE! WH interprets this flag as NO_REFLECTION +define('SPELL_ATTR1_NO_SKILL_INCREASE', 0x00000010); // [WoWDev Wiki] Does not give a skill up point. +define('SPELL_ATTR1_NOT_BREAK_STEALTH', 0x00000020); // Does not break stealth +define('SPELL_ATTR1_CHANNELED_2', 0x00000040); // Channeled (type 2) DESCRIPTION Both "channeled" attributes have identical handling in server & client +define('SPELL_ATTR1_CANT_BE_REFLECTED', 0x00000080); // Ignore reflection effects DESCRIPTION Spell will pierce through Spell Reflection and similar - NOTE! WH interprets this flag as ALL_EFFECTS_NEGATIVE +define('SPELL_ATTR1_CANT_TARGET_IN_COMBAT', 0x00000100); // Target cannot be in combat +define('SPELL_ATTR1_MELEE_COMBAT_START', 0x00000200); // Starts auto-attack (client only) DESCRIPTION Caster will begin auto-attacking the target on cast +define('SPELL_ATTR1_NO_THREAT', 0x00000400); // Does not generate threat DESCRIPTION Also does not cause target to engage +define('SPELL_ATTR1_DONT_REFRESH_DURATION_ON_RECAST', 0x00000800); // [WoWDev Wiki] Aura will not refresh it's duration when recast +define('SPELL_ATTR1_IS_PICKPOCKET', 0x00001000); // Pickpocket (client only) +define('SPELL_ATTR1_FARSIGHT', 0x00002000); // Farsight aura (client only) +define('SPELL_ATTR1_CHANNEL_TRACK_TARGET', 0x00004000); // Track target while channeling DESCRIPTION While channeling, adjust facing to face target +define('SPELL_ATTR1_DISPEL_AURAS_ON_IMMUNITY', 0x00008000); // Immunity cancels preapplied auras DESCRIPTION For immunity spells, cancel all auras that this spell would make you immune to when the spell is applied +define('SPELL_ATTR1_UNAFFECTED_BY_SCHOOL_IMMUNE', 0x00010000); // Unaffected by school immunities DESCRIPTION Will not pierce Divine Shield, Ice Block and other full invulnerabilities +define('SPELL_ATTR1_UNAUTOCASTABLE_BY_PET', 0x00020000); // Cannot be autocast by pet +define('SPELL_ATTR1_PREVENTS_ANIM', 0x00040000); // [WoWDev Wiki] Stun, Polymorph, Daze, Hex, etc. Auras apply "UNIT_FLAG_PREVENT_EMOTES_FROM_CHAT_TEXT". +define('SPELL_ATTR1_CANT_TARGET_SELF', 0x00080000); // Cannot be self-cast +define('SPELL_ATTR1_FINISHING_MOVE_DAMAGE', 0x00100000); // Requires combo points (type 1) - modifies effect amount +define('SPELL_ATTR1_THREAT_ONLY_ON_MISS', 0x00200000); // [WoWDev Wiki] Untested if this implies all functions listed under SpellMissInfo aside from Miss such as Parry, Dodge, Resist, etc. +define('SPELL_ATTR1_FINISHING_MOVE_DURATION', 0x00400000); // Requires combo points (type 2) - modifies effect duration +define('SPELL_ATTR1_IGNORE_OWNERS_DEATH', 0x00800000); // [WoWDev Wiki] Unaffected by death of owner. Possibly works with temporary summons as well? +define('SPELL_ATTR1_IS_FISHING', 0x01000000); // Fishing (client only) +define('SPELL_ATTR1_AURA_STAYS_AFTER_COMBAT', 0x02000000); // [WoWDev Wiki] +define('SPELL_ATTR1_REQUIRE_ALL_TARGETS', 0x04000000); // [WoWDev Wiki] Related to [target=focus] and [target=mouseover] macros? Used in many vehicle type spells. +define('SPELL_ATTR1_DISCOUNT_POWER_ON_MISS', 0x08000000); // [WoWDev Wiki] This attribute is almost exclusive with spells that consume combo-point-like secondary resources. +define('SPELL_ATTR1_DONT_DISPLAY_IN_AURA_BAR', 0x10000000); // Hide in aura bar (client only) +define('SPELL_ATTR1_CHANNEL_DISPLAY_SPELL_NAME', 0x20000000); // Show spell name during channel (client only) +define('SPELL_ATTR1_ENABLE_AT_DODGE', 0x40000000); // Enable at dodge +define('SPELL_ATTR1_CAST_WHEN_LEARNED', 0x80000000); // [WoWDev Wiki] Cast the spell when learned. -define('SPELL_ATTR7_UNK0', 0x00000001); // Unknown attribute 0@Attr7 -define('SPELL_ATTR7_IGNORE_DURATION_MODS', 0x00000002); // Ignore duration modifiers -define('SPELL_ATTR7_REACTIVATE_AT_RESURRECT', 0x00000004); // Reactivate at resurrect (client only) -define('SPELL_ATTR7_IS_CHEAT_SPELL', 0x00000008); // Is cheat spell DESCRIPTION Cannot cast if caster doesn't have UnitFlag2 & UNIT_FLAG2_ALLOW_CHEAT_SPELLS -define('SPELL_ATTR7_UNK4', 0x00000010); // Unknown attribute 4@Attr7 DESCRIPTION Soulstone related? -define('SPELL_ATTR7_SUMMON_PLAYER_TOTEM', 0x00000020); // Summons player-owned totem -define('SPELL_ATTR7_NO_PUSHBACK_ON_DAMAGE', 0x00000040); // Damage dealt by this does not cause spell pushback -define('SPELL_ATTR7_UNK7', 0x00000080); // Unknown attribute 7@Attr7 -define('SPELL_ATTR7_HORDE_ONLY', 0x00000100); // Horde only -define('SPELL_ATTR7_ALLIANCE_ONLY', 0x00000200); // Alliance only -define('SPELL_ATTR7_DISPEL_CHARGES', 0x00000400); // Dispel/Spellsteal remove individual charges -define('SPELL_ATTR7_INTERRUPT_ONLY_NONPLAYER', 0x00000800); // Only interrupt non-player casting -define('SPELL_ATTR7_UNK12', 0x00001000); // Unknown attribute 12@Attr7 -define('SPELL_ATTR7_UNK13', 0x00002000); // Unknown attribute 13@Attr7 -define('SPELL_ATTR7_UNK14', 0x00004000); // Unknown attribute 14@Attr7 -define('SPELL_ATTR7_UNK15', 0x00008000); // Unknown attribute 15@Attr7 DESCRIPTION Exorcism - guaranteed crit vs families? -define('SPELL_ATTR7_CAN_RESTORE_SECONDARY_POWER', 0x00010000); // Can restore secondary power DESCRIPTION Only spells with this attribute can replenish a non-active power type -define('SPELL_ATTR7_UNK17', 0x00020000); // Unknown attribute 17@Attr7 -define('SPELL_ATTR7_HAS_CHARGE_EFFECT', 0x00040000); // Has charge effect -define('SPELL_ATTR7_ZONE_TELEPORT', 0x00080000); // Is zone teleport -define('SPELL_ATTR7_UNK20', 0x00100000); // Unknown attribute 20@Attr7 DESCRIPTION Invulnerability related? -define('SPELL_ATTR7_UNK21', 0x00200000); // Unknown attribute 21@Attr7 -define('SPELL_ATTR7_IGNORE_COLD_WEATHER_FLYING', 0x00400000); // Ignore cold weather flying restriction DESCRIPTION Set for loaner mounts, allows them to be used despite lacking required flight skill -define('SPELL_ATTR7_UNK23', 0x00800000); // Unknown attribute 23@Attr7 -define('SPELL_ATTR7_UNK24', 0x01000000); // Unknown attribute 24@Attr7 -define('SPELL_ATTR7_UNK25', 0x02000000); // Unknown attribute 25@Attr7 -define('SPELL_ATTR7_UNK26', 0x04000000); // Unknown attribute 26@Attr7 -define('SPELL_ATTR7_UNK27', 0x08000000); // Unknown attribute 27@Attr7 -define('SPELL_ATTR7_CONSOLIDATED_RAID_BUFF', 0x10000000); // Consolidate in raid buff frame (client only) -define('SPELL_ATTR7_UNK29', 0x20000000); // Unknown attribute 29@Attr7 -define('SPELL_ATTR7_UNK30', 0x40000000); // Unknown attribute 30@Attr7 -define('SPELL_ATTR7_CLIENT_INDICATOR', 0x80000000); // Client indicator (client only) +define('SPELL_ATTR2_CAN_TARGET_DEAD', 0x00000001); // Can target dead players or corpses +define('SPELL_ATTR2_NO_SHAPESHIFT_UI', 0x00000002); // [WoWDev Wiki] No shapeshift UI such as Stealth, Shadowform, Druid shapeshifts, etc. Also certain custom scripted ones for quests or other various gameplay. +define('SPELL_ATTR2_CAN_TARGET_NOT_IN_LOS', 0x00000004); // Ignore Line of Sight +define('SPELL_ATTR2_ALLOW_LOW_LEVEL_BUFF', 0x00000008); // Allow Low Level Buff +define('SPELL_ATTR2_DISPLAY_IN_STANCE_BAR', 0x00000010); // Show in stance bar (client only) +define('SPELL_ATTR2_AUTOREPEAT_FLAG', 0x00000020); // Ranged auto-attack spell +define('SPELL_ATTR2_CANT_TARGET_TAPPED', 0x00000040); // Cannot target others' tapped units DESCRIPTION Can only target untapped units, or those tapped by caster +define('SPELL_ATTR2_DO_NOT_REPORT_SPELL_FAILURE', 0x00000080); // [WoWDev Wiki] Do not report spell failure. Combat log or error string related. +define('SPELL_ATTR2_INCLUDE_IN_ADVANCED_COMBAT_LOG', 0x00000100); // [WoWDev Wiki] Determines whether to include this aura in list of auras in SMSG_ENCOUNTER_START. +define('SPELL_ATTR2_ALWAYS_CAST_AS_UNIT', 0x00000200); // [WoWDev Wiki] Unclear what the differences of casting a spell in this way would do. +define('SPELL_ATTR2_SPECIAL_TAMING_FLAG', 0x00000400); // [WoWDev Wiki] +define('SPELL_ATTR2_HEALTH_FUNNEL', 0x00000800); // Health Funnel - NOTE! WH and leak data declare this attribute NO_TARGET_PER_SECOND_COSTS, but the per sec cost shows in tooltip and all associated spells have a per sec cost. +define('SPELL_ATTR2_CHAIN_FROM_CASTER', 0x00001000); // [WoWDev Wiki] Effectively a point blank AoE with the source as the caster but seems to only apply to melee abilities (Ex. Cleave, Heart Strike) +define('SPELL_ATTR2_PRESERVE_ENCHANT_IN_ARENA', 0x00002000); // Enchant persists when entering arena - NOTE! is ENCHANT_OWN_ITEM_ONLY in Attributes leak. Both names describe mostly the same thing. +define('SPELL_ATTR2_ALLOW_WHILE_INVISIBLE', 0x00004000); // [WoWDev Wiki] Allow spell to be used while invisible and the many different types of invisibility as well. - NOTE! Judging by flagged spells this makes no sense for 335. +define('SPELL_ATTR2_DO_NOT_CONSUME_IF_GAINED_DURING_CAST', 0x00008000); // [WoWDev Wiki] unused +define('SPELL_ATTR2_TAME_BEAST', 0x00010000); // Tame Beast - NOTE! NO_ACTIVE_PET in modern client, but descriptor is close enough +define('SPELL_ATTR2_NOT_RESET_AUTO_ACTIONS', 0x00020000); // Don't reset swing timer DESCRIPTION Does not reset melee/ranged autoattack timer on cast +define('SPELL_ATTR2_REQ_DEAD_PET', 0x00040000); // Requires dead pet - NOTE! both WH and leak data declare this attribute NO_JUMP_WHILE_CAST_PENDING .. whatever that means +define('SPELL_ATTR2_NOT_NEED_SHAPESHIFT', 0x00080000); // Also allow outside shapeshift DESCRIPTION Even if Stances are nonzero, allow spell to be cast outside of shapeshift (though not in a different shapeshift) +define('SPELL_ATTR2_INITIATE_COMBAT_POST_CAST_ENABLES_AUTO_ATTACK', 0x00100000); // [WoWDev Wiki] Enable auto-attacks after the first spell is cast when in combat. +define('SPELL_ATTR2_FAIL_ON_ALL_TARGETS_IMMUNE', 0x00200000); // Fail on all targets immune DESCRIPTION Causes BG flags to be dropped if combined with ATTR1_DISPEL_AURAS_ON_IMMUNITY +define('SPELL_ATTR2_NO_INITIAL_THREAT', 0x00400000); // [WoWDev Wiki] Can be found on several spells that deal damage and break stealth or are affected by a particular aura. +define('SPELL_ATTR2_IS_ARCANE_CONCENTRATION', 0x00800000); // Arcane Concentration - NOTE! both WH and leak data declare this attribute PROC_COOLDOWN_ON_FAILURE, but it only affects Arcane Concentration as set by TC +define('SPELL_ATTR2_ITEM_CAST_WITH_OWNER_SKILL', 0x01000000); // [WoWDev Wiki] +define('SPELL_ATTR2_DONT_BLOCK_MANA_REGEN', 0x02000000); // [WoWDev Wiki] Mana regeneration is not affected. +define('SPELL_ATTR2_UNAFFECTED_BY_AURA_SCHOOL_IMMUNE', 0x04000000); // Pierce aura application immunities DESCRIPTION Allow aura to be applied despite target being immune to new aura applications +define('SPELL_ATTR2_IGNORE_WEAPONSKILL', 0x08000000); // [WoWDev Wiki] Ignore skill level of a weapon. +define('SPELL_ATTR2_NOT_AN_ACTION', 0x10000000); // [WoWDev Wiki] Unsure if anything besides spells and object interactions constitute an "action". +define('SPELL_ATTR2_CANT_CRIT', 0x20000000); // Cannot critically strike +define('SPELL_ATTR2_ACTIVE_THREAT', 0x40000000); // Active Threat +define('SPELL_ATTR2_FOOD_BUFF', 0x80000000); // Food buff (client only) - NOTE! both WH and leak data declare this attribute RETAIN_ITEM_CAST .. unknown what that means + +define('SPELL_ATTR3_PVP_ENABLING', 0x00000001); // [WoWDev Wiki] Enables the PvP state when cast. +define('SPELL_ATTR3_IGNORE_PROC_SUBCLASS_MASK', 0x00000002); // Ignores subclass mask check when checking proc +define('SPELL_ATTR3_NO_CASTING_BAR_TEXT', 0x00000004); // [WoWDev Wiki] No casting bar text. +define('SPELL_ATTR3_COMPLETELY_BLOCKED', 0x00000008); // Blockable spell +define('SPELL_ATTR3_IGNORE_RESURRECTION_TIMER', 0x00000010); // Ignore resurrection timer +define('SPELL_ATTR3_NO_DURABILTIY_LOSS', 0x00000020); // [WoWDev Wiki] +define('SPELL_ATTR3_NO_AVOIDANCE', 0x00000040); // [WoWDev Wiki] Self descriptive. No AoE reduction modifiers will be calculated. +define('SPELL_ATTR3_STACK_FOR_DIFF_CASTERS', 0x00000080); // Stack separately for each caster +define('SPELL_ATTR3_ONLY_TARGET_PLAYERS', 0x00000100); // Can only target players +define('SPELL_ATTR3_NOT_A_PROC', 0x00000200); // Not a Proc DESCRIPTION Without this attribute, any triggered spell will be unable to trigger other auras' procs +define('SPELL_ATTR3_MAIN_HAND', 0x00000400); // Require main hand weapon +define('SPELL_ATTR3_BATTLEGROUND', 0x00000800); // Can only be cast in battleground +define('SPELL_ATTR3_ONLY_TARGET_GHOSTS', 0x00001000); // Can only target ghost players +define('SPELL_ATTR3_DONT_DISPLAY_CHANNEL_BAR', 0x00002000); // Do not display channel bar (client only) +define('SPELL_ATTR3_IS_HONORLESS_TARGET', 0x00004000); // Honorless Target - NOTE! HIDE_IN_RAID_FILTER in modern client. Attribute only present on Honorless Target buff. +define('SPELL_ATTR3_NORMAL_RANGED_ATTACK', 0x00008000); // [WoWDev Wiki] Auto Shoot, Shoot, Throw (Autoshot flag). +define('SPELL_ATTR3_CANT_TRIGGER_PROC', 0x00010000); // Cannot trigger procs +define('SPELL_ATTR3_NO_INITIAL_AGGRO', 0x00020000); // No initial aggro - [WoWDev Wiki] SPELL_ATTR3_SUPPRESS_TARGET_PROCS: This will suppress any procs the target could trigger from this spell. Similar to SPELL_ATTR3_SUPPRESS_CASTER_PROCS (0x00010000) +define('SPELL_ATTR3_IGNORE_HIT_RESULT', 0x00040000); // Ignore hit result DESCRIPTION Spell cannot miss, or be dodged/parried/blocked +define('SPELL_ATTR3_DISABLE_PROC', 0x00080000); // Cannot trigger spells during aura proc - NOTE! both WH and the leak data name this INSTANT_TARGET_PROCS .. sooo the opposite? why..? +define('SPELL_ATTR3_DEATH_PERSISTENT', 0x00100000); // Persists through death +define('SPELL_ATTR3_ONLY_PROC_OUTDOORS', 0x00200000); // [WoWDev Wiki] unused +define('SPELL_ATTR3_REQ_WAND', 0x00400000); // Requires equipped Wand +define('SPELL_ATTR3_NO_DAMAGE_HISTORY', 0x00800000); // [WoWDev Wiki] Possible combat log or scripting relation. +define('SPELL_ATTR3_REQ_OFFHAND', 0x01000000); // Requires offhand weapon +define('SPELL_ATTR3_TREAT_AS_PERIODIC', 0x02000000); // Treat as periodic effect +define('SPELL_ATTR3_CAN_PROC_FROM_PROCS', 0x04000000); // Can Proc From Procs +define('SPELL_ATTR3_DRAIN_SOUL', 0x08000000); // Drain Soul +define('SPELL_ATTR3_IGNORE_CASTER_AND_TARGET_RESTRICTIONS', 0x10000000); // [WoWDev Wiki] Ignore caster and target restrictions. - NOTE! WH handles this attribute as 'does not appear in log' like SPELL_ATTR0_HIDE_IN_COMBAT_LOG which it handles as 'Cast time is hidden' +define('SPELL_ATTR3_NO_DONE_BONUS', 0x20000000); // Damage dealt is unaffected by modifiers +define('SPELL_ATTR3_DONT_DISPLAY_RANGE', 0x40000000); // Do not show range in tooltip (client only) +define('SPELL_ATTR3_NOT_ON_AOE_IMMUNE', 0x80000000); // [WoWDev Wiki] A descriptor for spells that implement Area of Effect Immunity and can serve as a handler for scripts that call for this. + +define('SPELL_ATTR4_IGNORE_RESISTANCES', 0x00000001); // Cannot be resisted - NOTE! WH correctly handles this as NO_CAST_LOG and spells with this attribute do not show an "[Entity] casts [spell] at [target]" message n combat log +define('SPELL_ATTR4_PROC_ONLY_ON_CASTER', 0x00000002); // Only proc on self-cast - NOTE! also named CLASS_TRIGGER_ONLY_ON_TARGET +define('SPELL_ATTR4_FADES_WHILE_LOGGED_OUT', 0x00000004); // Buff expires while offline DESCRIPTION Debuffs (except Resurrection Sickness) will automatically do this +define('SPELL_ATTR4_NO_HELPFUL_THREAT', 0x00000008); // [WoWDev Wiki] +define('SPELL_ATTR4_NO_HARMFUL_THREAT', 0x00000010); // [WoWDev Wiki] May influence certain situations in towns with guard aggro in respect to PvP. +define('SPELL_ATTR4_ALLOW_CLIENT_TARGETING', 0x00000020); // [WoWDev Wiki] Allow client targeting. Applies only to pet spells, if this is not applied then opcode CMSG_PET_ACTION is sent instead of CMSG_PET_CAST_SPELL. +define('SPELL_ATTR4_NOT_STEALABLE', 0x00000040); // Aura cannot be stolen +define('SPELL_ATTR4_CAN_CAST_WHILE_CASTING', 0x00000080); // Can be cast while casting DESCRIPTION Ignores already in-progress cast and still casts +define('SPELL_ATTR4_FIXED_DAMAGE', 0x00000100); // Deals fixed damage +define('SPELL_ATTR4_TRIGGER_ACTIVATE', 0x00000200); // Spell is initially disabled (client only) +define('SPELL_ATTR4_SPELL_VS_EXTEND_COST', 0x00000400); // Attack speed modifies cost DESCRIPTION Adds 10 to power cost for each 1s of weapon speed +define('SPELL_ATTR4_NO_PARTIAL_IMMUNITY', 0x00000800); // [WoWDev Wiki] +define('SPELL_ATTR4_AURA_IS_BUFF', 0x00001000); // [WoWDev Wiki] Mostly applied to spells that would result in such spell showing as a debuff. +define('SPELL_ATTR4_DO_NOT_LOG_CASTER', 0x00002000); // [WoWDev Wiki] No caster object is sent to client combat log. +define('SPELL_ATTR4_DAMAGE_DOESNT_BREAK_AURAS', 0x00004000); // Damage does not break auras - NOTE! also named REACTIVE_DAMAGE_PROC +define('SPELL_ATTR4_NOT_IN_SPELLBOOK', 0x00008000); // [WoWDev Wiki] +define('SPELL_ATTR4_NOT_USABLE_IN_ARENA', 0x00010000); // Not usable in arena DESCRIPTION Makes spell unusable despite CD <= 10min +define('SPELL_ATTR4_USABLE_IN_ARENA', 0x00020000); // Usable in arena DESCRIPTION Makes spell usable despite CD > 10min +define('SPELL_ATTR4_AREA_TARGET_CHAIN', 0x00040000); // Chain area targets DESCRIPTION [NYI] Hits area targets over time instead of all at once +define('SPELL_ATTR4_ALLOW_PROC_WHILE_SITTING', 0x00080000); // [WoWDev Wiki] +define('SPELL_ATTR4_NOT_CHECK_SELFCAST_POWER', 0x00100000); // Allow self-cast to override stronger aura (client only) - NOTE! modern name AURA_NEVER_BOUNCES (similar meaning) +define('SPELL_ATTR4_DONT_REMOVE_IN_ARENA', 0x00200000); // Keep when entering arena +define('SPELL_ATTR4_PROC_SUPPRESS_SWING_ANIM', 0x00400000); // [WoWDev Wiki] Disables client side weapon swing animation. +define('SPELL_ATTR4_CANT_TRIGGER_ITEM_SPELLS', 0x00800000); // Cannot trigger item spells +define('SPELL_ATTR4_AUTO_RANGED_COMBAT', 0x01000000); // [WoWDev Wiki] +define('SPELL_ATTR4_IS_PET_SCALING', 0x02000000); // Pet Scaling aura +define('SPELL_ATTR4_CAST_ONLY_IN_OUTLAND', 0x04000000); // Only in Outland/Northrend - NOTE! modern client name is ONLY_FLYING_AREAS (similar, more correct), WH is "Allow Equip While Casting", (wtf, seriously) +define('SPELL_ATTR4_FORCE_DISPLAY_CASTBAR', 0x08000000); // +define('SPELL_ATTR4_IGNORE_COMBAT_TIMER', 0x10000000); // [WoWDev Wiki] +define('SPELL_ATTR4_AURA_BOUNCE_FAILS_SPELL', 0x20000000); // [WoWDev Wiki] +define('SPELL_ATTR4_OBSOLETE', 0x40000000); // [WoWDev Wiki] Deprecates the spell making it greyed out and gives "You can't use that here" error. Still usable with the triggered flag command though. +define('SPELL_ATTR4_USE_FACING_FROM_SPELL', 0x80000000); // [WoWDev Wiki] Affects orientation. The value used is likely related to FacingCasterFlags in Spell.dbc for 3.3.5. + +define('SPELL_ATTR5_CAN_CHANNEL_WHEN_MOVING', 0x00000001); // Can be channeled while moving +define('SPELL_ATTR5_NO_REAGENT_WHILE_PREP', 0x00000002); // No reagents during arena preparation +define('SPELL_ATTR5_REMOVE_ON_ARENA_ENTER', 0x00000004); // Remove when entering arena DESCRIPTION Force this aura to be removed on entering arena, regardless of other properties +define('SPELL_ATTR5_USABLE_WHILE_STUNNED', 0x00000008); // Usable while stunned +define('SPELL_ATTR5_TRIGGERS_CHANNELING', 0x00000010); // [WoWDev Wiki] Likely more script oriented. +define('SPELL_ATTR5_SINGLE_TARGET_SPELL', 0x00000020); // Single-target aura DESCRIPTION Remove previous application to another unit if applied +define('SPELL_ATTR5_IGNORE_AREA_EFFECT_PVP_CHECK', 0x00000040); // [WoWDev Wiki] Possible world PvP flag for objectives such as Spirit Towers? +define('SPELL_ATTR5_NOT_ON_PLAYER', 0x00000080); // [WoWDev Wiki] Opposite of SPELL_ATTR3_ONLY_TARGET_PLAYERS +define('SPELL_ATTR5_CANT_TARGET_PLAYER_CONTROLLED', 0x00000100); // Cannot target player controlled units but can target players +define('SPELL_ATTR5_START_PERIODIC_AT_APPLY', 0x00000200); // Immediately do periodic tick on apply +define('SPELL_ATTR5_HIDE_DURATION', 0x00000400); // Do not send aura duration to client +define('SPELL_ATTR5_ALLOW_TARGET_OF_TARGET_AS_TARGET', 0x00000800); // Auto-target target of target (client only) +define('SPELL_ATTR5_MELEE_CHAIN_TARGETING', 0x00001000); // [WoWDev Wiki] Cleave related? +define('SPELL_ATTR5_HASTE_AFFECT_DURATION', 0x00002000); // Duration scales with Haste Rating +define('SPELL_ATTR5_NOT_USABLE_WHILE_CHARMED', 0x00004000); // Charmed units cannot cast this spell +define('SPELL_ATTR5_TREAT_AS_AREA_EFFECT', 0x00008000); // [WoWDev Wiki] Related to multi-target spells? +define('SPELL_ATTR5_AURA_AFFECTS_NOT_JUST_REQ_EQUIPPED_ITEM', 0x00010000); // [WoWDev Wiki] +define('SPELL_ATTR5_USABLE_WHILE_FEARED', 0x00020000); // Usable while feared +define('SPELL_ATTR5_USABLE_WHILE_CONFUSED', 0x00040000); // Usable while confused +define('SPELL_ATTR5_DONT_TURN_DURING_CAST', 0x00080000); // Do not auto-turn while casting +define('SPELL_ATTR5_DO_NOT_ATTEMPT_A_PET_RESUMMON_WHEN_DISMOUNTING', 0x00100000); // [WoWDev Wiki] +define('SPELL_ATTR5_IGNORE_TARGET_REQUIREMENTS', 0x00200000); // [WoWDev Wiki] +define('SPELL_ATTR5_NOT_ON_TRIVIAL', 0x00400000); // [WoWDev Wiki] +define('SPELL_ATTR5_NO_PARTIAL_RESISTS', 0x00800000); // [WoWDev Wiki] Spell will either be fully resisted or deal the full amount of damage. +define('SPELL_ATTR5_IGNORE_CASTER_REQUIREMENTS', 0x01000000); // [WoWDev Wiki] +define('SPELL_ATTR5_ALWAYS_LINE_OF_SIGHT', 0x02000000); // [WoWDev Wiki] Constant line of sight required for spell duration. +define('SPELL_ATTR5_SKIP_CHECKCAST_LOS_CHECK', 0x04000000); // Ignore line of sight checks +define('SPELL_ATTR5_DONT_SHOW_AURA_IF_SELF_CAST', 0x08000000); // Don't show aura if self-cast (client only) +define('SPELL_ATTR5_DONT_SHOW_AURA_IF_NOT_SELF_CAST', 0x10000000); // Don't show aura unless self-cast (client only) +define('SPELL_ATTR5_AURA_UNIQUE_PER_CASTER', 0x20000000); // [WoWDev Wiki] Could be used for debuff grouping. +define('SPELL_ATTR5_ALWAYS_SHOW_GROUND_TEXTURE', 0x40000000); // [WoWDev Wiki] Likely refers to the Projected Texture setting and will cause this spell to ignore its value. +define('SPELL_ATTR5_ADD_MELEE_HIT_RATING', 0x80000000); // [WoWDev Wiki] (Forces nearby enemies to attack caster?) + +define('SPELL_ATTR6_DONT_DISPLAY_COOLDOWN', 0x00000001); // Don't display cooldown (client only) +define('SPELL_ATTR6_ONLY_IN_ARENA', 0x00000002); // Only usable in arena +define('SPELL_ATTR6_IGNORE_CASTER_AURAS', 0x00000004); // Ignore all preventing caster auras - NOTE! leak Data and WH name this NOT_AN_ATTACK +define('SPELL_ATTR6_ASSIST_IGNORE_IMMUNE_FLAG', 0x00000008); // Ignore immunity flags when assisting +define('SPELL_ATTR6_IGNORE_FOR_MOD_TIME_RATE', 0x00000010); // [WoWDev Wiki] +define('SPELL_ATTR6_DONT_CONSUME_PROC_CHARGES', 0x00000020); // Don't consume proc charges +define('SPELL_ATTR6_USE_SPELL_CAST_EVENT', 0x00000040); // Generate spell_cast event instead of aura_start (client only) - NOTE! FLOATING_COMBAT_TEXT_ON_CAST in modern client, but visual UI procs are not in 335 +define('SPELL_ATTR6_AURA_IS_WEAPON_PROC', 0x00000080); // [WoWDev Wiki] +define('SPELL_ATTR6_CANT_TARGET_CROWD_CONTROLLED', 0x00000100); // Do not implicitly target in CC DESCRIPTION Implicit targeting (chaining and area targeting) will not impact crowd controlled targets +define('SPELL_ATTR6_ALLOW_ON_CHARMED_TARGETS', 0x00000200); // [WoWDev Wiki] +define('SPELL_ATTR6_CAN_TARGET_POSSESSED_FRIENDS', 0x00000400); // Can target possessed friends DESCRIPTION [NYI] - NOTE! leak data and WH name this NO_AURA_LOG and it really prevents aura apply/remove messages in combat log +define('SPELL_ATTR6_NOT_IN_RAID_INSTANCE', 0x00000800); // Unusable in raid instances +define('SPELL_ATTR6_CASTABLE_WHILE_ON_VEHICLE', 0x00001000); // Castable while caster is on vehicle +define('SPELL_ATTR6_CAN_TARGET_INVISIBLE', 0x00002000); // Can target invisible units +define('SPELL_ATTR6_AI_PRIMARY_RANGED_ATTACK', 0x00004000); // [WoWDev Wiki] Related to Shoot? Needs description. +define('SPELL_ATTR6_NO_PUSHBACK', 0x00008000); // [WoWDev Wiki] +define('SPELL_ATTR6_NO_JUMP_PATHING', 0x00010000); // [WoWDev Wiki] +define('SPELL_ATTR6_ALLOW_EQUIP_WHILE_CASTING', 0x00020000); // [WoWDev Wiki] Mount related? +define('SPELL_ATTR6_CAST_BY_CHARMER', 0x00040000); // Spell is cast by charmer DESCRIPTION Client will prevent casting if not possessed, charmer will be caster for all intents and purposes +define('SPELL_ATTR6_DELAY_COMBAT_TIMER_DURING_CAST', 0x00080000); // [WoWDev Wiki] +define('SPELL_ATTR6_ONLY_VISIBLE_TO_CASTER', 0x00100000); // Only visible to caster (client only) +define('SPELL_ATTR6_CLIENT_UI_TARGET_EFFECTS', 0x00200000); // Client UI target effects (client only) - NOTE! SHOW_MECHANIC_AS_COMBAT_TEXT in modern client .. neither descriptor seems to be true +define('SPELL_ATTR6_ABSORB_CANNOT_BE_IGNORE', 0x00400000); // [WoWDev Wiki] +define('SPELL_ATTR6_TAPS_IMMEDIATELY', 0x00800000); // [WoWDev Wiki] +define('SPELL_ATTR6_CAN_TARGET_UNTARGETABLE', 0x01000000); // Can target untargetable units +define('SPELL_ATTR6_NOT_RESET_SWING_IF_INSTANT', 0x02000000); // Do not reset swing timer if cast time is instant +define('SPELL_ATTR6_VEHICLE_IMMUNITY_CATEGORY', 0x04000000); // [WoWDev Wiki] immunity to some buffs for some vehicles. +define('SPELL_ATTR6_LIMIT_PCT_HEALING_MODS', 0x08000000); // Limit applicable %healing modifiers DESCRIPTION This prevents certain healing modifiers from applying - see implementation if you really care about details +define('SPELL_ATTR6_DO_NOT_AUTO_SELECT_TARGET_WITH_INITIATES_COMBAT', 0x10000000); // [WoWDev Wiki] Death grip? +define('SPELL_ATTR6_LIMIT_PCT_DAMAGE_MODS', 0x20000000); // Limit applicable %damage modifiers DESCRIPTION This prevents certain damage modifiers from applying - see implementation if you really care about details +define('SPELL_ATTR6_DISABLE_TIED_EFFECT_POINTS', 0x40000000); // [WoWDev Wiki] The value used is likely from the SpellEffect column EffectBasePoints +define('SPELL_ATTR6_IGNORE_CATEGORY_COOLDOWN_MODS', 0x80000000); // Ignore cooldown modifiers for category cooldown + +define('SPELL_ATTR7_ALLOW_SPELL_REFLECTION', 0x00000001); // [WoWDev Wiki] Allow spell to be reflected. Will likely interfere if used with SPELL_ATTR1_CANT_BE_REFLECTED. +define('SPELL_ATTR7_IGNORE_DURATION_MODS', 0x00000002); // Ignore duration modifiers +define('SPELL_ATTR7_DISABLE_AURA_WHILE_DEAD', 0x00000004); // Reactivate at resurrect (client only) +define('SPELL_ATTR7_IS_CHEAT_SPELL', 0x00000008); // Is cheat spell DESCRIPTION Cannot cast if caster doesn't have UnitFlag2 & UNIT_FLAG2_ALLOW_CHEAT_SPELLS +define('SPELL_ATTR7_TREAT_AS_RAID_BUFF', 0x00000010); // [WoWDev Wiki] Spell assumes certain properties that would classify it as a "raid buff". (This is only a guess.) +define('SPELL_ATTR7_SUMMON_PLAYER_TOTEM', 0x00000020); // Summons player-owned totem +define('SPELL_ATTR7_NO_PUSHBACK_ON_DAMAGE', 0x00000040); // Damage dealt by this does not cause spell pushback +define('SPELL_ATTR7_PREPARE_FOR_VEHICLE_CONTROL_END', 0x00000080); // [WoWDev Wiki] Attribute is most likely server side only. +define('SPELL_ATTR7_HORDE_ONLY', 0x00000100); // Horde only +define('SPELL_ATTR7_ALLIANCE_ONLY', 0x00000200); // Alliance only +define('SPELL_ATTR7_DISPEL_CHARGES', 0x00000400); // Dispel/Spellsteal remove individual charges +define('SPELL_ATTR7_INTERRUPT_ONLY_NONPLAYER', 0x00000800); // Only interrupt non-player casting +define('SPELL_ATTR7_CAN_CAUSE_SILENCE', 0x00001000); // [WoWDev Wiki] Will only Silence NPCs/creatures. (Not confirmed.) +define('SPELL_ATTR7_NO_UI_NOT_INTERRUPTIBLE', 0x00002000); // [WoWDev Wiki] Can always be interrupted, even if caster is immune. +define('SPELL_ATTR7_RECAST_ON_RESUMMON', 0x00004000); // [WoWDev Wiki] only on 52150 Raise Dead. +define('SPELL_ATTR7_RESET_SWING_TIMER_AT_SPELL_START', 0x00008000); // [WoWDev Wiki] (Exorcism - guaranteed crit vs families?) +define('SPELL_ATTR7_CAN_RESTORE_SECONDARY_POWER', 0x00010000); // Can restore secondary power DESCRIPTION Only spells with this attribute can replenish a non-active power type - NOTE! replaed with ONLY_IN_SPELLBOOK_UNTIL_LEARNED in modern client +define('SPELL_ATTR7_DO_NOT_LOG_PVP_KILL', 0x00020000); // [WoWDev Wiki] +define('SPELL_ATTR7_HAS_CHARGE_EFFECT', 0x00040000); // Has charge effect +define('SPELL_ATTR7_ZONE_TELEPORT', 0x00080000); // Is zone teleport - NOTE! REPORT_SPELL_FAILURE_TO_UNIT_TARGET in modern client, but may still serve the same purpose as teleport spell ofter use custom error messages +define('SPELL_ATTR7_NO_CLIENT_FAIL_WHILE_STUNNED_FLEEING_CONFUSED', 0x00100000); // [WoWDev Wiki] Client will skip or bypass checking for stunned, fleeing, and confused states. +define('SPELL_ATTR7_RETAIN_COOLDOWN_THROUGH_LOAD', 0x00200000); // [WoWDev Wiki] +define('SPELL_ATTR7_IGNORE_COLD_WEATHER_FLYING', 0x00400000); // Ignore cold weather flying restriction DESCRIPTION Set for loaner mounts, allows them to be used despite lacking required flight skill +define('SPELL_ATTR7_CANT_DODGE', 0x00800000); // Spell cannot be dodged +define('SPELL_ATTR7_CANT_PARRY', 0x01000000); // Spell cannot be parried +define('SPELL_ATTR7_CANT_MISS', 0x02000000); // Spell cannot be missed +define('SPELL_ATTR7_TREAT_AS_NPC_AOE', 0x04000000); // [WoWDev Wiki] +define('SPELL_ATTR7_BYPASS_NO_RESURRECT_AURA', 0x08000000); // Bypasses the prevent resurrection aura +define('SPELL_ATTR7_CONSOLIDATED_RAID_BUFF', 0x10000000); // Consolidate in raid buff frame (client only) +define('SPELL_ATTR7_REFLECTION_ONLY_DEFENDS', 0x20000000); // [WoWDev Wiki] This possibly allows for a spell to be reflected but not damage the target and instead act more as a deflect. +define('SPELL_ATTR7_CAN_PROC_FROM_SUPPRESSED_TARGET_PROCS', 0x40000000); // [WoWDev Wiki] +define('SPELL_ATTR7_CLIENT_INDICATOR', 0x80000000); // Client indicator (client only) // (some) Skill ids +define('SKILL_FIRST_AID', 129); define('SKILL_BLACKSMITHING', 164); define('SKILL_LEATHERWORKING', 165); define('SKILL_ALCHEMY', 171); define('SKILL_HERBALISM', 182); +define('SKILL_COOKING', 185); define('SKILL_MINING', 186); define('SKILL_TAILORING', 197); define('SKILL_ENGINEERING', 202); define('SKILL_ENCHANTING', 333); +define('SKILL_FISHING', 356); define('SKILL_SKINNING', 393); -define('SKILL_JEWELCRAFTING', 755); -define('SKILL_INSCRIPTION', 773); define('SKILL_LOCKPICKING', 633); +define('SKILL_JEWELCRAFTING', 755); +define('SKILL_RIDING', 762); +define('SKILL_INSCRIPTION', 773); +define('SKILL_MOUNTS', 777); +define('SKILL_COMPANIONS', 778); +define('SKILLS_TRADE_PRIMARY', [SKILL_BLACKSMITHING, SKILL_LEATHERWORKING, SKILL_ALCHEMY, SKILL_HERBALISM, SKILL_MINING, SKILL_TAILORING, SKILL_ENGINEERING, SKILL_ENCHANTING, SKILL_SKINNING, SKILL_JEWELCRAFTING, SKILL_INSCRIPTION]); +define('SKILLS_TRADE_SECONDARY', [SKILL_FIRST_AID, SKILL_COOKING, SKILL_FISHING, SKILL_RIDING]); + +// (some) key currencies +define('CURRENCY_ARENA_POINTS', 103); +define('CURRENCY_HONOR_POINTS', 104); // AchievementCriteriaCondition define('ACHIEVEMENT_CRITERIA_CONDITION_NO_DEATH', 1); // reset progress on death @@ -1142,11 +1890,13 @@ define('ACHIEVEMENT_CRITERIA_CONDITION_NOT_IN_GROUP', 10); define('ACHIEVEMENT_FLAG_COUNTER', 0x0001); // Just count statistic (never stop and complete) define('ACHIEVEMENT_FLAG_HIDDEN', 0x0002); // Not sent to client - internal use only define('ACHIEVEMENT_FLAG_STORE_MAX_VALUE', 0x0004); // Store only max value? used only in "Reach level xx" -define('ACHIEVEMENT_FLAG_SUMM', 0x0008); // Use summ criteria value from all reqirements (and calculate max value) +define('ACHIEVEMENT_FLAG_SUM', 0x0008); // Use sum criteria value from all reqirements (and calculate max value) define('ACHIEVEMENT_FLAG_MAX_USED', 0x0010); // Show max criteria (and calculate max value ??) define('ACHIEVEMENT_FLAG_REQ_COUNT', 0x0020); // Use not zero req count (and calculate max value) define('ACHIEVEMENT_FLAG_AVERAGE', 0x0040); // Show as average value (value / time_in_days) depend from other flag (by def use last criteria value) -define('ACHIEVEMENT_FLAG_BAR', 0x0080); // Show as progress bar (value / max vale) depend from other flag (by def use last criteria value) +define('ACHIEVEMENT_FLAG_PROGRESS_BAR', 0x0080); // Show as progress bar (value / max vale) depend from other flag (by def use last criteria value) +define('ACHIEVEMENT_FLAG_REALM_FIRST', 0x0100); // first max race/class/profession +define('ACHIEVEMENT_FLAG_REALM_FIRST_KILL', 0x0200); // first boss kill // AchievementCriteriaFlags define('ACHIEVEMENT_CRITERIA_FLAG_SHOW_PROGRESS_BAR', 0x0001); // Show progress as bar @@ -1216,7 +1966,7 @@ define('ACHIEVEMENT_CRITERIA_TYPE_USE_GAMEOBJECT', 68); define('ACHIEVEMENT_CRITERIA_TYPE_BE_SPELL_TARGET2', 69); // define('ACHIEVEMENT_CRITERIA_TYPE_SPECIAL_PVP_KILL', 70); define('ACHIEVEMENT_CRITERIA_TYPE_FISH_IN_GAMEOBJECT', 72); -define('ACHIEVEMENT_CRITERIA_TYPE_EARNED_PVP_TITLE', 74); +define('ACHIEVEMENT_CRITERIA_TYPE_ON_LOGIN', 74); define('ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILLLINE_SPELLS', 75); // define('ACHIEVEMENT_CRITERIA_TYPE_WIN_DUEL', 76); // define('ACHIEVEMENT_CRITERIA_TYPE_LOSE_DUEL', 77); @@ -1256,404 +2006,40 @@ define('ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILL_LINE', 112); // define('ACHIEVEMENT_CRITERIA_TYPE_DISENCHANT_ROLLS', 117); // define('ACHIEVEMENT_CRITERIA_TYPE_USE_LFD_TO_GROUP_WITH_PLAYERS', 119); -// TrinityCore - Condition System -define('CND_SRC_CREATURE_LOOT_TEMPLATE', 1); -define('CND_SRC_DISENCHANT_LOOT_TEMPLATE', 2); -define('CND_SRC_FISHING_LOOT_TEMPLATE', 3); -define('CND_SRC_GAMEOBJECT_LOOT_TEMPLATE', 4); -define('CND_SRC_ITEM_LOOT_TEMPLATE', 5); -define('CND_SRC_MAIL_LOOT_TEMPLATE', 6); -define('CND_SRC_MILLING_LOOT_TEMPLATE', 7); -define('CND_SRC_PICKPOCKETING_LOOT_TEMPLATE', 8); -define('CND_SRC_PROSPECTING_LOOT_TEMPLATE', 9); -define('CND_SRC_REFERENCE_LOOT_TEMPLATE', 10); -define('CND_SRC_SKINNING_LOOT_TEMPLATE', 11); -define('CND_SRC_SPELL_LOOT_TEMPLATE', 12); -define('CND_SRC_SPELL_IMPLICIT_TARGET', 13); -define('CND_SRC_GOSSIP_MENU', 14); -define('CND_SRC_GOSSIP_MENU_OPTION', 15); -define('CND_SRC_CREATURE_TEMPLATE_VEHICLE', 16); -define('CND_SRC_SPELL', 17); -define('CND_SRC_SPELL_CLICK_EVENT', 18); -define('CND_SRC_QUEST_ACCEPT', 19); -define('CND_SRC_QUEST_SHOW_MARK', 20); -define('CND_SRC_VEHICLE_SPELL', 21); -define('CND_SRC_SMART_EVENT', 22); -define('CND_SRC_NPC_VENDOR', 23); -define('CND_SRC_SPELL_PROC', 24); +// TrinityCore - Achievement Criteria Data +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_NONE', 0); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_CREATURE', 1); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_PLAYER_CLASS_RACE', 2); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_PLAYER_LESS_HEALTH', 3); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_PLAYER_DEAD', 4); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_AURA', 5); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_AREA', 6); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_AURA', 7); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_VALUE', 8); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_LEVEL', 9); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_GENDER', 10); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_SCRIPT', 11); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_MAP_DIFFICULTY', 12); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_MAP_PLAYER_COUNT', 13); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_T_TEAM', 14); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_DRUNK', 15); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_HOLIDAY', 16); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_BG_LOSS_TEAM_SCORE', 17); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_INSTANCE_SCRIPT', 18); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_EQUIPED_ITEM', 19); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_MAP_ID', 20); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_PLAYER_CLASS_RACE', 21); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_NTH_BIRTHDAY', 22); +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_KNOWN_TITLE', 23); +// define('ACHIEVEMENT_CRITERIA_DATA_TYPE_GAME_EVENT', 24); // not in 3.3.5a +define('ACHIEVEMENT_CRITERIA_DATA_TYPE_S_ITEM_QUALITY', 25); -define('CND_AURA', 1); // aura is applied: spellId, UNUSED, NULL -define('CND_ITEM', 2); // owns item: itemId, count, UNUSED -define('CND_ITEM_EQUIPPED', 3); // has item equipped: itemId, NULL, NULL -define('CND_ZONEID', 4); // is in zone: areaId, NULL, NULL -define('CND_REPUTATION_RANK', 5); // reputation status: factionId, rankMask, NULL -define('CND_TEAM', 6); // is on team: teamId, NULL, NULL -define('CND_SKILL', 7); // has skill: skillId, value, NULL -define('CND_QUESTREWARDED', 8); // has finished quest: questId, NULL, NULL -define('CND_QUESTTAKEN', 9); // has accepted quest: questId, NULL, NULL -define('CND_DRUNKENSTATE', 10); // has drunken status: stateId, NULL, NULL -define('CND_WORLD_STATE', 11); -define('CND_ACTIVE_EVENT', 12); // world event is active: eventId, NULL, NULL -define('CND_INSTANCE_INFO', 13); -define('CND_QUEST_NONE', 14); // never seen quest: questId, NULL, NULL -define('CND_CLASS', 15); // belongs to classes: classMask, NULL, NULL -define('CND_RACE', 16); // belongs to races: raceMask, NULL, NULL -define('CND_ACHIEVEMENT', 17); // obtained achievement: achievementId, NULL, NULL -define('CND_TITLE', 18); // obtained title: titleId, NULL, NULL -define('CND_SPAWNMASK', 19); -define('CND_GENDER', 20); // has gender: genderId, NULL, NULL -define('CND_UNIT_STATE', 21); -define('CND_MAPID', 22); // is on map: mapId, NULL, NULL -define('CND_AREAID', 23); // is in area: areaId, NULL, NULL -define('CND_UNUSED_24', 24); -define('CND_SPELL', 25); // knows spell: spellId, NULL, NULL -define('CND_PHASEMASK', 26); // is in phase: phaseMask, NULL, NULL -define('CND_LEVEL', 27); // player level is..: level, operator, NULL -define('CND_QUEST_COMPLETE', 28); // has completed quest: questId, NULL, NULL -define('CND_NEAR_CREATURE', 29); // is near creature: creatureId, dist, NULL -define('CND_NEAR_GAMEOBJECT', 30); // is near gameObject: gameObjectId, dist, NULL -define('CND_OBJECT_ENTRY', 31); // target is ???: objectType, id, NULL -define('CND_TYPE_MASK', 32); // target is type: typeMask, NULL, NULL -define('CND_RELATION_TO', 33); -define('CND_REACTION_TO', 34); -define('CND_DISTANCE_TO', 35); // distance to target targetType, dist, operator -define('CND_ALIVE', 36); // target is alive: NULL, NULL, NULL -define('CND_HP_VAL', 37); // targets absolute health: amount, operator, NULL -define('CND_HP_PCT', 38); // targets relative health: amount, operator, NULL - -// TrinityCore - SmartAI -define('SAI_SRC_TYPE_CREATURE', 0); -define('SAI_SRC_TYPE_OBJECT', 1); -define('SAI_SRC_TYPE_AREATRIGGER', 2); -define('SAI_SRC_TYPE_ACTIONLIST', 9); - -define('SAI_EVENT_FLAG_NO_REPEAT', 0x0001); -define('SAI_EVENT_FLAG_DIFFICULTY_0', 0x0002); -define('SAI_EVENT_FLAG_DIFFICULTY_1', 0x0004); -define('SAI_EVENT_FLAG_DIFFICULTY_2', 0x0008); -define('SAI_EVENT_FLAG_DIFFICULTY_3', 0x0010); -define('SAI_EVENT_FLAG_NO_RESET', 0x0100); -define('SAI_EVENT_FLAG_WHILE_CHARMED', 0x0200); - -define('SAI_EVENT_UPDATE_IC', 0); // In combat. -define('SAI_EVENT_UPDATE_OOC', 1); // Out of combat. -define('SAI_EVENT_HEALTH_PCT', 2); // Health Percentage -define('SAI_EVENT_MANA_PCT', 3); // Mana Percentage -define('SAI_EVENT_AGGRO', 4); // On Creature Aggro -define('SAI_EVENT_KILL', 5); // On Creature Kill -define('SAI_EVENT_DEATH', 6); // On Creature Death -define('SAI_EVENT_EVADE', 7); // On Creature Evade Attack -define('SAI_EVENT_SPELLHIT', 8); // On Creature/Gameobject Spell Hit -define('SAI_EVENT_RANGE', 9); // On Target In Range -define('SAI_EVENT_OOC_LOS', 10); // On Target In Distance Out of Combat -define('SAI_EVENT_RESPAWN', 11); // On Creature/Gameobject Respawn -define('SAI_EVENT_TARGET_HEALTH_PCT', 12); // On Target Health Percentage -define('SAI_EVENT_VICTIM_CASTING', 13); // On Target Casting Spell -define('SAI_EVENT_FRIENDLY_HEALTH', 14); // On Friendly Health Deficit -define('SAI_EVENT_FRIENDLY_IS_CC', 15); // -define('SAI_EVENT_FRIENDLY_MISSING_BUFF', 16); // On Friendly Lost Buff -define('SAI_EVENT_SUMMONED_UNIT', 17); // On Creature/Gameobject Summoned Unit -define('SAI_EVENT_TARGET_MANA_PCT', 18); // On Target Mana Percentage -define('SAI_EVENT_ACCEPTED_QUEST', 19); // On Target Accepted Quest -define('SAI_EVENT_REWARD_QUEST', 20); // On Target Rewarded Quest -define('SAI_EVENT_REACHED_HOME', 21); // On Creature Reached Home -define('SAI_EVENT_RECEIVE_EMOTE', 22); // On Receive Emote. -define('SAI_EVENT_HAS_AURA', 23); // On Creature Has Aura -define('SAI_EVENT_TARGET_BUFFED', 24); // On Target Buffed With Spell -define('SAI_EVENT_RESET', 25); // After Combat, On Respawn or Spawn -define('SAI_EVENT_IC_LOS', 26); // On Target In Distance In Combat -define('SAI_EVENT_PASSENGER_BOARDED', 27); // -define('SAI_EVENT_PASSENGER_REMOVED', 28); // -define('SAI_EVENT_CHARMED', 29); // On Creature Charmed -define('SAI_EVENT_CHARMED_TARGET', 30); // On Target Charmed -define('SAI_EVENT_SPELLHIT_TARGET', 31); // On Target Spell Hit -define('SAI_EVENT_DAMAGED', 32); // On Creature Damaged -define('SAI_EVENT_DAMAGED_TARGET', 33); // On Target Damaged -define('SAI_EVENT_MOVEMENTINFORM', 34); // WAYPOINT_MOTION_TYPE = 2, POINT_MOTION_TYPE = 8 -define('SAI_EVENT_SUMMON_DESPAWNED', 35); // On Summoned Unit Despawned -define('SAI_EVENT_CORPSE_REMOVED', 36); // On Creature Corpse Removed -define('SAI_EVENT_AI_INIT', 37); // -define('SAI_EVENT_DATA_SET', 38); // On Creature/Gameobject Data Set, Can be used with SMART_ACTION_SET_DATA -define('SAI_EVENT_WAYPOINT_START', 39); // On Creature Waypoint ID Started -define('SAI_EVENT_WAYPOINT_REACHED', 40); // On Creature Waypoint ID Reached -// define('SAI_EVENT_TRANSPORT_ADDPLAYER', 41); // -// define('SAI_EVENT_TRANSPORT_ADDCREATURE', 42); // -// define('SAI_EVENT_TRANSPORT_REMOVE_PLAYER', 43); // -// define('SAI_EVENT_TRANSPORT_RELOCATE', 44); // -// define('SAI_EVENT_INSTANCE_PLAYER_ENTER', 45); // -define('SAI_EVENT_AREATRIGGER_ONTRIGGER', 46); // -// define('SAI_EVENT_QUEST_ACCEPTED', 47); // On Target Quest Accepted -// define('SAI_EVENT_QUEST_OBJ_COMPLETION', 48); // On Target Quest Objective Completed -// define('SAI_EVENT_QUEST_COMPLETION', 49); // On Target Quest Completed -// define('SAI_EVENT_QUEST_REWARDED', 50); // On Target Quest Rewarded -// define('SAI_EVENT_QUEST_FAIL', 51); // On Target Quest Field -define('SAI_EVENT_TEXT_OVER', 52); // On TEXT_OVER Event Triggered After SMART_ACTION_TALK -define('SAI_EVENT_RECEIVE_HEAL', 53); // On Creature Received Healing -define('SAI_EVENT_JUST_SUMMONED', 54); // On Creature Just spawned -define('SAI_EVENT_WAYPOINT_PAUSED', 55); // On Creature Paused at Waypoint ID -define('SAI_EVENT_WAYPOINT_RESUMED', 56); // On Creature Resumed after Waypoint ID -define('SAI_EVENT_WAYPOINT_STOPPED', 57); // On Creature Stopped On Waypoint ID -define('SAI_EVENT_WAYPOINT_ENDED', 58); // On Creature Waypoint Path Ended -define('SAI_EVENT_TIMED_EVENT_TRIGGERED', 59); // -define('SAI_EVENT_UPDATE', 60); // -define('SAI_EVENT_LINK', 61); // Used to link together multiple events as a chain of events. -define('SAI_EVENT_GOSSIP_SELECT', 62); // On gossip clicked (gossip_menu_option335). -define('SAI_EVENT_JUST_CREATED', 63); // -define('SAI_EVENT_GOSSIP_HELLO', 64); // On Right-Click Creature/Gameobject that have gossip enabled. -define('SAI_EVENT_FOLLOW_COMPLETED', 65); // -define('SAI_EVENT_EVENT_PHASE_CHANGE', 66); // On event phase mask set -define('SAI_EVENT_IS_BEHIND_TARGET', 67); // On Creature is behind target. -define('SAI_EVENT_GAME_EVENT_START', 68); // On game_event started. -define('SAI_EVENT_GAME_EVENT_END', 69); // On game_event ended. -define('SAI_EVENT_GO_STATE_CHANGED', 70); // -define('SAI_EVENT_GO_EVENT_INFORM', 71); // -define('SAI_EVENT_ACTION_DONE', 72); // -define('SAI_EVENT_ON_SPELLCLICK', 73); // -define('SAI_EVENT_FRIENDLY_HEALTH_PCT', 74); // -define('SAI_EVENT_DISTANCE_CREATURE', 75); // On creature guid OR any instance of creature entry is within distance. -define('SAI_EVENT_DISTANCE_GAMEOBJECT', 76); // On gameobject guid OR any instance of gameobject entry is within distance. -define('SAI_EVENT_COUNTER_SET', 77); // If the value of specified counterID is equal to a specified value -// define('SAI_EVENT_SCENE_START', 78); // don't use on 3.3.5a -// define('SAI_EVENT_SCENE_TRIGGER', 79); // don't use on 3.3.5a -// define('SAI_EVENT_SCENE_CANCEL', 80); // don't use on 3.3.5a -// define('SAI_EVENT_SCENE_COMPLETE', 81); // don't use on 3.3.5a -define('SAI_EVENT_SUMMONED_UNIT_DIES', 82); // CreatureId(0 all), CooldownMin, CooldownMax - -define('SAI_ACTION_NONE', 0); // Do nothing -define('SAI_ACTION_TALK', 1); // Param2 in Milliseconds. -define('SAI_ACTION_SET_FACTION', 2); // Sets faction to creature. -define('SAI_ACTION_MORPH_TO_ENTRY_OR_MODEL', 3); // Take DisplayID of creature (param1) OR Turn to DisplayID (param2) OR Both = 0 for Demorph -define('SAI_ACTION_SOUND', 4); // TextRange = 0 only sends sound to self, TextRange = 1 sends sound to everyone in visibility range -define('SAI_ACTION_PLAY_EMOTE', 5); // Play Emote -define('SAI_ACTION_FAIL_QUEST', 6); // Fail Quest of Target -define('SAI_ACTION_OFFER_QUEST', 7); // Add Quest to Target -define('SAI_ACTION_SET_REACT_STATE', 8); // React State. Can be Passive (0), Defensive (1), Aggressive (2), Assist (3). -define('SAI_ACTION_ACTIVATE_GOBJECT', 9); // Activate Object -define('SAI_ACTION_RANDOM_EMOTE', 10); // Play Random Emote -define('SAI_ACTION_CAST', 11); // Cast Spell ID at Target -define('SAI_ACTION_SUMMON_CREATURE', 12); // Summon Unit -define('SAI_ACTION_THREAT_SINGLE_PCT', 13); // Change Threat Percentage for Single Target -define('SAI_ACTION_THREAT_ALL_PCT', 14); // Change Threat Percentage for All Enemies -define('SAI_ACTION_CALL_AREAEXPLOREDOREVENTHAPPENS', 15); // -// define('SAI_ACTION_SET_INGAME_PHASE_ID', 16); // For 4.3.4 + only -define('SAI_ACTION_SET_EMOTE_STATE', 17); // Play Emote Continuously -define('SAI_ACTION_SET_UNIT_FLAG', 18); // Can set Multi-able flags at once -define('SAI_ACTION_REMOVE_UNIT_FLAG', 19); // Can Remove Multi-able flags at once -define('SAI_ACTION_AUTO_ATTACK', 20); // Stop or Continue Automatic Attack. -define('SAI_ACTION_ALLOW_COMBAT_MOVEMENT', 21); // Allow or Disable Combat Movement -define('SAI_ACTION_SET_EVENT_PHASE', 22); // -define('SAI_ACTION_INC_EVENT_PHASE', 23); // Set param1 OR param2 (not both). Value 0 has no effect. -define('SAI_ACTION_EVADE', 24); // Evade Incoming Attack -define('SAI_ACTION_FLEE_FOR_ASSIST', 25); // If you want the fleeing NPC to say '%s attempts to run away in fear' on flee, use 1 on param1. 0 for no message. -define('SAI_ACTION_CALL_GROUPEVENTHAPPENS', 26); // -define('SAI_ACTION_COMBAT_STOP', 27); // -define('SAI_ACTION_REMOVEAURASFROMSPELL', 28); // 0 removes all auras -define('SAI_ACTION_FOLLOW', 29); // Follow Target -define('SAI_ACTION_RANDOM_PHASE', 30); // -define('SAI_ACTION_RANDOM_PHASE_RANGE', 31); // -define('SAI_ACTION_RESET_GOBJECT', 32); // Reset Gameobject -define('SAI_ACTION_CALL_KILLEDMONSTER', 33); // This is the ID from quest_template.RequiredNpcOrGo -define('SAI_ACTION_SET_INST_DATA', 34); // Set Instance Data -// define('SAI_ACTION_SET_INST_DATA64', 35); // Set Instance Data uint64 -define('SAI_ACTION_UPDATE_TEMPLATE', 36); // Updates creature_template to given entry -define('SAI_ACTION_DIE', 37); // Kill Target -define('SAI_ACTION_SET_IN_COMBAT_WITH_ZONE', 38); // -define('SAI_ACTION_CALL_FOR_HELP', 39); // If you want the NPC to say '%s calls for help!'. Use 1 on param1, 0 for no message. -define('SAI_ACTION_SET_SHEATH', 40); // -define('SAI_ACTION_FORCE_DESPAWN', 41); // Despawn Target after param1 in Milliseconds. If you want to set respawn time set param2 in seconds. -define('SAI_ACTION_SET_INVINCIBILITY_HP_LEVEL', 42); // If you use both params, only percent will be used. -define('SAI_ACTION_MOUNT_TO_ENTRY_OR_MODEL', 43); // Mount to Creature Entry (param1) OR Mount to Creature Display (param2) Or both = 0 for Unmount -define('SAI_ACTION_SET_INGAME_PHASE_MASK', 44); // -define('SAI_ACTION_SET_DATA', 45); // Set Data For Target, can be used with SMART_EVENT_DATA_SET -define('SAI_ACTION_ATTACK_STOP', 46); // -define('SAI_ACTION_SET_VISIBILITY', 47); // Makes creature Visible = 1 or Invisible = 0 -define('SAI_ACTION_SET_ACTIVE', 48); // -define('SAI_ACTION_ATTACK_START', 49); // Allows basic melee swings to creature. -define('SAI_ACTION_SUMMON_GO', 50); // Spawns Gameobject, use target_type to set spawn position. -define('SAI_ACTION_KILL_UNIT', 51); // Kills Creature. -define('SAI_ACTION_ACTIVATE_TAXI', 52); // Sends player to flight path. You have to be close to Flight Master, which gives Taxi ID you need. -define('SAI_ACTION_WP_START', 53); // Creature starts Waypoint Movement. Use waypoints table to create movement. -define('SAI_ACTION_WP_PAUSE', 54); // Creature pauses its Waypoint Movement for given time. -define('SAI_ACTION_WP_STOP', 55); // Creature stops its Waypoint Movement. -define('SAI_ACTION_ADD_ITEM', 56); // Adds item(s) to player. -define('SAI_ACTION_REMOVE_ITEM', 57); // Removes item(s) from player. -define('SAI_ACTION_INSTALL_AI_TEMPLATE', 58); // -define('SAI_ACTION_SET_RUN', 59); // -define('SAI_ACTION_SET_DISABLE_GRAVITY', 60); // Only works for creatures with inhabit air. -define('SAI_ACTION_SET_SWIM', 61); // -define('SAI_ACTION_TELEPORT', 62); // Continue this action with the TARGET_TYPE column. Use any target_type (except 0), and use target_x, target_y, target_z, target_o as the coordinates -define('SAI_ACTION_SET_COUNTER', 63); // -define('SAI_ACTION_STORE_TARGET_LIST', 64); // -define('SAI_ACTION_WP_RESUME', 65); // Creature continues in its Waypoint Movement. -define('SAI_ACTION_SET_ORIENTATION', 66); // -define('SAI_ACTION_CREATE_TIMED_EVENT', 67); // -define('SAI_ACTION_PLAYMOVIE', 68); // -define('SAI_ACTION_MOVE_TO_POS', 69); // PointId is called by SMART_EVENT_MOVEMENTINFORM. Continue this action with the TARGET_TYPE column. Use any target_type, and use target_x, target_y, target_z, target_o as the coordinates -define('SAI_ACTION_ENABLE_TEMP_GOBJ', 70); // param1 = duration -define('SAI_ACTION_EQUIP', 71); // only slots with mask set will be sent to client, bits are 1, 2, 4, leaving mask 0 is defaulted to mask 7 (send all), Slots1-3 are only used if no Param1 is set -define('SAI_ACTION_CLOSE_GOSSIP', 72); // Closes gossip window. -define('SAI_ACTION_TRIGGER_TIMED_EVENT', 73); // -define('SAI_ACTION_REMOVE_TIMED_EVENT', 74); // -define('SAI_ACTION_ADD_AURA', 75); // Adds aura to player(s). Use target_type 17 to make AoE aura. -define('SAI_ACTION_OVERRIDE_SCRIPT_BASE_OBJECT', 76); // WARNING: CAN CRASH CORE, do not use if you dont know what you are doing -define('SAI_ACTION_RESET_SCRIPT_BASE_OBJECT', 77); // -define('SAI_ACTION_CALL_SCRIPT_RESET', 78); // -define('SAI_ACTION_SET_RANGED_MOVEMENT', 79); // Sets movement to follow at a specific range to the target. -define('SAI_ACTION_CALL_TIMED_ACTIONLIST', 80); // -define('SAI_ACTION_SET_NPC_FLAG', 81); // -define('SAI_ACTION_ADD_NPC_FLAG', 82); // -define('SAI_ACTION_REMOVE_NPC_FLAG', 83); // -define('SAI_ACTION_SIMPLE_TALK', 84); // Makes a player say text. SMART_EVENT_TEXT_OVER is not triggered and whispers can not be used. -define('SAI_ACTION_SELF_CAST', 85); // spellID, castFlags -define('SAI_ACTION_CROSS_CAST', 86); // This action is used to make selected caster (in CasterTargetType) to cast spell. Actual target is entered in target_type as normally. -define('SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST', 87); // Will select one entry from the ones provided. 0 is ignored. -define('SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST', 88); // 0 is ignored. -define('SAI_ACTION_RANDOM_MOVE', 89); // Creature moves to random position in given radius. -define('SAI_ACTION_SET_UNIT_FIELD_BYTES_1', 90); // -define('SAI_ACTION_REMOVE_UNIT_FIELD_BYTES_1', 91); // -define('SAI_ACTION_INTERRUPT_SPELL', 92); // This action allows you to interrupt the current spell being cast. If you do not set the spellId, the core will find the current spell depending on the withDelay and the withInstant values. -define('SAI_ACTION_SEND_GO_CUSTOM_ANIM', 93); // -define('SAI_ACTION_SET_DYNAMIC_FLAG', 94); // -define('SAI_ACTION_ADD_DYNAMIC_FLAG', 95); // -define('SAI_ACTION_REMOVE_DYNAMIC_FLAG', 96); // -define('SAI_ACTION_JUMP_TO_POS', 97); // -define('SAI_ACTION_SEND_GOSSIP_MENU', 98); // Can be used together with 'SMART_EVENT_GOSSIP_HELLO' to set custom gossip. -define('SAI_ACTION_GO_SET_LOOT_STATE', 99); // -define('SAI_ACTION_SEND_TARGET_TO_TARGET', 100); // Send targets previously stored with SMART_ACTION_STORE_TARGET, to another npc/go, the other npc/go can then access them as if it was its own stored list -define('SAI_ACTION_SET_HOME_POS', 101); // Use with SMART_TARGET_SELF or SMART_TARGET_POSITION -define('SAI_ACTION_SET_HEALTH_REGEN', 102); // Sets the current creatures health regen on or off. -define('SAI_ACTION_SET_ROOT', 103); // Enables or disables creature movement -define('SAI_ACTION_SET_GO_FLAG', 104); // oldFlag = newFlag -define('SAI_ACTION_ADD_GO_FLAG', 105); // oldFlag |= newFlag -define('SAI_ACTION_REMOVE_GO_FLAG', 106); // oldFlag &= ~newFlag -define('SAI_ACTION_SUMMON_CREATURE_GROUP', 107); // Use creature_summon_groups table. SAI target has no effect, use 0 -define('SAI_ACTION_SET_POWER', 108); // -define('SAI_ACTION_ADD_POWER', 109); // -define('SAI_ACTION_REMOVE_POWER', 110); // -define('SAI_ACTION_GAME_EVENT_STOP', 111); // -define('SAI_ACTION_GAME_EVENT_START', 112); // -define('SAI_ACTION_START_CLOSEST_WAYPOINT', 113); // Make target follow closest waypoint to its location -define('SAI_ACTION_MOVE_OFFSET', 114); // Use target_x, target_y, target_z With target_type=1 -define('SAI_ACTION_RANDOM_SOUND', 115); // -define('SAI_ACTION_SET_CORPSE_DELAY', 116); // -define('SAI_ACTION_DISABLE_EVADE', 117); // -define('SAI_ACTION_GO_SET_GO_STATE', 118); // -define('SAI_ACTION_SET_CAN_FLY', 119); // -define('SAI_ACTION_REMOVE_AURAS_BY_TYPE', 120); // -define('SAI_ACTION_SET_SIGHT_DIST', 121); // -define('SAI_ACTION_FLEE', 122); // -define('SAI_ACTION_ADD_THREAT', 123); // -define('SAI_ACTION_LOAD_EQUIPMENT', 124); // -define('SAI_ACTION_TRIGGER_RANDOM_TIMED_EVENT', 125); // -define('SAI_ACTION_REMOVE_ALL_GAMEOBJECTS', 126); // -define('SAI_ACTION_PAUSE_MOVEMENT', 127); // MovementSlot (default = 0, active = 1, controlled = 2), PauseTime (ms), Force -// define('SAI_ACTION_PLAY_ANIMKIT', 128); // don't use on 3.3.5a -// define('SAI_ACTION_SCENE_PLAY', 129); // don't use on 3.3.5a -// define('SAI_ACTION_SCENE_CANCEL', 130); // don't use on 3.3.5a -define('SAI_ACTION_SPAWN_SPAWNGROUP', 131); // -define('SAI_ACTION_DESPAWN_SPAWNGROUP', 132); // -define('SAI_ACTION_RESPAWN_BY_SPAWNID', 133); // type, typeGuid - Use to respawn npcs and gobs, the target in this case is always=1 and only a single unit could be a target via the spawnId (action_param1, action_param2) -define('SAI_ACTION_INVOKER_CAST', 134); // spellID, castFlags -define('SAI_ACTION_PLAY_CINEMATIC', 135); // cinematic -define('SAI_ACTION_SET_MOVEMENT_SPEED', 136); // movementType, speedInteger, speedFraction -define('SAI_ACTION_PLAY_SPELL_VISUAL_KIT', 137); // spellVisualKitId (RESERVED, PENDING CHERRYPICK) -define('SAI_ACTION_OVERRIDE_LIGHT', 138); // zoneId, areaLightId, overrideLightID, transitionMilliseconds -define('SAI_ACTION_OVERRIDE_WEATHER', 139); // zoneId, weatherId, intensity - -define('SAI_ACTION_ALL_SPELLCASTS', [SAI_ACTION_CAST, SAI_ACTION_ADD_AURA, SAI_ACTION_INVOKER_CAST, SAI_ACTION_SELF_CAST, SAI_ACTION_CROSS_CAST]); -define('SAI_ACTION_ALL_TIMED_ACTION_LISTS', [SAI_ACTION_CALL_TIMED_ACTIONLIST, SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST, SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST]); - -define('SAI_CAST_FLAG_INTERRUPT_PREV', 0x01); -define('SAI_CAST_FLAG_TRIGGERED', 0x02); -// define('SAI_CAST_FORCE_CAST', 0x04); // Forces cast even if creature is out of mana or out of range -// define('SAI_CAST_NO_MELEE_IF_OOM', 0x08); // Prevents creature from entering melee if out of mana or out of range -// define('SAI_CAST_FORCE_TARGET_SELF', 0x10); // the target to cast this spell on itself -define('SAI_CAST_FLAG_AURA_MISSING', 0x20); -define('SAI_CAST_FLAG_COMBAT_MOVE', 0x40); - -define('SAI_REACT_PASSIVE', 0); -define('SAI_REACT_DEFENSIVE', 1); -define('SAI_REACT_AGGRESSIVE', 2); -define('SAI_REACT_ASSIST', 3); - -define('SAI_SUMMON_TIMED_OR_DEAD_DESPAWN', 1); -define('SAI_SUMMON_TIMED_OR_CORPSE_DESPAWN', 2); -define('SAI_SUMMON_TIMED_DESPAWN', 3); -define('SAI_SUMMON_TIMED_DESPAWN_OOC', 4); -define('SAI_SUMMON_CORPSE_DESPAWN', 5); -define('SAI_SUMMON_CORPSE_TIMED_DESPAWN', 6); -define('SAI_SUMMON_DEAD_DESPAWN', 7); -define('SAI_SUMMON_MANUAL_DESPAWN', 8); - -define('SAI_TARGET_NONE', 0); // None. -define('SAI_TARGET_SELF', 1); // Self cast. -define('SAI_TARGET_VICTIM', 2); // Our current target. (ie: highest aggro) -define('SAI_TARGET_HOSTILE_SECOND_AGGRO', 3); // Second highest aggro. -define('SAI_TARGET_HOSTILE_LAST_AGGRO', 4); // Dead last on aggro. -define('SAI_TARGET_HOSTILE_RANDOM', 5); // Just any random target on our threat list. -define('SAI_TARGET_HOSTILE_RANDOM_NOT_TOP', 6); // Any random target except top threat. -define('SAI_TARGET_ACTION_INVOKER', 7); // Unit who caused this Event to occur. -define('SAI_TARGET_POSITION', 8); // Use xyz from event params. -define('SAI_TARGET_CREATURE_RANGE', 9); // (Random?) creature with specified ID within specified range. -define('SAI_TARGET_CREATURE_GUID', 10); // Creature with specified GUID. -define('SAI_TARGET_CREATURE_DISTANCE', 11); // Creature with specified ID within distance. (Different from #9?) -define('SAI_TARGET_STORED', 12); // Uses pre-stored target(list) -define('SAI_TARGET_GAMEOBJECT_RANGE', 13); // (Random?) object with specified ID within specified range. -define('SAI_TARGET_GAMEOBJECT_GUID', 14); // Object with specified GUID. -define('SAI_TARGET_GAMEOBJECT_DISTANCE', 15); // Object with specified ID within distance. (Different from #13?) -define('SAI_TARGET_INVOKER_PARTY', 16); // Invoker's party members -define('SAI_TARGET_PLAYER_RANGE', 17); // (Random?) player within specified range. -define('SAI_TARGET_PLAYER_DISTANCE', 18); // (Random?) player within specified distance. (Different from #17?) -define('SAI_TARGET_CLOSEST_CREATURE', 19); // Closest creature with specified ID within specified range. -define('SAI_TARGET_CLOSEST_GAMEOBJECT', 20); // Closest object with specified ID within specified range. -define('SAI_TARGET_CLOSEST_PLAYER', 21); // Closest player within specified range. -define('SAI_TARGET_ACTION_INVOKER_VEHICLE', 22); // Unit's vehicle who caused this Event to occur -define('SAI_TARGET_OWNER_OR_SUMMONER', 23); // Unit's owner or summoner -define('SAI_TARGET_THREAT_LIST', 24); // All units on creature's threat list -define('SAI_TARGET_CLOSEST_ENEMY', 25); // Any attackable target (creature or player) within maxDist -define('SAI_TARGET_CLOSEST_FRIENDLY', 26); // Any friendly unit (creature, player or pet) within maxDist -define('SAI_TARGET_LOOT_RECIPIENTS', 27); // All tagging players -define('SAI_TARGET_FARTHEST', 28); // Farthest unit on the threat list -define('SAI_TARGET_VEHICLE_PASSENGER', 29); // Vehicle can target unit in given seat -define('SAI_TARGET_CLOSEST_UNSPAWNED_GO', 30); // entry(0any), maxDist - -define('SAI_TEMPLATE_BASIC', 0); // -define('SAI_TEMPLATE_CASTER', 1); // +JOIN: target_param1 as castFlag -define('SAI_TEMPLATE_TURRET', 2); // +JOIN: target_param1 as castflag -define('SAI_TEMPLATE_PASSIVE', 3); // -define('SAI_TEMPLATE_CAGED_GO_PART', 4); // -define('SAI_TEMPLATE_CAGED_NPC_PART', 5); // - -define('SAI_SPAWN_FLAG_NONE', 0x00); -define('SAI_SPAWN_FLAG_IGNORE_RESPAWN', 0x01); // onSpawnIn - ignore & reset respawn timer -define('SAI_SPAWN_FLAG_FORCE_SPAWN', 0x02); // onSpawnIn - force additional spawn if already in world -define('SAI_SPAWN_FLAG_NOSAVE_RESPAWN', 0x04); // onDespawn - remove respawn time - -// profiler queue interactions -define('PR_QUEUE_STATUS_ENDED', 0); -define('PR_QUEUE_STATUS_WAITING', 1); -define('PR_QUEUE_STATUS_WORKING', 2); -define('PR_QUEUE_STATUS_READY', 3); -define('PR_QUEUE_STATUS_ERROR', 4); -define('PR_QUEUE_ERROR_UNK', 0); -define('PR_QUEUE_ERROR_CHAR', 1); -define('PR_QUEUE_ERROR_ARMORY', 2); - -// profiler completion manager -define('PR_EXCLUDE_GROUP_UNAVAILABLE', 0x001); -define('PR_EXCLUDE_GROUP_TCG', 0x002); -define('PR_EXCLUDE_GROUP_COLLECTORS_EDITION', 0x004); -define('PR_EXCLUDE_GROUP_PROMOTION', 0x008); -define('PR_EXCLUDE_GROUP_WRONG_REGION', 0x010); -define('PR_EXCLUDE_GROUP_REQ_ALLIANCE', 0x020); -define('PR_EXCLUDE_GROUP_REQ_HORDE', 0x040); -define('PR_EXCLUDE_GROUP_OTHER_FACTION', PR_EXCLUDE_GROUP_REQ_ALLIANCE | PR_EXCLUDE_GROUP_REQ_HORDE); -define('PR_EXCLUDE_GROUP_REQ_FISHING', 0x080); -define('PR_EXCLUDE_GROUP_REQ_ENGINEERING', 0x100); -define('PR_EXCLUDE_GROUP_REQ_TAILORING', 0x200); -define('PR_EXCLUDE_GROUP_WRONG_PROFESSION', PR_EXCLUDE_GROUP_REQ_FISHING | PR_EXCLUDE_GROUP_REQ_ENGINEERING | PR_EXCLUDE_GROUP_REQ_TAILORING); -define('PR_EXCLUDE_GROUP_REQ_CANT_BE_EXALTED', 0x400); -define('PR_EXCLUDE_GROUP_ANY', 0x7FF); +// TrinityCore - Account Security +define('SEC_PLAYER', 0); +define('SEC_MODERATOR', 1); +define('SEC_GAMEMASTER', 2); +define('SEC_ADMINISTRATOR', 3); +define('SEC_CONSOLE', 4); // console only - should not be encountered // Areatrigger types define('AT_TYPE_NONE', 0); @@ -1663,4 +2049,36 @@ define('AT_TYPE_OBJECTIVE', 3); define('AT_TYPE_SMART', 4); define('AT_TYPE_SCRIPT', 5); +// summon types +define('SUMMONER_TYPE_CREATURE', 0); +define('SUMMONER_TYPE_GAMEOBJECT', 1); + +// Map Types +define('MAP_TYPE_ZONE', 0); +define('MAP_TYPE_TRANSIT', 1); +define('MAP_TYPE_DUNGEON', 2); +define('MAP_TYPE_RAID', 3); +define('MAP_TYPE_BATTLEGROUND', 4); +define('MAP_TYPE_DUNGEON_HC', 5); +define('MAP_TYPE_ARENA', 6); +define('MAP_TYPE_MMODE_RAID', 7); +define('MAP_TYPE_MMODE_RAID_HC', 8); + +define('EMOTE_FLAG_ONLY_STANDING', 0x0001); // Only while standig +define('EMOTE_FLAG_USE_MOUNT', 0x0002); // Emote applies to mount +define('EMOTE_FLAG_NOT_CHANNELING', 0x0004); // Not while channeling +define('EMOTE_FLAG_ANIM_TALK', 0x0008); // Talk anim - talk +define('EMOTE_FLAG_ANIM_QUESTION', 0x0010); // Talk anim - question +define('EMOTE_FLAG_ANIM_EXCLAIM', 0x0020); // Talk anim - exclamation +define('EMOTE_FLAG_ANIM_SHOUT', 0x0040); // Talk anim - shout +define('EMOTE_FLAG_NOT_SWIMMING', 0x0080); // Not while swimming +define('EMOTE_FLAG_ANIM_LAUGH', 0x0100); // Talk anim - laugh +define('EMOTE_FLAG_CAN_LIE_ON_GROUND', 0x0200); // Ok while sleeping or dead +define('EMOTE_FLAG_NOT_FROM_CLIENT', 0x0400); // Disallow from client +define('EMOTE_FLAG_NOT_CASTING', 0x0800); // Not while casting +define('EMOTE_FLAG_END_MOVEMENT', 0x1000); // Movement ends +define('EMOTE_FLAG_INTERRUPT_ON_ATTACK', 0x2000); // Interrupt on attack +define('EMOTE_FLAG_ONLY_STILL', 0x4000); // Only while still +define('EMOTE_FLAG_NOT_FLYING', 0x8000); // Not while flying + ?> diff --git a/includes/game.php b/includes/game.php deleted file mode 100644 index 89ffde4d..00000000 --- a/includes/game.php +++ /dev/null @@ -1,490 +0,0 @@ - 'inv_misc_questionmark', - 0 => 'spell_nature_elementalabsorption', - 6 => ['spell_deathknight_bloodpresence', 'spell_deathknight_frostpresence', 'spell_deathknight_unholypresence' ], - 11 => ['spell_nature_starfall', 'ability_racial_bearform', 'spell_nature_healingtouch' ], - 3 => ['ability_hunter_beasttaming', 'ability_marksmanship', 'ability_hunter_swiftstrike' ], - 8 => ['spell_holy_magicalsentry', 'spell_fire_firebolt02', 'spell_frost_frostbolt02' ], - 2 => ['spell_holy_holybolt', 'spell_holy_devotionaura', 'spell_holy_auraoflight' ], - 5 => ['spell_holy_wordfortitude', 'spell_holy_holybolt', 'spell_shadow_shadowwordpain' ], - 4 => ['ability_rogue_eviscerate', 'ability_backstab', 'ability_stealth' ], - 7 => ['spell_nature_lightning', 'spell_nature_lightningshield', 'spell_nature_magicimmunity' ], - 9 => ['spell_shadow_deathcoil', 'spell_shadow_metamorphosis', 'spell_shadow_rainoffire' ], - 1 => ['ability_rogue_eviscerate', 'ability_warrior_innerrage', 'ability_warrior_defensivestance' ] - ); - - public static $classFileStrings = array( - null, 'warrior', 'paladin', 'hunter', 'rogue', 'priest', 'deathknight', 'shaman', 'mage', 'warlock', null, 'druid' - ); - - private static $combatRatingToItemMod = array( // zero-indexed idx:CR; val:Mod - null, 12, 13, 14, 15, 16, 17, 18, 19, - 20, 21, 22, 23, 24, 25, 26, 27, 28, - 29, 30, null, null, null, 37, 44 - ); - - public static $lvlIndepRating = array( // rating doesn't scale with level - ITEM_MOD_MANA, ITEM_MOD_HEALTH, ITEM_MOD_ATTACK_POWER, ITEM_MOD_MANA_REGENERATION, ITEM_MOD_SPELL_POWER, - ITEM_MOD_HEALTH_REGEN, ITEM_MOD_SPELL_PENETRATION, ITEM_MOD_BLOCK_VALUE - ); - - public static $questClasses = array( - -2 => [ 0], - 0 => [ 1, 3, 4, 8, 9, 10, 11, 12, 25, 28, 33, 36, 38, 40, 41, 44, 45, 46, 47, 51, 85, 130, 132, 139, 154, 267, 1497, 1519, 1537, 2257, 3430, 3431, 3433, 3487, 4080, 4298], - 1 => [ 14, 15, 16, 17, 141, 148, 188, 215, 220, 331, 357, 361, 363, 400, 405, 406, 440, 490, 493, 618, 1377, 1637, 1638, 1657, 1769, 3524, 3525, 3526, 3557], - 2 => [ 206, 209, 491, 717, 718, 719, 721, 722, 796, 1176, 1196, 1337, 1417, 1581, 1583, 1584, 1941, 2017, 2057, 2100, 2366, 2367, 2437, 2557, 3535, 3562, 3688, 3713, 3714, 3715, 3716, 3717, 3789, 3790, 3791, 3792, 3842, 3847, 3848, 3849, 3905, 4100, 4131, 4196, 4228, 4264, 4265, 4272, 4277, 4415, 4416, 4494, 4522, 4723, 4809, 4813, 4820], - 3 => [ 1977, 2159, 2677, 2717, 3428, 3429, 3456, 3457, 3606, 3607, 3805, 3836, 3845, 3923, 3959, 4075, 4273, 4493, 4500, 4603, 4722, 4812, 4987], - 4 => [ -372, -263, -262, -261, -162, -161, -141, -82, -81, -61], - 5 => [ -373, -371, -324, -304, -264, -201, -182, -181, -121, -101, -24], - 6 => [ -25, 2597, 3277, 3358, 3820, 4384, 4710], - 7 => [-1010, -368, -367, -365, -344, -241, -1], - 8 => [ 3483, 3518, 3519, 3520, 3521, 3522, 3523, 3679, 3703], - 9 => [-1005, -1003, -1002, -1001, -376, -375, -374, -370, -369, -366, -364, -41, -22], // 22: seasonal - 10 => [ 65, 66, 67, 210, 394, 495, 2817, 3537, 3711, 4024, 4197, 4395, 4742] - ); - - /* why: - Because petSkills (and ranged weapon skills) are the only ones with more than two skillLines attached. Because Left Joining ?_spell with ?_skillLineability causes more trouble than it has uses. - Because this is more or less the only reaonable way to fit all that information into one database field, so.. - .. the indizes of this array are bits of skillLine2OrMask in ?_spell if skillLineId1 is negative - */ - public static $skillLineMask = array( // idx => [familyId, skillLineId] - -1 => array( // Pets (Hunter) - [ 1, 208], [ 2, 209], [ 3, 203], [ 4, 210], [ 5, 211], [ 6, 212], [ 7, 213], // Wolf, Cat, Spider, Bear, Boar, Crocolisk, Carrion Bird - [ 8, 214], [ 9, 215], [11, 217], [12, 218], [20, 236], [21, 251], [24, 653], // Crab, Gorilla, Raptor, Tallstrider, Scorpid, Turtle, Bat - [25, 654], [26, 655], [27, 656], [30, 763], [31, 767], [32, 766], [33, 765], // Hyena, Bird of Prey, Wind Serpent, Dragonhawk, Ravager, Warp Stalker, Sporebat - [34, 764], [35, 768], [37, 775], [38, 780], [39, 781], [41, 783], [42, 784], // Nether Ray, Serpent, Moth, Chimaera, Devilsaur, Silithid, Worm - [43, 786], [44, 785], [45, 787], [46, 788] // Rhino, Wasp, Core Hound, Spirit Beast - ), - -2 => array( // Pets (Warlock) - [15, 189], [16, 204], [17, 205], [19, 207], [23, 188], [29, 761] // Felhunter, Voidwalker, Succubus, Doomguard, Imp, Felguard - ), - -3 => array( // Ranged Weapons - [null, 45], [null, 46], [null, 226] // Bow, Gun, Crossbow - ) - ); - - public static $sockets = array( // jsStyle Strings - 'meta', 'red', 'yellow', 'blue' - ); - - // 'replicates' $WH.g_statToJson - public static $itemMods = array( // zero-indexed; "mastrtng": unused mastery; _[a-z] => taken mods.. - 'dmg', 'mana', 'health', 'agi', 'str', 'int', 'spi', - 'sta', 'energy', 'rage', 'focus', 'runicpwr', 'defrtng', 'dodgertng', - 'parryrtng', 'blockrtng', 'mlehitrtng', 'rgdhitrtng', 'splhitrtng', 'mlecritstrkrtng', 'rgdcritstrkrtng', - 'splcritstrkrtng', '_mlehitrtng', '_rgdhitrtng', '_splhitrtng', '_mlecritstrkrtng', '_rgdcritstrkrtng', '_splcritstrkrtng', - 'mlehastertng', 'rgdhastertng', 'splhastertng', 'hitrtng', 'critstrkrtng', '_hitrtng', '_critstrkrtng', - 'resirtng', 'hastertng', 'exprtng', 'atkpwr', 'rgdatkpwr', 'feratkpwr', 'splheal', - 'spldmg', 'manargn', 'armorpenrtng', 'splpwr', 'healthrgn', 'splpen', 'block', // ITEM_MOD_BLOCK_VALUE - 'mastrtng', 'armor', 'firres', 'frores', 'holres', 'shares', 'natres', - 'arcres', 'firsplpwr', 'frosplpwr', 'holsplpwr', 'shasplpwr', 'natsplpwr', 'arcsplpwr' - ); - - public static $class2SpellFamily = array( - // null Warrior Paladin Hunter Rogue Priest DK Shaman Mage Warlock null Druid - null, 4, 10, 9, 8, 6, 15, 11, 3, 5, null, 7 - ); - - public static $areaFloors = array( - 206 => 3, 209 => 7, 719 => 3, 721 => 4, 796 => 4, 1196 => 2, 1337 => 2, 1581 => 2, 1583 => 7, 1584 => 2, - 2017 => 2, 2057 => 4, 2100 => 2, 2557 => 6, 2677 => 4, 3428 => 3, 3457 => 17, 3790 => 2, 3791 => 2, 3959 => 8, - 3456 => 6, 3715 => 2, 3848 => 3, 3849 => 2, 4075 => 2, 4100 => 2, 4131 => 2, 4196 => 2, 4228 => 4, 4272 => 2, - 4273 => 6, 4277 => 3, 4395 => 2, 4494 => 2, 4722 => 2, 4812 => 8 - ); - - public static function itemModByRatingMask($mask) - { - if (($mask & 0x1C000) == 0x1C000) // special case resilience - return ITEM_MOD_RESILIENCE_RATING; - - if (($mask & 0x00E0) == 0x00E0) // hit rating - all subcats (mle, rgd, spl) - return ITEM_MOD_HIT_RATING; - - if (($mask & 0x0700) == 0x0700) // crit rating - all subcats (mle, rgd, spl) - return ITEM_MOD_CRIT_RATING; - - for ($j = 0; $j < count(self::$combatRatingToItemMod); $j++) - { - if (!self::$combatRatingToItemMod[$j]) - continue; - - if (!($mask & (1 << $j))) - continue; - - return self::$combatRatingToItemMod[$j]; - } - - return 0; - } - - public static function sideByRaceMask($race) - { - // Any - if (!$race || ($race & RACE_MASK_ALL) == RACE_MASK_ALL) - return SIDE_BOTH; - - // Horde - if ($race & RACE_MASK_HORDE && !($race & RACE_MASK_ALLIANCE)) - return SIDE_HORDE; - - // Alliance - if ($race & RACE_MASK_ALLIANCE && !($race & RACE_MASK_HORDE)) - return SIDE_ALLIANCE; - - return SIDE_BOTH; - } - - public static function getReputationLevelForPoints($pts) - { - if ($pts >= 41999) - return REP_EXALTED; - else if ($pts >= 20999) - return REP_REVERED; - else if ($pts >= 8999) - return REP_HONORED; - else if ($pts >= 2999) - return REP_FRIENDLY; - else if ($pts >= 0) - return REP_NEUTRAL; - else if ($pts >= -3000) - return REP_UNFRIENDLY; - else if ($pts >= -6000) - return REP_HOSTILE; - else - return REP_HATED; - } - - public static function getTaughtSpells(&$spell) - { - $extraIds = [-1]; // init with -1 to prevent empty-array errors - $lookup = [-1]; - switch (gettype($spell)) - { - case 'object': - if (get_class($spell) != 'SpellList') - return []; - - $lookup[] = $spell->id; - foreach ($spell->canTeachSpell() as $idx) - $extraIds[] = $spell->getField('effect'.$idx.'TriggerSpell'); - - break; - case 'integer': - $lookup[] = $spell; - break; - case 'array': - $lookup = $spell; - break; - default: - return []; - } - - // note: omits required spell and chance in skill_discovery_template - $data = array_merge( - DB::World()->selectCol('SELECT spellId FROM spell_learn_spell WHERE entry IN (?a)', $lookup), - DB::World()->selectCol('SELECT spellId FROM skill_discovery_template WHERE reqSpell IN (?a)', $lookup), - $extraIds - ); - - // return list of integers, not strings - $data = array_map('intVal', $data); - - return $data; - } - - public static function getPageText($ptId) - { - $pages = []; - while ($ptId) - { - if ($row = DB::World()->selectRow('SELECT ptl.Text AS Text_loc?d, pt.* FROM page_text pt LEFT JOIN page_text_locale ptl ON pt.ID = ptl.ID AND locale = ? WHERE pt.ID = ?d', User::$localeId, User::$localeString, $ptId)) - { - $ptId = $row['NextPageID']; - $pages[] = Util::parseHtmlText(Util::localizedString($row, 'Text')); - } - else - { - trigger_error('Referenced PageTextId #'.$ptId.' is not in DB', E_USER_WARNING); - break; - } - } - - return $pages; - } - - - /*********************/ - /* World Pos. Checks */ - /*********************/ - - private static $alphaMapCache = []; - - private static function alphaMapCheck(int $areaId, array &$set) : bool - { - $file = 'setup/generated/alphaMaps/'.$areaId.'.png'; - if (!file_exists($file)) // file does not exist (probably instanced area) - return false; - - // invalid and corner cases (literally) - if (!is_array($set) || empty($set['posX']) || empty($set['posY']) || $set['posX'] >= 100 || $set['posY'] >= 100) - { - $set = null; - return true; - } - - if (empty(self::$alphaMapCache[$areaId])) - self::$alphaMapCache[$areaId] = imagecreatefrompng($file); - - // alphaMaps are 1000 x 1000, adapt points [black => valid point] - if (!imagecolorat(self::$alphaMapCache[$areaId], $set['posX'] * 10, $set['posY'] * 10)) - $set = null; - - return true; - } - - public static function checkCoords(array $points) : array - { - $result = []; - $capitals = array( // capitals take precedence over their surroundings - 1497, 1637, 1638, 3487, // Undercity, Ogrimmar, Thunder Bluff, Silvermoon City - 1519, 1537, 1657, 3557, // Stormwind City, Ironforge, Darnassus, The Exodar - 3703, 4395 // Shattrath City, Dalaran - ); - - foreach ($points as $res) - { - if (self::alphaMapCheck($res['areaId'], $res)) - { - if (!$res) - continue; - - // some rough measure how central the spawn is on the map (the lower the number, the better) - // 0: perfect center; 1: touches a border - $q = abs( (($res['posX'] - 50) / 50) * (($res['posY'] - 50) / 50) ); - - if (empty($result) || $result[0] > $q) - $result = [$q, $res]; - } - else if (in_array($res['areaId'], $capitals)) // capitals (auto-discovered) and no hand-made alphaMap available - return $res; - else if (empty($result)) // add with lowest quality if alpha map is missing - $result = [1.0, $res]; - } - - // spawn does not really match on a map, but we need at least one result - if (!$result) - { - usort($points, function ($a, $b) { return ($a['dist'] < $b['dist']) ? -1 : 1; }); - $result = [1.0, $points[0]]; - } - - return $result[1]; - } - - public static function getWorldPosForGUID(int $type, int ...$guids) : array - { - $result = []; - - switch ($type) - { - case Type::NPC: - $result = DB::World()->select('SELECT `guid` AS ARRAY_KEY, `id`, `map` AS `mapId`, `position_y` AS `posX`, `position_x` AS `posY` FROM creature WHERE `guid` IN (?a)', $guids); - break; - case Type::OBJECT: - $result = DB::World()->select('SELECT `guid` AS ARRAY_KEY, `id`, `map` AS `mapId`, `position_y` AS `posX`, `position_x` AS `posY` FROM gameobject WHERE `guid` IN (?a)', $guids); - break; - case Type::SOUND: - $result = DB::AoWoW()->select('SELECT `soundId` AS ARRAY_KEY, `soundId` AS `id`, `mapId`, `posX`, `posY` FROM dbc_soundemitters WHERE `soundId` IN (?a)', $guids); - break; - case Type::AREATRIGGER: - $result = DB::AoWoW()->select('SELECT `id` AS ARRAY_KEY, `id`, `mapId`, `posX`, `posY` FROM dbc_areatrigger WHERE `id` IN (?a)', $guids); - break; - default: - trigger_error('Game::getWorldPosForGUID - instanced with unsupported TYPE '.$type, E_USER_WARNING); - } - - return $result; - } - - public static function worldPosToZonePos(int $mapId, float $posX, float $posY, int $areaId = 0, int $floor = -1) : array - { - if (!$mapId < 0) - return []; - - $query = 'SELECT - dm.id, - wma.areaId, - IFNULL(dm.floor, 0) AS floor, - 100 - ROUND(IF(dm.id IS NOT NULL, (?f - dm.minY) * 100 / (dm.maxY - dm.minY), (?f - wma.right) * 100 / (wma.left - wma.right)), 1) AS `posX`, - 100 - ROUND(IF(dm.id IS NOT NULL, (?f - dm.minX) * 100 / (dm.maxX - dm.minX), (?f - wma.bottom) * 100 / (wma.top - wma.bottom)), 1) AS `posY`, - SQRT(POWER(abs(IF(dm.id IS NOT NULL, (?f - dm.minY) * 100 / (dm.maxY - dm.minY), (?f - wma.right) * 100 / (wma.left - wma.right)) - 50), 2) + - POWER(abs(IF(dm.id IS NOT NULL, (?f - dm.minX) * 100 / (dm.maxX - dm.minX), (?f - wma.bottom) * 100 / (wma.top - wma.bottom)) - 50), 2)) AS `dist` - FROM - dbc_worldmaparea wma - LEFT JOIN - dbc_dungeonmap dm ON dm.mapId = IF(?d AND (wma.mapId NOT IN (0, 1, 530, 571) OR wma.areaId = 4395), wma.mapId, -1) - WHERE - wma.mapId = ?d AND IF(?d, wma.areaId = ?d, wma.areaId <> 0){ AND IF(dm.floor IS NULL, 1, dm.floor = ?d)} - HAVING - (`posX` BETWEEN 0.1 AND 99.9 AND `posY` BETWEEN 0.1 AND 99.9) - ORDER BY - `dist` ASC'; - - // dist BETWEEN 0 (center) AND 70.7 (corner) - $points = DB::Aowow()->select($query, $posX, $posX, $posY, $posY, $posX, $posX, $posY, $posY, 1, $mapId, $areaId, $areaId, $floor < 0 ? DBSIMPLE_SKIP : $floor); - if (!$points) // retry: TC counts pre-instance subareas as instance-maps .. which have no map file - $points = DB::Aowow()->select($query, $posX, $posX, $posY, $posY, $posX, $posX, $posY, $posY, 0, $mapId, 0, 0, DBSIMPLE_SKIP); - - if (!is_array($points)) - { - trigger_error('Game::worldPosToZonePos - dbc query failed', E_USER_ERROR); - return []; - } - - return $points; - } - - public static function getQuotesForCreature(int $creatureId, bool $asHTML = false, string $talkSource = '') : array - { - $nQuotes = 0; - $quotes = []; - $soundIds = []; - - $quoteSrc = DB::World()->select(' - SELECT - ct.GroupID AS ARRAY_KEY, ct.ID as ARRAY_KEY2, - ct.`Type` AS `talkType`, - ct.TextRange AS `range`, - IFNULL(bct.`LanguageID`, ct.`Language`) AS lang, - IFNULL(NULLIF(bct.Text, ""), IFNULL(NULLIF(bct.Text1, ""), IFNULL(ct.`Text`, ""))) AS text_loc0, - {IFNULL(NULLIF(bctl.Text, ""), IFNULL(NULLIF(bctl.Text1, ""), IFNULL(ctl.Text, ""))) AS text_loc?d,} - IF(bct.SoundEntriesID > 0, bct.SoundEntriesID, ct.Sound) AS soundId - FROM - creature_text ct - {LEFT JOIN - creature_text_locale ctl ON ct.CreatureID = ctl.CreatureID AND ct.GroupID = ctl.GroupID AND ct.ID = ctl.ID AND ctl.Locale = ?} - LEFT JOIN - broadcast_text bct ON ct.BroadcastTextId = bct.ID - {LEFT JOIN - broadcast_text_locale bctl ON ct.BroadcastTextId = bctl.ID AND bctl.locale = ?} - WHERE - ct.CreatureID = ?d', - User::$localeId ?: DBSIMPLE_SKIP, - User::$localeId ? Util::$localeStrings[User::$localeId] : DBSIMPLE_SKIP, - User::$localeId ? Util::$localeStrings[User::$localeId] : DBSIMPLE_SKIP, - $creatureId - ); - - foreach ($quoteSrc as $grp => $text) - { - $group = []; - foreach ($text as $t) - { - if ($t['soundId']) - $soundIds[] = $t['soundId']; - - $msg = Util::localizedString($t, 'text'); - if (!$msg) - continue; - - // fixup .. either set %s for emotes or dont >.< - if (in_array($t['talkType'], [2, 16]) && strpos($msg, '%s') === false) - $msg = '%s '.$msg; - - // fixup: bad case-insensivity - $msg = Util::parseHtmlText(str_replace('%S', '%s', htmlentities($msg)), !$asHTML); - - if ($talkSource) - $msg = sprintf($msg, $talkSource); - - // make type css compatible - switch ($t['talkType']) - { - case 1: // yell: - case 14: $t['talkType'] = 1; break; // - dark red - case 2: // emote: - case 16: // " - case 3: // boss emote: - case 41: $t['talkType'] = 4; break; // - orange - case 4: // whisper: - case 15: // " - case 5: // boss whisper: - case 42: $t['talkType'] = 3; break; // - pink-ish - default: $t['talkType'] = 2; // [type: 0, 12] say: yellow-ish - - } - - // prefix - $pre = ''; - if ($t['talkType'] != 4) - $pre = ($talkSource ?: '%s').' '.Lang::npc('textTypes', $t['talkType']).Lang::main('colon').($t['lang'] ? '['.Lang::game('languages', $t['lang']).'] ' : null); - - if ($asHTML) - $msg = '
%s'.($t['range'] ? sprintf(Util::$dfnString, Lang::npc('textRanges', $t['range']), $msg) : $msg).'
'; - else - $msg = '[div][span class=s'.$t['talkType'].']%s'.html_entity_decode($msg).'[/span][/div]'; - - $line = array( - 'range' => $t['range'], - 'text' => $msg, - 'prefix' => $pre - ); - - - $nQuotes++; - $group[] = $line; - } - - if ($group) - $quotes[$grp] = $group; - } - - return [$quotes, $nQuotes, $soundIds]; - } - - public static function getBreakpointsForSkill(int $skillId, int $reqLevel) : array - { - switch ($skillId) - { - case SKILL_HERBALISM: - case SKILL_LOCKPICKING: - case SKILL_JEWELCRAFTING: - case SKILL_INSCRIPTION: - case SKILL_SKINNING: - case SKILL_MINING: - $points = [$reqLevel]; // red/orange - - if ($reqLevel + 25 <= MAX_SKILL) // orange/yellow - $points[] = $reqLevel + 25; - - if ($reqLevel + 50 <= MAX_SKILL) // yellow/green - $points[] = $reqLevel + 50; - - if ($reqLevel + 100 <= MAX_SKILL) // green/grey - $points[] = $reqLevel + 100; - - return $points; - default: - return [$reqLevel]; - } - } -} - -?> diff --git a/includes/game/chrclass.class.php b/includes/game/chrclass.class.php new file mode 100644 index 00000000..3a94bc4b --- /dev/null +++ b/includes/game/chrclass.class.php @@ -0,0 +1,79 @@ +value & $classMask; + } + + public function toMask() : int + { + return 1 << ($this->value - 1); + } + + public static function fromMask(int $classMask = self::MASK_ALL) : array + { + $x = []; + foreach (self::cases() as $cl) + if ($cl->toMask() & $classMask) + $x[] = $cl->value; + + return $x; + } + + public function json() : string + { + return match ($this) + { + self::WARRIOR => 'warrior', + self::PALADIN => 'paladin', + self::HUNTER => 'hunter', + self::ROGUE => 'rogue', + self::PRIEST => 'priest', + self::DEATHKNIGHT => 'deathknight', + self::SHAMAN => 'shaman', + self::MAGE => 'mage', + self::WARLOCK => 'warlock', + self::DRUID => 'druid' + }; + } + + public function spellFamily() : int + { + return match ($this) + { + self::WARRIOR => SPELLFAMILY_WARRIOR, + self::PALADIN => SPELLFAMILY_PALADIN, + self::HUNTER => SPELLFAMILY_HUNTER, + self::ROGUE => SPELLFAMILY_ROGUE, + self::PRIEST => SPELLFAMILY_PRIEST, + self::DEATHKNIGHT => SPELLFAMILY_DEATHKNIGHT, + self::SHAMAN => SPELLFAMILY_SHAMAN, + self::MAGE => SPELLFAMILY_MAGE, + self::WARLOCK => SPELLFAMILY_WARLOCK, + self::DRUID => SPELLFAMILY_DRUID + }; + } +} + +?> diff --git a/includes/game/chrrace.class.php b/includes/game/chrrace.class.php new file mode 100644 index 00000000..70e481c1 --- /dev/null +++ b/includes/game/chrrace.class.php @@ -0,0 +1,144 @@ +value & $raceMask; + } + + public function toMask() : int + { + return 1 << ($this->value - 1); + } + + public function isAlliance() : bool + { + return $this->toMask() & self::MASK_ALLIANCE; + } + + public function isHorde() : bool + { + return $this->toMask() & self::MASK_HORDE; + } + + public function getSide() : int + { + if ($this->isHorde() && $this->isAlliance()) + return SIDE_BOTH; + else if ($this->isHorde()) + return SIDE_HORDE; + else if ($this->isAlliance()) + return SIDE_ALLIANCE; + else + return SIDE_NONE; + } + + public function getTeam() : int + { + if ($this->isHorde() && $this->isAlliance()) + return TEAM_NEUTRAL; + else if ($this->isHorde()) + return TEAM_HORDE; + else if ($this->isAlliance()) + return TEAM_ALLIANCE; + else + return TEAM_NEUTRAL; + } + + public function json() : string + { + return match ($this) + { + self::HUMAN => 'human', + self::ORC => 'orc', + self::DWARF => 'dwarf', + self::NIGHTELF => 'nightelf', + self::UNDEAD => 'scourge', + self::TAUREN => 'tauren', + self::GNOME => 'gnome', + self::TROLL => 'troll', + self::BLOODELF => 'bloodelf', + self::DRAENEI => 'draenei', + default => '' + }; + } + + public static function fromMask(int $raceMask = self::MASK_ALL) : array + { + $x = []; + foreach (self::cases() as $cl) + if ($cl->toMask() & $raceMask) + $x[] = $cl->value; + + return $x; + } + + public static function sideFromMask(int $raceMask) : int + { + // Any + if (!$raceMask || ($raceMask & self::MASK_ALL) == self::MASK_ALL) + return SIDE_BOTH; + + // Horde + if ($raceMask & self::MASK_HORDE && !($raceMask & self::MASK_ALLIANCE)) + return SIDE_HORDE; + + // Alliance + if ($raceMask & self::MASK_ALLIANCE && !($raceMask & self::MASK_HORDE)) + return SIDE_ALLIANCE; + + return SIDE_BOTH; + } + + public static function teamFromMask(int $raceMask) : int + { + // Any + if (!$raceMask || ($raceMask & self::MASK_ALL) == self::MASK_ALL) + return TEAM_NEUTRAL; + + // Horde + if ($raceMask & self::MASK_HORDE && !($raceMask & self::MASK_ALLIANCE)) + return TEAM_HORDE; + + // Alliance + if ($raceMask & self::MASK_ALLIANCE && !($raceMask & self::MASK_HORDE)) + return TEAM_ALLIANCE; + + return TEAM_NEUTRAL; + } +} + +?> diff --git a/includes/game/chrstatistics.php b/includes/game/chrstatistics.php new file mode 100644 index 00000000..1777e31c --- /dev/null +++ b/includes/game/chrstatistics.php @@ -0,0 +1,724 @@ + ['health', ITEM_MOD_HEALTH, null, 115, self::FLAG_ITEM], + self::MANA => ['mana', ITEM_MOD_MANA, null, 116, self::FLAG_ITEM], + self::AGILITY => ['agi', ITEM_MOD_AGILITY, null, 21, self::FLAG_ITEM], + self::STRENGTH => ['str', ITEM_MOD_STRENGTH, null, 20, self::FLAG_ITEM], + self::INTELLECT => ['int', ITEM_MOD_INTELLECT, null, 23, self::FLAG_ITEM], + self::SPIRIT => ['spi', ITEM_MOD_SPIRIT, null, 24, self::FLAG_ITEM], + self::STAMINA => ['sta', ITEM_MOD_STAMINA, null, 22, self::FLAG_ITEM], + self::ENERGY => ['energy', null, null, null, self::FLAG_ITEM], + self::RAGE => ['rage', null, null, null, self::FLAG_ITEM], + self::FOCUS => ['focus', null, null, null, self::FLAG_ITEM], + self::RUNIC_POWER => ['runic', null, null, null, self::FLAG_ITEM | self::FLAG_SERVERSIDE], + self::DEFENSE_RTG => ['defrtng', ITEM_MOD_DEFENSE_SKILL_RATING, CR_DEFENSE_SKILL, 42, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::DODGE_RTG => ['dodgertng', ITEM_MOD_DODGE_RATING, CR_DODGE, 45, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::PARRY_RTG => ['parryrtng', ITEM_MOD_PARRY_RATING, CR_PARRY, 46, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::BLOCK_RTG => ['blockrtng', ITEM_MOD_BLOCK_RATING, CR_BLOCK, 44, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::MELEE_HIT_RTG => ['mlehitrtng', ITEM_MOD_HIT_MELEE_RATING, CR_HIT_MELEE, 95, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::RANGED_HIT_RTG => ['rgdhitrtng', ITEM_MOD_HIT_RANGED_RATING, CR_HIT_RANGED, 39, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::SPELL_HIT_RTG => ['splhitrtng', ITEM_MOD_HIT_SPELL_RATING, CR_HIT_SPELL, 48, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::MELEE_CRIT_RTG => ['mlecritstrkrtng', ITEM_MOD_CRIT_MELEE_RATING, CR_CRIT_MELEE, 84, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::RANGED_CRIT_RTG => ['rgdcritstrkrtng', ITEM_MOD_CRIT_RANGED_RATING, CR_CRIT_RANGED, 40, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::SPELL_CRIT_RTG => ['splcritstrkrtng', ITEM_MOD_CRIT_SPELL_RATING, CR_CRIT_SPELL, 49, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::MELEE_HIT_TAKEN_RTG => ['_mlehitrtng', ITEM_MOD_HIT_TAKEN_MELEE_RATING, CR_HIT_TAKEN_MELEE, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::RANGED_HIT_TAKEN_RTG => ['_rgdhitrtng', ITEM_MOD_HIT_TAKEN_RANGED_RATING, CR_HIT_TAKEN_RANGED, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::SPELL_HIT_TAKEN_RTG => ['_splhitrtng', ITEM_MOD_HIT_TAKEN_SPELL_RATING, CR_HIT_TAKEN_SPELL, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::MELEE_CRIT_TAKEN_RTG => ['_mlecritstrkrtng', ITEM_MOD_CRIT_TAKEN_MELEE_RATING, CR_CRIT_TAKEN_MELEE, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::RANGED_CRIT_TAKEN_RTG => ['_rgdcritstrkrtng', ITEM_MOD_CRIT_TAKEN_RANGED_RATING, CR_CRIT_TAKEN_RANGED, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::SPELL_CRIT_TAKEN_RTG => ['_splcritstrkrtng', ITEM_MOD_CRIT_TAKEN_SPELL_RATING, CR_CRIT_TAKEN_SPELL, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::MELEE_HASTE_RTG => ['mlehastertng', ITEM_MOD_HASTE_MELEE_RATING, CR_HASTE_MELEE, 78, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::RANGED_HASTE_RTG => ['rgdhastertng', ITEM_MOD_HASTE_RANGED_RATING, CR_HASTE_RANGED, 101, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::SPELL_HASTE_RTG => ['splhastertng', ITEM_MOD_HASTE_SPELL_RATING, CR_HASTE_SPELL, 102, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::HIT_RTG => ['hitrtng', ITEM_MOD_HIT_RATING, -CR_HIT_MELEE, 119, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::CRIT_RTG => ['critstrkrtng', ITEM_MOD_CRIT_RATING, -CR_CRIT_MELEE, 96, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::HIT_TAKEN_RTG => ['_hitrtng', ITEM_MOD_HIT_TAKEN_RATING, -CR_HIT_TAKEN_MELEE, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::CRIT_TAKEN_RTG => ['_critstrkrtng', ITEM_MOD_CRIT_TAKEN_RATING, -CR_CRIT_TAKEN_MELEE, null, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::RESILIENCE_RTG => ['resirtng', ITEM_MOD_RESILIENCE_RATING, -CR_CRIT_TAKEN_MELEE, 79, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::HASTE_RTG => ['hastertng', ITEM_MOD_HASTE_RATING, -CR_HASTE_MELEE, 103, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::EXPERTISE_RTG => ['exprtng', ITEM_MOD_EXPERTISE_RATING, CR_EXPERTISE, 117, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::ATTACK_POWER => ['atkpwr', ITEM_MOD_ATTACK_POWER, null, 77, self::FLAG_ITEM], + self::RANGED_ATTACK_POWER => ['rgdatkpwr', ITEM_MOD_RANGED_ATTACK_POWER, null, 38, self::FLAG_ITEM], + self::FERAL_ATTACK_POWER => ['feratkpwr', ITEM_MOD_FERAL_ATTACK_POWER, null, 97, self::FLAG_ITEM], + self::HEALING_SPELL_POWER => ['splheal', ITEM_MOD_SPELL_HEALING_DONE, null, 50, self::FLAG_ITEM], + self::DAMAGE_SPELL_POWER => ['spldmg', ITEM_MOD_SPELL_DAMAGE_DONE, null, 51, self::FLAG_ITEM], + self::MANA_REGENERATION => ['manargn', ITEM_MOD_MANA_REGENERATION, null, 61, self::FLAG_ITEM], + self::ARMOR_PENETRATION_RTG => ['armorpenrtng', ITEM_MOD_ARMOR_PENETRATION_RATING, CR_ARMOR_PENETRATION, 114, self::FLAG_ITEM | self::FLAG_LVL_SCALING], + self::SPELL_POWER => ['splpwr', ITEM_MOD_SPELL_POWER, null, 123, self::FLAG_ITEM], + self::HEALTH_REGENERATION => ['healthrgn', ITEM_MOD_HEALTH_REGEN, null, 60, self::FLAG_ITEM], + self::SPELL_PENETRATION => ['splpen', ITEM_MOD_SPELL_PENETRATION, null, 94, self::FLAG_ITEM], + self::BLOCK => ['block', ITEM_MOD_BLOCK_VALUE, null, 43, self::FLAG_ITEM], + // self::MASTERY_RTG => ['mastrtng', ITEM_MOD_MASTERY_RATING, CR_MASTERY, null, self::FLAG_NONE], + self::ARMOR => ['armor', null,/*ITEM_MOD_EXTRA_ARMOR */null, 41, self::FLAG_ITEM], + self::FIRE_RESISTANCE => ['firres', null,/*ITEM_MOD_FIRE_RESISTANCE */null, 26, self::FLAG_ITEM], + self::FROST_RESISTANCE => ['frores', null,/*ITEM_MOD_FROST_RESISTANCE */null, 28, self::FLAG_ITEM], + self::HOLY_RESISTANCE => ['holres', null,/*ITEM_MOD_HOLY_RESISTANCE */null, 30, self::FLAG_ITEM], + self::SHADOW_RESISTANCE => ['shares', null,/*ITEM_MOD_SHADOW_RESISTANCE*/null, 29, self::FLAG_ITEM], + self::NATURE_RESISTANCE => ['natres', null,/*ITEM_MOD_NATURE_RESISTANCE*/null, 27, self::FLAG_ITEM], + self::ARCANE_RESISTANCE => ['arcres', null,/*ITEM_MOD_ARCANE_RESISTANCE*/null, 25, self::FLAG_ITEM], + self::FIRE_SPELL_POWER => ['firsplpwr', null, null, 53, self::FLAG_ITEM], + self::FROST_SPELL_POWER => ['frosplpwr', null, null, 54, self::FLAG_ITEM], + self::HOLY_SPELL_POWER => ['holsplpwr', null, null, 55, self::FLAG_ITEM], + self::SHADOW_SPELL_POWER => ['shasplpwr', null, null, 57, self::FLAG_ITEM], + self::NATURE_SPELL_POWER => ['natsplpwr', null, null, 56, self::FLAG_ITEM], + self::ARCANE_SPELL_POWER => ['arcsplpwr', null, null, 52, self::FLAG_ITEM], + // v not part of g_statToJson v + self::WEAPON_DAMAGE => ['dmg', null, null, null, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE], + self::WEAPON_DAMAGE_TYPE => ['damagetype', null, null, 35, self::FLAG_SERVERSIDE | self::FLAG_NO_WEIGHT], + self::WEAPON_DAMAGE_MIN => ['dmgmin1', null, null, 33, self::FLAG_SERVERSIDE], + self::WEAPON_DAMAGE_MAX => ['dmgmax1', null, null, 34, self::FLAG_SERVERSIDE], + self::WEAPON_SPEED => ['speed', null, null, 36, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE], + self::WEAPON_DPS => ['dps', null, null, 32, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE], + self::MELEE_DAMAGE_MIN => ['mledmgmin', null, null, 135, self::FLAG_SERVERSIDE], + self::MELEE_DAMAGE_MAX => ['mledmgmax', null, null, 136, self::FLAG_SERVERSIDE], + self::MELEE_SPEED => ['mlespeed', null, null, 137, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE], + self::MELEE_DPS => ['mledps', null, null, 134, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE | self::FLAG_PROFILER], + self::RANGED_DAMAGE_MIN => ['rgddmgmin', null, null, 139, self::FLAG_SERVERSIDE], + self::RANGED_DAMAGE_MAX => ['rgddmgmax', null, null, 140, self::FLAG_SERVERSIDE], + self::RANGED_SPEED => ['rgdspeed', null, null, 141, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE], + self::RANGED_DPS => ['rgddps', null, null, 138, self::FLAG_SERVERSIDE | self::FLAG_FLOAT_VALUE | self::FLAG_PROFILER], + self::EXTRA_SOCKETS => ['nsockets', null, null, 100, self::FLAG_SERVERSIDE | self::FLAG_NO_WEIGHT], + self::ARMOR_BONUS => ['armorbonus', null, null, 109, self::FLAG_SERVERSIDE], + self::MELEE_ATTACK_POWER => ['mleatkpwr', null, null, 37, self::FLAG_SERVERSIDE | self::FLAG_PROFILER], + // v Profiler only v + self::EXPERTISE => ['exp', null, null, null, self::FLAG_PROFILER], + self::ARMOR_PENETRATION_PCT => ['armorpenpct', null, null, null, self::FLAG_PROFILER], + self::MELEE_HIT_PCT => ['mlehitpct', null, null, null, self::FLAG_PROFILER], + self::MELEE_CRIT_PCT => ['mlecritstrkpct', null, null, null, self::FLAG_PROFILER], + self::MELEE_HASTE_PCT => ['mlehastepct', null, null, null, self::FLAG_PROFILER], + self::RANGED_HIT_PCT => ['rgdhitpct', null, null, null, self::FLAG_PROFILER], + self::RANGED_CRIT_PCT => ['rgdcritstrkpct', null, null, null, self::FLAG_PROFILER], + self::RANGED_HASTE_PCT => ['rgdhastepct', null, null, null, self::FLAG_PROFILER], + self::SPELL_HIT_PCT => ['splhitpct', null, null, null, self::FLAG_PROFILER], + self::SPELL_CRIT_PCT => ['splcritstrkpct', null, null, null, self::FLAG_PROFILER], + self::SPELL_HASTE_PCT => ['splhastepct', null, null, null, self::FLAG_PROFILER], + self::MANA_REGENERATION_SPI => ['spimanargn', null, null, null, self::FLAG_PROFILER], + self::MANA_REGENERATION_OC => ['oocmanargn', null, null, null, self::FLAG_PROFILER], + self::MANA_REGENERATION_IC => ['icmanargn', null, null, null, self::FLAG_PROFILER], + self::ARMOR_TOTAL => ['fullarmor', null, null, null, self::FLAG_PROFILER], + self::DEFENSE => ['def', null, null, null, self::FLAG_PROFILER], + self::DODGE_PCT => ['dodgepct', null, null, null, self::FLAG_PROFILER], + self::PARRY_PCT => ['parrypct', null, null, null, self::FLAG_PROFILER], + self::BLOCK_PCT => ['blockpct', null, null, null, self::FLAG_PROFILER], + self::RESILIENCE_PCT => ['resipct', null, null, null, self::FLAG_PROFILER] + ); + + /* Combat Rating needed for 1% effect at level 60 (Note: Shaman, Druid, Paladin and Death Knight have a /1.3 modifier on HASTE not set here) + * Data taken from gtcombatratings.dbc for level 60 [idx % 100 = 59] + * Corrections from gtoctclasscombatratingscalar.dbc with Warrior as base [idx = ratingId + 1] + * Maybe create this data during setup, but then again it will never change for 3.3.5a + */ + private static $crPerPctPoint = array( + CR_WEAPON_SKILL => 2.50, CR_DEFENSE_SKILL => 1.50, CR_DODGE => 13.80, CR_PARRY => 13.80, CR_BLOCK => 5.00, + CR_HIT_MELEE => 10.00, CR_HIT_RANGED => 10.00, CR_HIT_SPELL => 8.00, CR_CRIT_MELEE => 14.00, CR_CRIT_RANGED => 14.00, + CR_CRIT_SPELL => 14.00, CR_HIT_TAKEN_MELEE => 10.00, CR_HIT_TAKEN_RANGED => 10.00, CR_HIT_TAKEN_SPELL => 8.00, CR_CRIT_TAKEN_MELEE => 28.75, + CR_CRIT_TAKEN_RANGED => 28.75, CR_CRIT_TAKEN_SPELL => 28.75, CR_HASTE_MELEE => 10.00, CR_HASTE_RANGED => 10.00, CR_HASTE_SPELL => 10.00, + CR_WEAPON_SKILL_MAINHAND => 2.50, CR_WEAPON_SKILL_OFFHAND => 2.50, CR_WEAPON_SKILL_RANGED => 2.50, CR_EXPERTISE => 2.50, CR_ARMOR_PENETRATION => 4.69512 / 1.1, + ); + + public static function isLevelIndependent(int $stat) : bool + { + if (!isset(self::$data[$stat])) + return false; + + return !(self::$data[$stat][self::IDX_FLAGS] & self::FLAG_LVL_SCALING); + } + + public static function getWeightJson(string|int $jsonOrCriteriaId) : string + { + if (is_numeric($jsonOrCriteriaId)) + $row = array_find(self::$data, fn($x) => $x[self::IDX_FILTER_CR_ID] == $jsonOrCriteriaId); + else + $row = array_find(self::$data, fn($x) => $x[self::IDX_JSON_STR] == $jsonOrCriteriaId); + + return $row && $row[self::IDX_FILTER_CR_ID] && !($row[self::IDX_FLAGS] & self::FLAG_NO_WEIGHT) ? $row[self::IDX_JSON_STR] : ''; + } + + public static function getRatingPctFactor(int $stat) : float + { + // Note: this makes the weapon skill related combat ratings inaccessible. Is this relevant..? + if (!isset(self::$data[$stat]) || self::$data[$stat][self::IDX_COMBAT_RATING] === null) + return 0.0; + + // note: originally any CRIT_TAKEN_RTG stat was set to 0 in favor of RESILIENCE_RTG + // we keep the dbc value and just link RESILIENCE_RTG to CRIT_TAKEN_RTG + // note2: the js expects some stats to be directly mapped to a combat rating that doesn't exist + // picked the next best one in this case and denoted it with a negative value in the $data dump + return self::$crPerPctPoint[abs(self::$data[$stat][self::IDX_COMBAT_RATING])]; + } + + public static function getJsonString(int $stat) : string + { + if (!isset(self::$data[$stat])) + return ''; + + return self::$data[$stat][self::IDX_JSON_STR]; + } + + public static function getFilterCriteriumId(int $stat) : ?int + { + if (!isset(self::$data[$stat])) + return null; + + return self::$data[$stat][self::IDX_FILTER_CR_ID]; + } + + public static function getFlags(int $stat) : int + { + if (!isset(self::$data[$stat])) + return 0; + + return self::$data[$stat][self::IDX_FLAGS]; + } + + public static function getJsonStringsFor(int $flags = Stat::FLAG_NONE) : array + { + $x = []; + foreach (self::$data as $k => [$s, , , , $f]) + if ($s && (!$flags || $flags & $f)) + $x[$k] = $s; + + return $x; + } + + public static function getCombatRatingsFor(int $flags = Stat::FLAG_NONE) : array + { + $x = []; + foreach (self::$data as $k => [, , $c, , $f]) + if ($c > 0 && (!$flags || $flags & $f)) + $x[$k] = $c; + + return $x; + } + + public static function getFilterCriteriumIdFor(int $flags = Stat::FLAG_NONE) : array + { + $x = []; + foreach (self::$data as $k => [, , , $cr, $f]) + if ($cr && (!$flags || $flags & $f)) + $x[$k] = $cr; + + return $x; + } + + public static function getIndexFrom(int $idx, string $search) : int + { + return array_find_key(self::$data, fn($x) => $x[$idx] == $search) ?: 0; + } +} + +class StatsContainer implements \Countable +{ + private array $store = []; + + private array $relSpells = []; + private array $relEnchantments = []; + + private static array $combinedSpellStats = array ( + Stat::ATTACK_POWER => [Stat::RANGED_ATTACK_POWER, Stat::MELEE_ATTACK_POWER], + Stat::SPELL_POWER => [Stat::DAMAGE_SPELL_POWER, Stat::HEALING_SPELL_POWER], + // combat ratings below could be merged like this, but easier to handle as they are already in the same bitmask of the same spell effect + // Stat::HIT_RTG => [Stat::MELEE_HIT_RTG, Stat::RANGED_HIT_RTG, Stat::SPELL_HIT_RTG], + // Stat::CRIT_RTG => [Stat::MELEE_CRIT_TAKEN_RTG, Stat::RANGED_CRIT_RTG, Stat::SPELL_CRIT_RTG], + // Stat::RESILIENCE_RTG => [Stat::MELEE_CRIT_RTG, Stat::RANGED_CRIT_TAKEN_RTG, Stat::SPELL_CRIT_TAKEN_RTG] + ); + + public function __construct(array $relSpells = [], array $relEnchantments = []) + { + if ($relSpells) + $this->relSpells = $relSpells; + + if ($relEnchantments) + $this->relEnchantments = $relEnchantments; + } + + /**********/ + /* Source */ + /**********/ + + public function fromItem(array $item) : self + { + if (!$item) + return $this; + + // convert itemMods to stats + for ($i = 1; $i <= 10; $i++) + { + $mod = $item['statType'.$i]; + $val = $item['statValue'.$i]; + if (!$mod || !$val) + continue; + + if ($idx = Stat::getIndexFrom(Stat::IDX_ITEM_MOD, $mod)) + Util::arraySumByKey($this->store, [$idx => $val]); + } + + // also occurs as seperate field (gets summed in calculation but not in tooltip) + if ($item['tplBlock']) + Util::arraySumByKey($this->store, [Stat::BLOCK => $item['tplBlock']]); + + // convert spells to stats + for ($i = 1; $i <= 5; $i++) + if (in_array($item['spellTrigger'.$i], [SPELL_TRIGGER_EQUIP, SPELL_TRIGGER_USE, SPELL_TRIGGER_USE_NODELAY])) + if ($relS = $this->relS($item['spellId'.$i])) + $this->fromSpell($relS); + + // for ITEM_CLASS_GEM get stats from enchantment + if ($relE = $this->relE($item['gemEnchantmentId'])) + $this->fromEnchantment($relE); + + return $this; + } + + public function fromEnchantment(array $enchantment) : self + { + if (!$enchantment) + return $this; + + for ($i = 1; $i <= 3; $i++) + { + $type = $enchantment['type'.$i]; + $object = $enchantment['object'.$i]; + $amount = $enchantment['amount'.$i]; // !CAUTION! scaling enchantments are initialized with "0" as amount. 0 is a valid amount! + + if ($type == ENCHANTMENT_TYPE_EQUIP_SPELL && ($relS = $this->relS($object))) + $this->fromSpell($relS); + else + foreach ($this->convertEnchantment($type, $object) as $idx) + Util::arraySumByKey($this->store, [$idx => $amount]); + } + + return $this; + } + + public function fromSpell(array $spell, bool $onlyFoodBuff = false) : self + { + if (!$spell) + return $this; + + if ($onlyFoodBuff && !($spell['attributes2'] & SPELL_ATTR2_FOOD_BUFF)) + return $this; + + $tmpStore = []; + + for ($i = 1; $i <= 3; $i++) + { + $eff = $spell['effect'.$i.'Id']; + $aura = $spell['effect'.$i.'AuraId']; + $mVal = $spell['effect'.$i.'MiscValue']; + $amt = $spell['effect'.$i.'BasePoints'] + $spell['effect'.$i.'DieSides']; + + if (in_array($eff, SpellList::EFFECTS_ENCHANTMENT) && ($relE = $this->relE($mVal))) + $this->fromEnchantment($relE); + else if ($aura == SPELL_AURA_PERIODIC_TRIGGER_SPELL && ($ts = $spell['effect'.$i.'TriggerSpell'])) + { + if ($relS = $this->relS($ts)) + $this->fromSpell($relS, true); + } + else + foreach ($this->convertSpellEffect($aura, $mVal, $amt) as $idx) + Util::arraySumByKey($tmpStore, [$idx => $amt]); + } + + foreach (self::$combinedSpellStats as $combined => $stats) + { + for ($i = 0; $i < count($stats); $i++) + { + if (empty($tmpStore[$stats[$i]])) + continue 2; + + if ($i && $tmpStore[$stats[$i]] != $tmpStore[$stats[$i - 1]]) + continue 2; + } + + Util::arraySumByKey($tmpStore, [$combined => $tmpStore[$stats[0]]]); + foreach ($stats as $stat) + unset($tmpStore[$stat]); + } + + Util::arraySumByKey($this->store, $tmpStore); + + return $this; + } + + public function fromJson(array &$json, bool $pruneFromSrc = false) : self + { + if (!$json) + return $this; + + foreach (Stat::getJsonStringsFor() as $idx => $key) + { + if (isset($json[$key])) // 0 is a valid amount! + { + if (Stat::getFlags($idx) & Stat::FLAG_FLOAT_VALUE) + Util::arraySumByKey($this->store, [$idx => (float)$json[$key]]); + else + Util::arraySumByKey($this->store, [$idx => (int)$json[$key]]); + } + + if ($pruneFromSrc) + unset($json[$key]); + } + + return $this; + } + + public function fromDB(int $type, int $typeId, int $fieldFlags = Stat::FLAG_NONE) : self + { + foreach (DB::Aowow()->selectRow('SELECT (%n) FROM ::item_stats WHERE `type` = %i AND `typeId` = %i', Stat::getJsonStringsFor($fieldFlags ?: (Stat::FLAG_ITEM | Stat::FLAG_SERVERSIDE)), $type, $typeId) as $key => $amt) + { + if ($amt === null) + continue; + + $idx = Stat::getIndexFrom(Stat::IDX_JSON_STR, $key); + $float = Stat::getFlags($idx) & Stat::FLAG_FLOAT_VALUE; + + if (Util::checkNumeric($amt, $float ? NUM_CAST_FLOAT : NUM_CAST_INT)) + Util::arraySumByKey($this->store, [$idx => $amt]); + } + + return $this; + } + + public function fromContainer(StatsContainer ...$container) : self + { + foreach ($container as $c) + Util::arraySumByKey($this->store, $c->toRaw()); + + return $this; + } + + + /**********/ + /* Output */ + /**********/ + + public function toJson(int $outFlags = Stat::FLAG_NONE, bool $includeEmpty = true) : array + { + $out = []; + foreach ($this->store as $stat => $amt) + if ((!$outFlags || (Stat::getFlags($stat) & $outFlags)) && ($amt || $includeEmpty)) + $out[Stat::getJsonString($stat)] = $amt; + + return $out; + } + + public function toRaw() : array + { + return $this->store; + } + + public function filter(?callable $filterFn = null) : self + { + $this->store = array_filter($this->store, $filterFn, ARRAY_FILTER_USE_BOTH); + return $this; + } + + public function count() : int + { + return count($this->store); + } + + /****************/ + /* internal use */ + /****************/ + + private function relE(int $enchantmentId) : array + { + return $this->relEnchantments[$enchantmentId] ?? []; + } + + private function relS(int $spellId) : array + { + return $this->relSpells[$spellId] ?? []; + } + + private static function convertEnchantment(int $type, int $object) : array + { + switch ($type) + { + case ENCHANTMENT_TYPE_PRISMATIC_SOCKET: + return [Stat::EXTRA_SOCKETS]; + case ENCHANTMENT_TYPE_DAMAGE: + return [Stat::WEAPON_DAMAGE]; + case ENCHANTMENT_TYPE_TOTEM: + return [Stat::WEAPON_DPS]; + case ENCHANTMENT_TYPE_STAT: // ITEM_MOD_* + return [Stat::getIndexFrom(Stat::IDX_ITEM_MOD, $object)]; + case ENCHANTMENT_TYPE_RESISTANCE: + return match ($object) + { + SPELL_SCHOOL_NORMAL => [Stat::ARMOR], + SPELL_SCHOOL_HOLY => [Stat::HOLY_RESISTANCE], + SPELL_SCHOOL_FIRE => [Stat::FIRE_RESISTANCE], + SPELL_SCHOOL_NATURE => [Stat::NATURE_RESISTANCE], + SPELL_SCHOOL_FROST => [Stat::FROST_RESISTANCE], + SPELL_SCHOOL_SHADOW => [Stat::SHADOW_RESISTANCE], + SPELL_SCHOOL_ARCANE => [Stat::ARCANE_RESISTANCE], + default => [] + }; + case ENCHANTMENT_TYPE_EQUIP_SPELL: // handled one level up + case ENCHANTMENT_TYPE_COMBAT_SPELL: // we do not average effects, so skip + case ENCHANTMENT_TYPE_USE_SPELL: + default: + return []; + } + + return []; + } + + public static function convertCombatRating(int $mask) : array + { + $hitMask = (1 << CR_HIT_MELEE) | (1 << CR_HIT_RANGED) | (1 << CR_HIT_SPELL); + if (($mask & $hitMask) == $hitMask) + return [Stat::HIT_RTG]; // generic hit rating + + $critMask = (1 << CR_CRIT_MELEE) | (1 << CR_CRIT_RANGED) | (1 << CR_CRIT_SPELL); + if (($mask & $critMask) == $critMask) + return [Stat::CRIT_RTG]; // generic crit rating + + $takentMask = (1 << CR_CRIT_TAKEN_MELEE) | (1 << CR_CRIT_TAKEN_RANGED) | (1 << CR_CRIT_TAKEN_SPELL); + if (($mask & $takentMask) == $takentMask) + return [Stat::RESILIENCE_RTG]; // resilience + + $result = []; // there really shouldn't be multiple ratings in that mask besides the cases above, but who knows.. + foreach (Stat::getCombatRatingsFor() as $stat => $cr) + if ($mask & (1 << $cr)) + $result[] = $stat; + + return $result; + } + + private static function convertSpellEffect(int $auraId, int $miscValue, int &$amount) : array + { + $stats = []; + + switch ($auraId) + { + case SPELL_AURA_MOD_STAT: + return match ($miscValue) + { + STAT_STRENGTH => [Stat::STRENGTH], + STAT_AGILITY => [Stat::AGILITY], + STAT_STAMINA => [Stat::STAMINA], + STAT_INTELLECT => [Stat::INTELLECT], + STAT_SPIRIT => [Stat::SPIRIT], + default => $miscValue < 0 ? [Stat::AGILITY, Stat::STRENGTH, Stat::INTELLECT, Stat::SPIRIT, Stat::STAMINA] : [] + }; + case SPELL_AURA_MOD_INCREASE_HEALTH: + case SPELL_AURA_MOD_INCREASE_HEALTH_NONSTACK: + case SPELL_AURA_MOD_INCREASE_HEALTH_2: + return [Stat::HEALTH]; + case SPELL_AURA_MOD_DAMAGE_DONE: + // + weapon damage + if ($miscValue == (1 << SPELL_SCHOOL_NORMAL)) + return [Stat::WEAPON_DAMAGE]; + + // full magic mask + if ($miscValue == SPELL_MAGIC_SCHOOLS) + return [Stat::DAMAGE_SPELL_POWER]; + + if ($miscValue & (1 << SPELL_SCHOOL_HOLY)) + $stats[] = Stat::HOLY_SPELL_POWER; + if ($miscValue & (1 << SPELL_SCHOOL_FIRE)) + $stats[] = Stat::FIRE_SPELL_POWER; + if ($miscValue & (1 << SPELL_SCHOOL_NATURE)) + $stats[] = Stat::NATURE_SPELL_POWER; + if ($miscValue & (1 << SPELL_SCHOOL_FROST)) + $stats[] = Stat::FROST_SPELL_POWER; + if ($miscValue & (1 << SPELL_SCHOOL_SHADOW)) + $stats[] = Stat::SHADOW_SPELL_POWER; + if ($miscValue & (1 << SPELL_SCHOOL_ARCANE)) + $stats[] = Stat::ARCANE_SPELL_POWER; + + return $stats; + case SPELL_AURA_MOD_HEALING_DONE: // not as a mask.. + return [Stat::HEALING_SPELL_POWER]; + case SPELL_AURA_MOD_INCREASE_ENERGY: // MiscVal:type see defined Powers only energy/mana in use + return match ($miscValue) + { + POWER_ENERGY => [Stat::ENERGY], + POWER_RAGE => [Stat::RAGE], + POWER_MANA => [Stat::MANA], + POWER_RUNIC_POWER => [Stat::RUNIC_POWER], + default => [] + }; + case SPELL_AURA_MOD_RATING: + case SPELL_AURA_MOD_RATING_FROM_STAT: + if ($stat = self::convertCombatRating($miscValue)) + return $stat; + + return []; + case SPELL_AURA_MOD_RESISTANCE_EXCLUSIVE: + case SPELL_AURA_MOD_BASE_RESISTANCE: + case SPELL_AURA_MOD_RESISTANCE: + // Armor only if explicitly specified + if ($miscValue == (1 << SPELL_SCHOOL_NORMAL)) + return [Stat::ARMOR]; + + // Holy resistance only if explicitly specified (should it even exist...?) + if ($miscValue == (1 << SPELL_SCHOOL_HOLY)) + return [Stat::HOLY_RESISTANCE]; + + if ($miscValue & (1 << SPELL_SCHOOL_FIRE)) + $stats[] = Stat::FIRE_RESISTANCE; + if ($miscValue & (1 << SPELL_SCHOOL_NATURE)) + $stats[] = Stat::NATURE_RESISTANCE; + if ($miscValue & (1 << SPELL_SCHOOL_FROST)) + $stats[] = Stat::FROST_RESISTANCE; + if ($miscValue & (1 << SPELL_SCHOOL_SHADOW)) + $stats[] = Stat::SHADOW_RESISTANCE; + if ($miscValue & (1 << SPELL_SCHOOL_ARCANE)) + $stats[] = Stat::ARCANE_RESISTANCE; + + return $stats; + case SPELL_AURA_PERIODIC_HEAL: // hp5 + case SPELL_AURA_MOD_REGEN: + case SPELL_AURA_MOD_HEALTH_REGEN_IN_COMBAT: + return [Stat::HEALTH_REGENERATION]; + case SPELL_AURA_MOD_POWER_REGEN: // mp5 + return [Stat::MANA_REGENERATION]; + case SPELL_AURA_MOD_ATTACK_POWER: + return [Stat::MELEE_ATTACK_POWER]; + case SPELL_AURA_MOD_RANGED_ATTACK_POWER: + return [Stat::RANGED_ATTACK_POWER]; + case SPELL_AURA_MOD_SHIELD_BLOCKVALUE: + return [Stat::BLOCK]; + case SPELL_AURA_MOD_EXPERTISE: + return [Stat::EXPERTISE]; + case SPELL_AURA_MOD_TARGET_RESISTANCE: + $amount = abs($amount); // functionally negative, but we work with the absolute amount + if ($miscValue == 0x7C) // SPELL_MAGIC_SCHOOLS & ~SPELL_SCHOOL_HOLY + return [Stat::SPELL_PENETRATION]; + } + + return []; + } +} + +?> diff --git a/includes/game/loot/loot.class.php b/includes/game/loot/loot.class.php new file mode 100644 index 00000000..55c6f513 --- /dev/null +++ b/includes/game/loot/loot.class.php @@ -0,0 +1,71 @@ +jsGlobals, $data); + } +} + +?> diff --git a/includes/game/loot/lootbycontainer.class.php b/includes/game/loot/lootbycontainer.class.php new file mode 100644 index 00000000..64d287d4 --- /dev/null +++ b/includes/game/loot/lootbycontainer.class.php @@ -0,0 +1,333 @@ +results; + } + + /** + * recurse through reference loot while applying modifiers from parent container + * + * @param string $tableName a known loot template table name + * @param int $lootId a loot template entry + * @param int $groupId [optional] limit result to provided loot group + * @param float $baseChance [optional] chance multiplier passed down from parent container + * @return array [[lootRows], [itemIds]] + */ + private function getByContainerRecursive(string $tableName, int $lootId, int $groupId = 0, float $baseChance = 1.0) : array + { + $loot = []; + $rawItems = []; + + if (!$tableName || !$lootId) + return [null, null]; + + $rows = DB::World()->selectAssoc('SELECT * FROM %n', $tableName, 'WHERE %if', $groupId, '`groupid` = %i AND', $groupId, '%end `entry` = %i', $lootId); + if (!$rows) + return [null, null]; + + $groupChances = []; + $nGroupEquals = []; + $cnd = new Conditions(); + foreach ($rows as $entry) + { + $set = array( + 'quest' => $entry['QuestRequired'], + 'group' => $entry['GroupId'], + 'parentRef' => $tableName == self::REFERENCE ? $lootId : 0, + 'realChanceMod' => $baseChance, + 'groupChance' => 0 + ); + + $where = [['(`cuFlags` & %i) = 0', CUSTOM_EXCLUDE_FOR_LISTVIEW | CUSTOM_UNAVAILABLE], [DB::OR, []]]; + for ($i = 1; $i < 5; $i++) + $where[1][1][] = ["`reqSourceItemId$i` = %i", $entry['Item']]; + for ($i = 1; $i < 7; $i++) + $where[1][1][] = ["`reqItemId$i` = %i", $entry['Item']]; + + if ($entry['QuestRequired'] && ($quests = DB::Aowow()->selectCol('SELECT `id` FROM ::quests WHERE %and', $where))) + foreach ($quests as $questId) + $cnd->addExternalCondition(Conditions::lootTableToConditionSource($tableName), $lootId . ':' . $entry['Item'], [Conditions::QUESTTAKEN, $questId], true); + + // TC 'mode' (dynamic loot modifier) + $buff = []; + for ($i = 0; $i < 8; $i++) + if ($entry['LootMode'] & (1 << $i)) + $buff[] = $i + 1; + + $set['mode'] = implode(', ', $buff); + + if ($entry['Reference']) + { + if (!in_array($entry['Reference'], $this->knownRefs)) + $this->knownRefs[$entry['Reference']] = $this->getByContainerRecursive(self::REFERENCE, $entry['Reference'], 0, $entry['Chance'] / 100); + + [$data, $raw] = $this->knownRefs[$entry['Reference']]; + + $loot = array_merge($loot, $data); + $rawItems = array_merge($rawItems, $raw); + + $set['reference'] = $entry['Reference']; + $set['multiplier'] = $entry['MaxCount']; + } + else + { + $rawItems[] = $entry['Item']; + $set['content'] = $entry['Item']; + $set['min'] = $entry['MinCount']; + $set['max'] = $entry['MaxCount']; + } + + if (!isset($groupChances[$entry['GroupId']])) + { + $groupChances[$entry['GroupId']] = 0; + $nGroupEquals[$entry['GroupId']] = 0; + } + + if ($set['quest'] || !$set['group']) + $set['groupChance'] = $entry['Chance']; + else if ($entry['GroupId'] && !$entry['Chance']) + { + $nGroupEquals[$entry['GroupId']]++; + $set['groupChance'] = &$groupChances[$entry['GroupId']]; + } + else if ($entry['GroupId'] && $entry['Chance']) + { + $set['groupChance'] = $entry['Chance']; + + if (!$entry['Reference']) + { + if (empty($groupChances[$entry['GroupId']])) + $groupChances[$entry['GroupId']] = 0; + + $groupChances[$entry['GroupId']] += $entry['Chance']; + } + } + else // shouldn't have happened + { + trigger_error('Unhandled case in calculating chance for item '.$entry['Item'].'!', E_USER_WARNING); + continue; + } + + $loot[] = $set; + } + + foreach (array_keys($nGroupEquals) as $k) + { + $sum = $groupChances[$k]; + if (!$sum) + $sum = 0; + else if ($sum >= 100.01) + { + trigger_error('Loot entry '.$lootId.' / group '.$k.' has a total chance of '.number_format($sum, 2).'%. Some items cannot drop!', E_USER_WARNING); + $sum = 100; + } + // is applied as backReference to items with 0-chance + $groupChances[$k] = (100 - $sum) / ($nGroupEquals[$k] ?: 1); + } + + if ($cnd->getBySource(Conditions::lootTableToConditionSource($tableName), group: $lootId)->prepare()) + { + $this->storeJSGlobals($cnd->getJsGlobals()); + $cnd->toListviewColumn($loot, $this->extraCols, $lootId, 'content'); + } + + return [$loot, array_unique($rawItems)]; + } + + /** + * fetch loot for given loot container and optionally merge multiple container while adding mode info. + * If difficultyBit is 0, no merge will occur + * + * @param string $table a known loote template table name + * @param array $lootEntries array of [difficultyBit => entry]. + * @return bool success and found loot + */ + public function getByContainer(string $table, array $lootEntries): bool + { + if (!in_array($table, self::TEMPLATES)) + return false; + + foreach ($lootEntries as $modeBit => $entry) + { + if (!$entry) + continue; + + [$lootRows, $itemIds] = $this->getByContainerRecursive($table, $entry); + if (!$lootRows) + continue; + + $items = new ItemList(array(['i.id', $itemIds])); + $this->storeJSGlobals($items->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + $itemRows = $items->getListviewData(); + + // assign listview LV rows to loot rows, not the other way round! The same item may be contained multiple times + foreach ($lootRows as $loot) + { + $count = ceil($loot['groupChance'] * $loot['realChanceMod'] * 100); + + /* on modes... + * modes.mode is the (masked) sum of all modes where this item has been seen + * modes.mode & 1 dungeon normal + * modes.mode & 2 dungeon heroic + * modes.mode & 4 generic case (never included in mask for instanced creatures/gos or always === 4 for non-instanced creatures/gos) + * modes.mode & 8 raid 10 nh + * modes.mode & 16 raid 25 nh + * modes.mode & 32 raid 10 hc + * modes.mode & 64 raid 25 hc + * + * modes[4] is _always_ included and is the sum total over all modes: + * ex: modes:{"mode":1,"1":{"count":4408,"outof":16013},"4":{"count":4408,"outof":22531}} + */ + if ($modeBit) + { + $modes = array( // emulate 'percent' with precision: 2 + 'mode' => $modeBit, + $modeBit => ['count' => $count, 'outof' => 10000] + ); + if ($modeBit != 4) + $modes[4] = $modes[$modeBit]; + + + // unsure: force display as noteworthy + // if (!empty($loot['content']) && !empty($itemRows[$loot['content']]) && $itemRows[$loot['content']]['name'][0] == 7 - ITEM_QUALITY_POOR) + // $modes['mode'] = 4; + // else if ($count < 100) // chance < 1% + // $modes['mode'] = 4; + + + // existing result row; merge modes and move on + if (!is_null($k = array_find_key($this->results, function($x) use ($loot) { + if (!empty($loot['reference'])) + return $x['id'] == $loot['reference'] && $x['mode'] == $loot['mode'] && $x['group'] == $loot['group'] && $x['stack'] == [$loot['multiplier'], $loot['multiplier']]; + else + return $x['id'] == $loot['content'] && $x['mode'] == $loot['mode'] && $x['group'] == $loot['group']; + }))) + { + $this->results[$k]['modes']['mode'] |= $modes['mode']; + $this->results[$k]['modes'][$modeBit] = $modes[$modeBit]; + $this->results[$k]['modes'][4]['count'] = max($modes[4]['count'], $this->results[$k]['modes'][4]['count']); + + continue; + } + } + + $base = array( + 'count' => $count, + 'outof' => 10000, + 'group' => $loot['group'], + 'quest' => $loot['quest'], + 'mode' => $loot['mode'] ?: null, // dyn loot mode + 'modes' => $modes ?? null, // difficulties + 'reference' => $loot['parentRef'] ?: null, + 'condition' => $loot['condition'] ?? null, + 'pctstack' => self::buildStack($loot['min'] ?? 0, $loot['max'] ?? 0) + ); + + $base = array_filter($base, fn($x) => $x !== null); + + if (empty($loot['reference'])) // regular drop + { + if ($itemRow = $itemRows[$loot['content']] ?? null) + { + $extra = ['stack' => [$loot['min'], $loot['max']]]; + + // unsure if correct - tag item as trash if chance < 1% and tagged as having many sources + if ($base['count'] < 100 && $items->getEntry($loot['content'])['moreMask'] & SRC_FLAG_COMMON) + $extra['commondrop'] = 1; + + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + { + if (!isset($this->results[$loot['content']])) + $this->results[$loot['content']] = array_merge($itemRow, $base, $extra); + else + $this->results[$loot['content']]['count'] += $base['count']; + } + else + $this->results[] = array_merge($itemRow, $base, $extra); + } + else + trigger_error('Item #'.$loot['content'].' referenced by loot does not exist!', E_USER_WARNING); + } + else if (User::isInGroup(U_GROUP_EMPLOYEE)) // create dummy for ref-drop + { + $data = array( + 'id' => $loot['reference'], + 'name' => '@REFERENCE: '.$loot['reference'], + 'icon' => 'trade_engineering', + 'stack' => [$loot['multiplier'], $loot['multiplier']], + 'commondrop' => 1 + ); + $this->results[] = array_merge($base, $data); + + $this->jsGlobals[Type::ITEM][$loot['reference']] = $data; + } + } + } + + // move excessive % to extra loot + if (!User::isInGroup(U_GROUP_EMPLOYEE)) + { + foreach ($this->results as &$_) + { + // remember 'count' is always relative to a base of 10000 + if ($_['count'] <= 10000) + continue; + + while ($_['count'] > 20000) + { + $_['stack'][0]++; + $_['stack'][1]++; + $_['count'] -= 10000; + } + + $_['stack'][1]++; + $_['count'] = 10000; + } + } + else + { + $fields = [['mode', 'Dyn. Mode'], ['reference', 'Reference']]; + $base = []; + $set = 0; + foreach ($this->results as $foo) + { + foreach ($fields as $idx => [$field, $title]) + { + $val = $foo[$field] ?? 0; + if (!isset($base[$idx])) + $base[$idx] = $val; + else if ($base[$idx] != $val) + $set |= 1 << $idx; + } + + if ($set == (pow(2, count($fields)) - 1)) + break; + } + + $this->extraCols[] = "\$Listview.funcBox.createSimpleCol('group', 'Group', '7%', 'group')"; + foreach ($fields as $idx => [$field, $title]) + if ($set & (1 << $idx)) + $this->extraCols[] = "\$Listview.funcBox.createSimpleCol('".$field."', '".$title."', '7%', '".$field."')"; + } + + return !empty($this->results); + } +} + +?> diff --git a/includes/game/loot/lootbyitem.class.php b/includes/game/loot/lootbyitem.class.php new file mode 100644 index 00000000..ba7a29ce --- /dev/null +++ b/includes/game/loot/lootbyitem.class.php @@ -0,0 +1,441 @@ + [Type::NPC, [], '$LANG.tab_droppedby', 'dropped-by', [], [], []], + self::QUEST_REWARD => [Type::QUEST, [], '$LANG.tab_rewardfrom', 'reward-from-quest', [], [], []], + self::ITEM_CONTAINED => [Type::ITEM, [], '$LANG.tab_containedin', 'contained-in-item', [], [], []], + self::OBJECT_CONTAINED => [Type::OBJECT, [], '$LANG.tab_containedin', 'contained-in-object', [], [], []], + self::NPC_PICKPOCKETED => [Type::NPC, [], '$LANG.tab_pickpocketedfrom', 'pickpocketed-from', [], [], []], + self::NPC_SKINNED => [Type::NPC, [], '$LANG.tab_skinnedfrom', 'skinned-from', [], [], []], + self::ITEM_DISENCHANTED => [Type::ITEM, [], '$LANG.tab_disenchantedfrom', 'disenchanted-from', [], [], []], + self::ITEM_PROSPECTED => [Type::ITEM, [], '$LANG.tab_prospectedfrom', 'prospected-from', [], [], []], + self::ITEM_MILLED => [Type::ITEM, [], '$LANG.tab_milledfrom', 'milled-from', [], [], []], + self::NPC_MINED => [Type::NPC, [], '$LANG.tab_minedfromnpc', 'mined-from-npc', [], [], []], + self::NPC_SALVAGED => [Type::NPC, [], '$LANG.tab_salvagedfrom', 'salvaged-from', [], [], []], + self::NPC_GATHERED => [Type::NPC, [], '$LANG.tab_gatheredfromnpc', 'gathered-from-npc', [], [], []], + self::OBJECT_MINED => [Type::OBJECT, [], '$LANG.tab_minedfrom', 'mined-from-object', [], [], []], + self::OBJECT_GATHERED => [Type::OBJECT, [], '$LANG.tab_gatheredfrom', 'gathered-from-object', [], [], []], + self::ZONE_FISHED => [Type::ZONE, [], '$LANG.tab_fishedin', 'fished-in-zone', [], [], []], + self::OBJECT_FISHED => [Type::OBJECT, [], '$LANG.tab_fishedin', 'fished-in-object', [], [], []], + self::SPELL_CREATED => [Type::SPELL, [], '$LANG.tab_createdby', 'created-by', [], [], []], + self::ACHIEVEMENT_REWARD => [Type::ACHIEVEMENT, [], '$LANG.tab_rewardfrom', 'reward-from-achievement', [], [], []] + ); + private string $queryTemplate = + 'SELECT lt1.`entry` AS ARRAY_KEY, + IF(lt1.`reference` = 0, lt1.`item`, lt1.`reference`) AS "item", + lt1.`chance` AS "chance", + SUM(IF(lt2.`chance` = 0, 1, 0)) AS "nZeroItems", + SUM(IF(lt2.`reference` = 0, lt2.`chance`, 0)) AS "sumChance", + IF(lt1.`groupid` > 0, 1, 0) AS "isGrouped", + IF(lt1.`reference` = 0, lt1.`mincount`, 1) AS "min", + IF(lt1.`reference` = 0, lt1.`maxcount`, 1) AS "max", + IF(lt1.`reference` > 0, lt1.`maxcount`, 1) AS "multiplier" + FROM %n lt1 + LEFT JOIN %n lt2 ON lt1.`entry` = lt2.`entry` AND lt1.`groupid` = lt2.`groupid` + WHERE %and + GROUP BY lt2.`entry`, lt2.`groupid`'; + + /** + * @param int $entry item id to find loot container for + * @return void + */ + public function __construct(private int $entry) + { + + } + + /** + * iterate over result set + * + * @return iterable [tabIdx => [lvTemplate, lvData]] + */ + public function &iterate() : \Generator + { + reset($this->results); + + foreach ($this->results as $k => [, $tabData]) + if ($tabData['data']) // only yield tabs with content + yield $k => $this->results[$k]; + } + + /** + * calculate chance and stack info and apply to loot rows + * + * @param array $refs loot rows to apply chance + stack info to + * @param array $parents [optional] ref loot ids this call is derived from + * @return array [entry => stack+chance-info] + */ + private function calcChance(array $refs, array $parents = []) : array + { + $result = []; + + foreach ($refs as $rId => $ref) + { + // check for possible database inconsistencies + if (!$ref['chance'] && !$ref['isGrouped']) + trigger_error('Loot by Item: Ungrouped Item/Ref '.$ref['item'].' has 0% chance assigned!', E_USER_WARNING); + + if ($ref['isGrouped'] && $ref['sumChance'] > 100) + trigger_error('Loot by Item: Group with Item/Ref '.$ref['item'].' has '.number_format($ref['sumChance'], 2).'% total chance! Some items cannot drop!', E_USER_WARNING); + + if ($ref['isGrouped'] && $ref['sumChance'] >= 100 && !$ref['chance']) + trigger_error('Loot by Item: Item/Ref '.$ref['item'].' with adaptive chance cannot drop. Group already at 100%!', E_USER_WARNING); + + $chance = abs($ref['chance'] ?: (100 - $ref['sumChance']) / $ref['nZeroItems']) / 100; + + // apply inherited chanceMods + if (isset($this->chanceMods[$ref['item']])) + { + $chance *= $this->chanceMods[$ref['item']][0]; + $chance = 1 - pow(1 - $chance, $this->chanceMods[$ref['item']][1]); + } + + // save chance for parent-ref + $this->chanceMods[$rId] = [$chance, $ref['multiplier']]; + + // refTemplate doesn't point to a new ref -> we are done + if (in_array($rId, $parents)) + continue; + + $result[$rId] = array( + 'percent' => $chance, + 'stack' => [$ref['min'], $ref['max']], + 'count' => 1 // ..and one for the sort script + ); + + if ($_ = self::buildStack($ref['min'], $ref['max'])) + $result[$rId]['pctstack'] = $_; + } + + // sort by % DESC + uasort($result, fn($a, $b) => $b['percent'] <=> $a['percent']); + + return $result; + } + + /** + * fetch loot container for item provided to __construct + * + * @param int $maxResults [optional] SQL_LIMIT override + * @param array $lootTableList [optional] limit lookup to provided loot template table names + * @return bool success + */ + public function getByItem(int $maxResults = Listview::DEFAULT_SIZE, array $lootTableList = []) : bool + { + if (!$this->entry) + return false; + + $refResults = []; + + /* + get references containing the item + */ + $newRefs = DB::World()->selectAssoc( + $this->queryTemplate, + Loot::REFERENCE, Loot::REFERENCE, + [['lt1.`item` = %i', $this->entry], ['lt1.`reference` = 0']] + ); + + /* + i'm currently not seeing a reasonable way to blend this into creature/gobject/etc tabs as one entity may drop the same item multiple times, with and without conditions. + if ($newRefs) + { + $cnd = new Conditions(); + if ($cnd->getBySource(Conditions::SRC_REFERENCE_LOOT_TEMPLATE, entry: $this->entry)) + if ($cnd->toListviewColumn($newRefs, $x, $this->entry)) + $this->storejsGlobals($cnd->getJsGlobals()); + } + */ + + while ($newRefs) + { + $curRefs = $newRefs; + $newRefs = DB::World()->selectAssoc( + $this->queryTemplate, + Loot::REFERENCE, Loot::REFERENCE, + [['lt1.`reference` IN %in', array_keys($curRefs)]] + ); + + $refResults += $this->calcChance($curRefs, array_column($newRefs, 'item')); + } + + /* + search the real loot-templates for the itemId and gathered refs + */ + foreach (self::TEMPLATES as $lootTemplate) + { + if ($lootTableList && !in_array($lootTemplate, $lootTableList)) + continue; + + if ($lootTemplate == Loot::REFERENCE) + continue; + + $where = [[DB::OR, [[DB::AND, [['lt1.`reference` = 0'], ['lt1.`item` = %i', $this->entry]]]]]]; + if ($refResults) + $where[0][1][] = ['lt1.`reference` IN %in', array_keys($refResults)]; + + $result = $this->calcChance(DB::World()->selectAssoc( + $this->queryTemplate, + $lootTemplate, $lootTemplate, + $where + )); + + // do not skip here if $result is empty. Additional loot for spells and quest is added separately + + // format for actual use + foreach ($result as $k => $v) + { + unset($result[$k]); + $v['percent'] = round($v['percent'] * 100, 3); + $result[abs($k)] = $v; + } + + // cap fetched entries to the sql-limit to guarantee that the highest chance items get selected first + // screws with GO-loot and skinning-loot as these templates are shared for several tabs (fish, herb, ore) and (herb, ore, leather) + $ids = array_slice(array_keys($result), 0, $maxResults); + + // fill ListviewTabs + match ($lootTemplate) + { + Loot::GAMEOBJECT => $this->handleObjectLoot( $ids, $result), + Loot::MAIL => $this->handleMailLoot( $ids, $result), + Loot::SPELL => $this->handleSpellLoot( $ids, $result), + Loot::CREATURE => $this->handleNpcLoot( $ids, $result, self::NPC_DROPPED, 'lootId'), + Loot::PICKPOCKET => $this->handleNpcLoot( $ids, $result, self::NPC_PICKPOCKETED, 'pickpocketLootId'), + Loot::SKINNING => $this->handleNpcLoot( $ids, $result, self::NPC_SKINNED, 'skinLootId'), // tabId < 0: assigned real id later + Loot::PROSPECTING => $this->handleGenericLoot($ids, $result, self::ITEM_PROSPECTED, 'id'), + Loot::MILLING => $this->handleGenericLoot($ids, $result, self::ITEM_MILLED, 'id'), + Loot::ITEM => $this->handleGenericLoot($ids, $result, self::ITEM_CONTAINED, 'id'), + Loot::DISENCHANT => $this->handleGenericLoot($ids, $result, self::ITEM_DISENCHANTED, 'disenchantId'), + Loot::FISHING => $this->handleGenericLoot($ids, $result, self::ZONE_FISHED, 'id') // subAreas are currently ignored + }; + } + + // finalize tabs + foreach ($this->listviewTabs as $idx => [$type, $data, $name, $id, $extraCols, $hiddenCols, $visibleCols]) + { + $tabData = array( + 'data' => $data, + 'name' => $name, + 'id' => $id + ); + + if ($extraCols) + $tabData['extraCols'] = array_unique($extraCols); + + if ($hiddenCols) + $tabData['hiddenCols'] = array_unique($hiddenCols); + + if ($visibleCols) + $tabData['visibleCols'] = array_unique($visibleCols); + + $this->results[$idx] = [Type::getFileString($type), $tabData]; + } + + return true; + } + + private function handleGenericLoot(array $ids, array $result, int $tabId, string $dbField) : bool + { + if (!$ids) + return false; + + [$type, &$data, , , &$extraCols, ,] = $this->listviewTabs[$tabId]; + + $srcObj = Type::newList($type, [[$dbField, $ids]]); + if (!$srcObj || $srcObj->error) + return false; + + $srcData = $srcObj->getListviewData(); + $this->storeJSGlobals($srcObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + + $extraCols[] = '$Listview.extraCols.percent'; + + foreach ($srcObj->iterate() as $__) + $data[] = array_merge($srcData[$srcObj->id], $result[$srcObj->getField($dbField)]); + + return true; + } + + private function handleNpcLoot(array $ids, array $result, int $tabId, string $dbField) : bool + { + if (!$ids) + return false; + + if ($baseIds = DB::Aowow()->selectPairs( + 'SELECT `difficultyEntry1` AS ARRAY_KEY, `id` FROM ::creature WHERE `difficultyEntry1` IN %in UNION + SELECT `difficultyEntry2` AS ARRAY_KEY, `id` FROM ::creature WHERE `difficultyEntry2` IN %in UNION + SELECT `difficultyEntry3` AS ARRAY_KEY, `id` FROM ::creature WHERE `difficultyEntry3` IN %in', + $ids, $ids, $ids + )) + { + $parentObj = new CreatureList(array(['id', $baseIds])); + if (!$parentObj->error) + { + $this->storeJSGlobals($parentObj->getJSGlobals()); + $parentData = $parentObj->getListviewData(); + $ids = array_diff($ids, $baseIds); + } + } + + $npc = new CreatureList(array([$dbField, $ids])); + if ($npc->error) + return false; + + $srcData = $npc->getListviewData(); + $this->storeJSGlobals($npc->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + [, &$data, , , &$extraCols, ,] = $this->listviewTabs[$tabId]; + + foreach ($npc->iterate() as $__) + { + if ($tabId == self::NPC_SKINNED) + { + if ($npc->isMineable()) + $tabId = self::NPC_MINED; + else if ($npc->isGatherable()) + $tabId = self::NPC_GATHERED; + else if ($npc->isSalvageable()) + $tabId = self::NPC_SALVAGED; + } + + $p = $npc->getField('parentId'); + + $data[] = array_merge($parentData[$p] ?? $srcData[$npc->id], $result[$npc->getField($dbField)]); + $extraCols[] = '$Listview.extraCols.percent'; + } + + return true; + } + + private function handleSpellLoot(array $ids, array $result) : bool + { + $conditions = array( + DB::OR, + [DB::AND, ['effect1CreateItemId', $this->entry], [DB::OR, ['effect1Id', SpellList::EFFECTS_ITEM_CREATE], ['effect1AuraId', SpellList::AURAS_ITEM_CREATE]]], + [DB::AND, ['effect2CreateItemId', $this->entry], [DB::OR, ['effect2Id', SpellList::EFFECTS_ITEM_CREATE], ['effect2AuraId', SpellList::AURAS_ITEM_CREATE]]], + [DB::AND, ['effect3CreateItemId', $this->entry], [DB::OR, ['effect3Id', SpellList::EFFECTS_ITEM_CREATE], ['effect3AuraId', SpellList::AURAS_ITEM_CREATE]]] + ); + if ($ids) + $conditions[] = ['id', $ids]; + + $srcObj = new SpellList($conditions); + if ($srcObj->error) + return false; + + $this->storeJSGlobals($srcObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); + [, &$data, , , &$extraCols, , &$visibleCols] = $this->listviewTabs[self::SPELL_CREATED]; + + if (!empty($result)) + $extraCols[] = '$Listview.extraCols.percent'; + + if ($srcObj->hasSetFields('reagent1', 'reagent2', 'reagent3', 'reagent4', 'reagent5', 'reagent6', 'reagent7', 'reagent8')) + $visibleCols[] = 'reagents'; + + foreach ($srcObj->getListviewData() as $id => $row) + $data[] = array_merge($row, $result[$id] ?? ['percent' => -1]); + + return true; + } + + private function handleMailLoot(array $ids, array $result) : bool + { + // quest part + $conditions = array(DB::OR, + ['rewardChoiceItemId1', $this->entry], ['rewardChoiceItemId2', $this->entry], ['rewardChoiceItemId3', $this->entry], ['rewardChoiceItemId4', $this->entry], ['rewardChoiceItemId5', $this->entry], + ['rewardChoiceItemId6', $this->entry], ['rewardItemId1', $this->entry], ['rewardItemId2', $this->entry], ['rewardItemId3', $this->entry], ['rewardItemId4', $this->entry] + ); + if ($ids) + $conditions[] = ['rewardMailTemplateId', $ids]; + + $quests = new QuestList($conditions); + if (!$quests->error) + { + $this->storeJSGlobals($quests->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); + [, &$qData, , , , , ] = $this->listviewTabs[self::QUEST_REWARD]; + + foreach ($quests->getListviewData() as $id => $row) + $qData[] = array_merge($row, $result[$id] ?? ['percent' => -1]); + } + + // achievement part + $conditions = array(['itemExtra', $this->entry]); + if ($ar = DB::World()->selectCol('SELECT `ID` FROM achievement_reward WHERE %if', $ids, '`MailTemplateID` IN %in OR %end', $ids, '`ItemID` = %i', $this->entry)) + array_push($conditions, ['id', $ar], DB::OR); + + $achievements = new AchievementList($conditions); + if (!$achievements->error) + { + $this->storeJSGlobals($achievements->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); + [, &$aData, , , , &$hiddenCols, &$visibleCols] = $this->listviewTabs[self::ACHIEVEMENT_REWARD]; + + foreach ($achievements->getListviewData() as $id => $row) + $aData[] = array_merge($row, $result[$id] ?? ['percent' => -1]); + + $hiddenCols[] = 'rewards'; + $visibleCols[] = 'category'; + } + + return !$quests->error || !$achievements->error; + } + + private function handleObjectLoot(array $ids, array $result) : bool + { + if (!$ids) + return false; + + $srcObj = new GameObjectList(array(['lootId', $ids])); + if ($srcObj->error) + return false; + + foreach ($srcObj->getListviewData() as $id => $row) + { + $tabId = match($row['type']) + { + 25 => self::OBJECT_FISHED, // fishing node + -3 => self::OBJECT_GATHERED, // herb + -4 => self::OBJECT_MINED, // vein + default => self::OBJECT_CONTAINED // general chest loot + }; + + [, &$tabData, , , &$extraCols, , &$visibleCols] = $this->listviewTabs[$tabId]; + + $tabData[] = array_merge($row, $result[$srcObj->getEntry($id)['lootId']]); + $extraCols[] = '$Listview.extraCols.percent'; + if ($tabId != 15) + $visibleCols[] = 'skill'; + } + + return true; + } +} + +?> diff --git a/includes/game/misc.php b/includes/game/misc.php new file mode 100644 index 00000000..11cc6ab1 --- /dev/null +++ b/includes/game/misc.php @@ -0,0 +1,376 @@ + 'inv_misc_questionmark', + 0 => 'spell_nature_elementalabsorption', + 6 => ['spell_deathknight_bloodpresence', 'spell_deathknight_frostpresence', 'spell_deathknight_unholypresence' ], + 11 => ['spell_nature_starfall', 'ability_racial_bearform', 'spell_nature_healingtouch' ], + 3 => ['ability_hunter_beasttaming', 'ability_marksmanship', 'ability_hunter_swiftstrike' ], + 8 => ['spell_holy_magicalsentry', 'spell_fire_firebolt02', 'spell_frost_frostbolt02' ], + 2 => ['spell_holy_holybolt', 'spell_holy_devotionaura', 'spell_holy_auraoflight' ], + 5 => ['spell_holy_wordfortitude', 'spell_holy_holybolt', 'spell_shadow_shadowwordpain' ], + 4 => ['ability_rogue_eviscerate', 'ability_backstab', 'ability_stealth' ], + 7 => ['spell_nature_lightning', 'spell_nature_lightningshield', 'spell_nature_magicimmunity' ], + 9 => ['spell_shadow_deathcoil', 'spell_shadow_metamorphosis', 'spell_shadow_rainoffire' ], + 1 => ['ability_rogue_eviscerate', 'ability_warrior_innerrage', 'ability_warrior_defensivestance' ] + ); + + public const /* array */ QUEST_CLASSES = array( + -2 => [ 0], + 0 => [ 1, 3, 4, 8, 9, 10, 11, 12, 25, 28, 33, 36, 38, 40, 41, 44, 45, 46, 47, 51, 85, 130, 132, 139, 154, 267, 1497, 1519, 1537, 2257, 3430, 3431, 3433, 3487, 4080, 4298], + 1 => [ 14, 15, 16, 17, 141, 148, 188, 215, 220, 331, 357, 361, 363, 400, 405, 406, 440, 490, 493, 618, 1377, 1637, 1638, 1657, 1769, 3524, 3525, 3526, 3557], + 2 => [ 206, 209, 491, 717, 718, 719, 721, 722, 796, 1176, 1196, 1337, 1477, 1581, 1583, 1584, 1941, 2017, 2057, 2100, 2366, 2367, 2437, 2557, 3535, 3562, 3688, 3713, 3714, 3715, 3716, 3717, 3789, 3790, 3791, 3792, 3842, 3847, 3848, 3849, 3905, 4100, 4131, 4196, 4228, 4264, 4265, 4272, 4277, 4415, 4416, 4494, 4522, 4723, 4809, 4813, 4820], + 3 => [ 1977, 2159, 2677, 2717, 3428, 3429, 3456, 3457, 3606, 3607, 3805, 3836, 3845, 3923, 3959, 4075, 4273, 4493, 4500, 4603, 4722, 4812, 4987], + 4 => [ -372, -263, -262, -261, -162, -161, -141, -82, -81, -61], + 5 => [ -373, -371, -324, -304, -264, -201, -182, -181, -121, -101, -24], + 6 => [ -25, 2597, 3277, 3358, 3820, 4384, 4710], + 7 => [-1010, -368, -367, -365, -344, -241, -1], + 8 => [ 3483, 3518, 3519, 3520, 3521, 3522, 3523, 3679, 3703], + 9 => [-1005, -1003, -1002, -1001, -376, -375, -374, -370, -369, -366, -364, -41, -22], // 22: seasonal + 10 => [ 65, 66, 67, 210, 394, 495, 2817, 3537, 3711, 4024, 4197, 4395, 4742] + ); + + // questSortId for quests need updating + // partially points non-instanced area with identical name for instance quests + public static array $questSortFix = array( + -221 => 440, // Treasure Map => Tanaris + -284 => 0, // Special => Misc (some quests get shuffled into seasonal) + 151 => 0, // Designer Island => Misc + 22 => 0, // Programmer Isle + 35 => 33, // Booty Bay => Stranglethorn Vale + 131 => 132, // Kharanos => Coldridge Valley + 24 => 9, // Northshire Abbey => Northshire Valley + 279 => 36, // Dalaran Crater => Alterac Mountains + 4342 => 4298, // Acherus: The Ebon Hold => The Scarlet Enclave + 2079 => 15, // Alcaz Island => Dustwallow Marsh + 1939 => 440, // Abyssal Sands => Tanaris + 393 => 363, // Darkspeer Strand => Valley of Trials + 702 => 141, // Rut'theran Village => Teldrassil + 221 => 220, // Camp Narache => Red Cloud Mesa + 1116 => 357, // Feathermoon Stronghold => Feralas + 236 => 209, // Shadowfang Keep + 4769 => 4742, // Hrothgar's Landing => Hrothgar's Landing + 4613 => 4395, // Dalaran City => Dalaran + 4522 => 210, // Icecrown Citadell => Icecrown + 3896 => 3703, // Aldor Rise => Shattrath City + 3696 => 3522, // The Barrier Hills => Blade's Edge Mountains + 2839 => 2597, // Alterac Valley + 19 => 1977, // Zul'Gurub + 4445 => 4273, // Ulduar + 2300 => 1941, // Caverns of Time + 3545 => 3535, // Hellfire Citadel + 2562 => 3457, // Karazhan + 3840 => 3959, // Black Temple + 1717 => 491, // Razorfen Kraul + 978 => 1176, // Zul'Farrak + 133 => 721, // Gnomeregan + 3607 => 3905, // Serpentshrine Cavern + 3845 => 3842, // Tempest Keep + 1517 => 1337, // Uldaman + 1417 => 1477 // Sunken Temple + ); + + public static array $questSubCats = array( + 1 => [132], // Dun Morogh: Coldridge Valley + 12 => [9], // Elwynn Forest: Northshire Valley + 141 => [188], // Teldrassil: Shadowglen + 3524 => [3526], // Azuremyst Isle: Ammen Vale + + 14 => [363], // Durotar: Valley of Trials + 85 => [154], // Tirisfal Glades: Deathknell + 215 => [220], // Mulgore: Red Cloud Mesa + 3430 => [3431], // Eversong Woods: Sunstrider Isle + + 46 => [25], // Burning Steppes: Blackrock Mountain + 361 => [1769], // Felwood: Timbermaw Hold + 3519 => [3679], // Terokkar: Skettis + 3535 => [3562, 3713, 3714], // Hellfire Citadel + 3905 => [3715, 3716, 3717], // Coilfang Reservoir + 3688 => [3789, 3790, 3792], // Auchindoun + 1941 => [2366, 2367, 4100], // Caverns of Time + 3842 => [3847, 3848, 3849], // Tempest Keep + 4522 => [4809, 4813, 4820] // Icecrown Citadel + ); + + /* why: + Because petSkills (and ranged weapon skills) are the only ones with more than two skillLines attached. Because Left Joining ::spell with ::skillLineability causes more trouble than it has uses. + Because this is more or less the only reaonable way to fit all that information into one database field, so.. + .. the indizes of this array are bits of skillLine2OrMask in ::spell if skillLineId1 is negative + */ + public static array $skillLineMask = array( // idx => [familyId, skillLineId] + -1 => array( // Pets (Hunter) + [ 1, 208], [ 2, 209], [ 3, 203], [ 4, 210], [ 5, 211], [ 6, 212], [ 7, 213], // Wolf, Cat, Spider, Bear, Boar, Crocolisk, Carrion Bird + [ 8, 214], [ 9, 215], [11, 217], [12, 218], [20, 236], [21, 251], [24, 653], // Crab, Gorilla, Raptor, Tallstrider, Scorpid, Turtle, Bat + [25, 654], [26, 655], [27, 656], [30, 763], [31, 767], [32, 766], [33, 765], // Hyena, Bird of Prey, Wind Serpent, Dragonhawk, Ravager, Warp Stalker, Sporebat + [34, 764], [35, 768], [37, 775], [38, 780], [39, 781], [41, 783], [42, 784], // Nether Ray, Serpent, Moth, Chimaera, Devilsaur, Silithid, Worm + [43, 786], [44, 785], [45, 787], [46, 788] // Rhino, Wasp, Core Hound, Spirit Beast + ), + -2 => array( // Pets (Warlock) + [15, 189], [16, 204], [17, 205], [19, 207], [23, 188], [29, 761] // Felhunter, Voidwalker, Succubus, Doomguard, Imp, Felguard + ), + -3 => array( // Ranged Weapons + [null, 45], [null, 46], [null, 226] // Bow, Gun, Crossbow + ) + ); + + public static array $sockets = array( // jsStyle Strings + 'meta', 'red', 'yellow', 'blue' + ); + + public static function getReputationLevelForPoints(int $pts) : int + { + return match (true) { + $pts >= 42000 => REP_EXALTED, + $pts >= 21000 => REP_REVERED, + $pts >= 9000 => REP_HONORED, + $pts >= 3000 => REP_FRIENDLY, + $pts >= 0 => REP_NEUTRAL, + $pts >= -3000 => REP_UNFRIENDLY, + $pts >= -6000 => REP_HOSTILE, + default => REP_HATED, + }; + } + + public static function getTaughtSpells(mixed &$spell) : array + { + $extraIds = [-1]; // init with -1 to prevent empty-array errors + $lookup = [-1]; + switch (gettype($spell)) + { + case 'object': + if (get_class($spell) != SpellList::class) + return []; + + $lookup[] = $spell->id; + foreach ($spell->canTeachSpell() as $idx) + $extraIds[] = $spell->getField('effect'.$idx.'TriggerSpell'); + + break; + case 'integer': + $lookup[] = $spell; + break; + case 'array': + $lookup = $spell; + break; + default: + return []; + } + + // note: omits required spell and chance in skill_discovery_template + $data = array_merge( + DB::World()->selectCol('SELECT spellId FROM spell_learn_spell WHERE entry IN %in', $lookup), + DB::World()->selectCol('SELECT spellId FROM skill_discovery_template WHERE reqSpell IN %in', $lookup), + $extraIds + ); + + // return list of integers, not strings + $data = array_map('intVal', $data); + + return $data; + } + + public static function getBook(int $ptId, ?int $startPage = null) : ?Book + { + $pages = []; + while ($ptId) + { + if ($row = DB::World()->selectRow('SELECT ptl.`Text` AS Text_loc%i, pt.* FROM page_text pt LEFT JOIN page_text_locale ptl ON pt.`ID` = ptl.`ID` AND locale = %s WHERE pt.`ID` = %i', Lang::getLocale()->value, Lang::getLocale()->json(), $ptId)) + { + $ptId = $row['NextPageID']; + $pages[] = Util::localizedString($row, 'Text'); + continue; + } + + trigger_error('Referenced PageTextId #'.$ptId.' is not in DB', E_USER_WARNING); + break; + } + + return $pages ? new Book($pages, page: $startPage) : null; + } + + public static function getQuotesForCreature(int $creatureId, bool $asHTML = false, string $talkSource = '') : array + { + $nQuotes = 0; + $quotes = []; + $soundIds = []; + + $quoteSrc = DB::World()->selectAssoc( + 'SELECT ct.`GroupID` AS ARRAY_KEY, ct.`ID` AS ARRAY_KEY2, ct.`Type` AS "talkType", ct.TextRange AS "range", + IFNULL(bct.`LanguageID`, ct.`Language`) AS "lang", + IFNULL(NULLIF(bct.`Text`, ""), IFNULL(NULLIF(bct.`Text1`, ""), IFNULL(ct.`Text`, ""))) AS "text_loc0", + %if', Lang::getLocale()->value, + 'IFNULL(NULLIF(bctl.`Text`, ""), IFNULL(NULLIF(bctl.`Text1`, ""), IFNULL(ctl.`Text`, ""))) AS text_loc%i,', Lang::getLocale()->value, + '%end + IF(bct.`SoundEntriesID` > 0, bct.`SoundEntriesID`, ct.`Sound`) AS "soundId" + FROM creature_text ct + LEFT JOIN broadcast_text bct ON ct.`BroadcastTextId` = bct.`ID` + %if', Lang::getLocale()->value, + 'LEFT JOIN creature_text_locale ctl ON ct.`CreatureID` = ctl.`CreatureID` AND ct.`GroupID` = ctl.`GroupID` AND ct.`ID` = ctl.`ID` AND ctl.`Locale` = %s', Lang::getLocale()->json(), + 'LEFT JOIN broadcast_text_locale bctl ON ct.`BroadcastTextId` = bctl.`ID` AND bctl.`locale` = %s', Lang::getLocale()->json(), + '%end + WHERE ct.`CreatureID` = %i', + $creatureId + ); + + foreach ($quoteSrc as $grp => $text) + { + $group = []; + foreach ($text as $t) + { + if ($t['soundId']) + $soundIds[] = $t['soundId']; + + $msg = Util::localizedString($t, 'text'); + if (!$msg) + continue; + + // fixup .. either set %s for emotes or dont >.< + if (in_array($t['talkType'], [2, 16]) && strpos($msg, '%s') === false) + $msg = '%s '.$msg; + + // fixup: bad case-insensitivity + $msg = Util::parseHtmlText(str_replace('%S', '%s', htmlentities($msg)), !$asHTML); + + if ($talkSource) + $msg = sprintf($msg, $talkSource); + + // convert [old, new] talkType to css compatible + $t['talkType'] = match ((int)$t['talkType']) + { + 0, 12 => 2, // say - yellow-ish + 1, 14 => 1, // yell - dark red + 2, 16, // emote + 3, 41 => 4, // boss emote - orange + 4, 15, // whisper + 5, 42 => 3, // boss whisper - pink-ish + default => 2 + }; + + // prefix + $prefix = ''; + if ($t['talkType'] != 4) + $prefix = ($talkSource ?: '%s').' '.Lang::npc('textTypes', $t['talkType']).Lang::main('colon').($t['lang'] ? '['.Lang::game('languages', $t['lang']).'] ' : ' '); + + if ($asHTML) + $msg = '
'.$prefix.($t['range'] ? sprintf(Util::$dfnString, Lang::npc('textRanges', $t['range']), $msg) : $msg).'
'; + else + $msg = '[div][span class=s'.$t['talkType'].']'.$prefix.html_entity_decode($msg).'[/span][/div]'; + + $line = array( + 'range' => $t['range'], + 'text' => $msg + ); + + $nQuotes++; + $group[] = $line; + } + + if ($group) + $quotes[$grp] = $group; + } + + return [$quotes, $nQuotes, $soundIds]; + } + + public static function getBreakpointsForSkill(int $skillId, int $reqLevel) : array + { + if ($skillId == SKILL_FISHING) + return array( + round(sqrt(.25) * $reqLevel), // 25% valid catches + round(sqrt(.50) * $reqLevel), // 50% valid catches + round(sqrt(.75) * $reqLevel), // 75% valid catches + $reqLevel // 100% valid catches + ); + + switch ($skillId) + { + case SKILL_SKINNING: + $reqLevel /= 5; // we pass creature level * 5 (so, skill value), but formula depends on actual creature level + if ($reqLevel < 10) + $reqLevel = 0; + else if ($reqLevel < 20) + $reqLevel = ($reqLevel - 10) * 10; + else + $reqLevel *= 5; + case SKILL_HERBALISM: + case SKILL_LOCKPICKING: + case SKILL_JEWELCRAFTING: + case SKILL_INSCRIPTION: + case SKILL_MINING: + case SKILL_ENGINEERING: + $points = [$reqLevel]; // red/orange + + if ($reqLevel + 25 <= MAX_SKILL) // orange/yellow + $points[] = $reqLevel + 25; + + if ($reqLevel + 50 <= MAX_SKILL) // yellow/green + $points[] = $reqLevel + 50; + + if ($reqLevel + 100 <= MAX_SKILL) // green/grey + $points[] = $reqLevel + 100; + + return $points; + default: + return [$reqLevel]; + } + } + + public static function getEnchantmentCondition(int $conditionId, bool $interactive = false) : string + { + $gemCnd = DB::Aowow()->selectRow('SELECT * FROM ::itemenchantmentcondition WHERE `id` = %i', $conditionId); + if (!$gemCnd) + return ''; + + $x = ''; + for ($i = 1; $i < 6; $i++) + { + if (!$gemCnd['color'.$i]) + continue; + + $fiColors = function (int $idx) + { + return match ($idx) + { + 2 => '0:3:5', // red + 3 => '2:4:5', // yellow + 4 => '1:3:4', // blue + default => '' // uhhh.... + }; + }; + + $bLink = $gemCnd['color'.$i] ? ($interactive ? ''. Lang::item('gemColors', $gemCnd['color'.$i] - 1).'' : Lang::item('gemColors', $gemCnd['color'.$i] - 1)) : ''; + $cLink = $gemCnd['cmpColor'.$i] ? ($interactive ? ''.Lang::item('gemColors', $gemCnd['cmpColor'.$i] - 1).'' : Lang::item('gemColors', $gemCnd['cmpColor'.$i] - 1)) : ''; + + switch ($gemCnd['comparator'.$i]) + { + case ENCHANT_CONDITION_LESS_VALUE: // requires less than N gems + case ENCHANT_CONDITION_MORE_VALUE: // requires at least N gems + $x .= ''.Lang::item('gemRequires').Lang::item('gemConditions', $gemCnd['comparator'.$i], [$gemCnd['value'.$i], $bLink]).'
'; + break; + case ENCHANT_CONDITION_MORE_COMPARE: // requires more gems than gems + $x .= ''.Lang::item('gemRequires').Lang::item('gemConditions', $gemCnd['comparator'.$i], [$bLink, $cLink]).'
'; + break; + } + } + + return $x; + } +} + +?> diff --git a/includes/game/worldposition.class.php b/includes/game/worldposition.class.php new file mode 100644 index 00000000..df13aaf6 --- /dev/null +++ b/includes/game/worldposition.class.php @@ -0,0 +1,196 @@ += 100 || $set['posY'] >= 100) + { + $set = null; + return true; + } + + if (empty(self::$alphaMapCache[$areaId])) + self::$alphaMapCache[$areaId] = imagecreatefrompng($file); + + // alphaMaps are 1000 x 1000, adapt points [black => valid point] + if (!imagecolorat(self::$alphaMapCache[$areaId], $set['posX'] * 10, $set['posY'] * 10)) + $set = null; + + return true; + } + + public static function checkZonePos(array $points) : array + { + $result = []; + + foreach ($points as $res) + { + if (self::alphaMapCheck($res['areaId'], $res)) + { + if (!$res) + continue; + + // some rough measure how central the spawn is on the map (the lower the number, the better) + // 0: perfect center; 1: touches a border + $q = abs( (($res['posX'] - 50) / 50) * (($res['posY'] - 50) / 50) ); + + if (empty($result) || $result[0] > $q) + $result = [$q, $res]; + } + // capitals (auto-discovered) and no hand-made alphaMap available + else if (in_array($res['areaId'], self::$capitalCities)) + return $res; + // add with lowest quality if alpha map is missing + else if (empty($result)) + $result = [1.0, $res]; + } + + // spawn does not really match on a map, but we need at least one result + if (!$result) + { + usort($points, fn($a, $b) => $a['dist'] <=> $b['dist']); + $result = [1.0, $points[0]]; + } + + return $result[1]; + } + + public static function getForGUID(int $type, int ...$guids) : array + { + $result = []; + + switch ($type) + { + case Type::NPC: + $result = DB::World()->selectAssoc('SELECT `guid` AS ARRAY_KEY, `id`, `map` AS `mapId`, `position_x` AS `posX`, `position_y` AS `posY` FROM creature WHERE `guid` IN %in', $guids); + break; + case Type::OBJECT: + $result = DB::World()->selectAssoc('SELECT `guid` AS ARRAY_KEY, `id`, `map` AS `mapId`, `position_x` AS `posX`, `position_y` AS `posY` FROM gameobject WHERE `guid` IN %in', $guids); + break; + case Type::SOUND: + $result = DB::AoWoW()->selectAssoc('SELECT `id` AS ARRAY_KEY, `soundId` AS `id`, `mapId`, `posX`, `posY` FROM ::soundemitters WHERE `id` IN %in', $guids); + break; + case Type::ZONE: + $result = DB::Aowow()->selectAssoc('SELECT -`id` AS ARRAY_KEY, `id`, `parentMapId` AS `mapId`, `parentX` AS `posX`, `parentY` AS `posY` FROM ::zones WHERE -`id` IN %in', $guids); + break; + case Type::AREATRIGGER: + $result = []; + if ($base = array_filter($guids, fn($x) => $x > 0)) + $result = array_replace($result, DB::AoWoW()->selectAssoc('SELECT `id` AS ARRAY_KEY, `id`, `mapId`, `posX`, `posY` FROM ::areatrigger WHERE `id` IN %in', $base)); + if ($endpoints = array_filter($guids, fn($x) => $x < 0)) + $result = array_replace($result, DB::World()->selectAssoc( + 'SELECT -`ID` AS ARRAY_KEY, ID AS `id`, `target_map` AS `mapId`, `target_position_x` AS `posX`, `target_position_y` AS `posY` FROM areatrigger_teleport WHERE -`id` IN %in UNION + SELECT -`entryorguid` AS ARRAY_KEY, entryorguid AS `id`, `action_param1` AS `mapId`, `target_x` AS `posX`, `target_y` AS `posY` FROM smart_scripts WHERE -`entryorguid` IN %in AND `source_type` = %i AND `action_type` = %i', + $endpoints, $endpoints, SmartAI::SRC_TYPE_AREATRIGGER, SmartAction::ACTION_TELEPORT + )); + break; + default: + trigger_error('WorldPosition::getForGUID - unsupported TYPE #'.$type, E_USER_WARNING); + } + + if ($diff = array_diff($guids, array_keys($result))) + trigger_error('WorldPosition::getForGUID - no spawn points for TYPE #'.$type.' GUIDS: '.implode(', ', $diff), E_USER_WARNING); + + return $result; + } + + public static function toZonePos(int $mapId, float $mapX, float $mapY, int $preferedAreaId = 0, int $preferedFloor = -1) : array + { + if (!$mapId < 0) + return []; + + if (!isset(self::$zoneMapCache[$mapId])) + self::initZoneMaps($mapId); + + $points = []; + for ($i = 0; $i < 2; $i++) + { + foreach (self::$zoneMapCache[$mapId] as $area) + { + if (!$i && $preferedAreaId != 0 && $area['areaId'] != $preferedAreaId) + continue; + + if (!$i && $preferedFloor >= 0 && $area['floor'] != $preferedFloor) + continue; + + if ($mapX < $area['minX'] || $mapX > $area['maxX'] || + $mapY < $area['minY'] || $mapY > $area['maxY']) + continue; + + // dist BETWEEN 0 (center) AND 70.7 (corner) + $posX = round(($area['maxY'] - $mapY) * 100 / ($area['maxY'] - $area['minY']), 1); + $posY = round(($area['maxX'] - $mapX) * 100 / ($area['maxX'] - $area['minX']), 1); + $dist = sqrt(pow(abs($posX - 50), 2) + pow(abs($posY - 50), 2)); + + $points[] = array( + 'id' => $area['id'], + 'areaId' => $area['areaId'], + 'floor' => $area['floor'], + 'multifloor' => $area['multifloor'], + 'srcPrio' => $area['srcPrio'], + 'posX' => $posX, + 'posY' => $posY, + 'dist' => $dist + ); + } + + // retry: pre-instance subareas belong to the instance-maps but are displayed on the outside. There also cases where the zone reaches outside it's own map. + if ($points) + break; + } + + // sort by srcPrio DESC (primary), dist ASC (secondary) + usort($points, fn($a, $b) => ($b['srcPrio'] <=> $a['srcPrio']) ?: ($a['dist'] <=> $b['dist'])); + + return $points; + } + + private static function initZoneMaps(int $mapId) : void + { + self::$zoneMapCache[$mapId] = DB::Aowow()->selectAssoc( + 'SELECT + x.`id`, + x.`areaId`, + x.`minX`, x.`maxX`, x.`minY`, x.`maxY`, + IF(x.`defaultDungeonMapId` < 0, x.`floor` + 1, x.`floor`) AS `floor`, + IF(useDM.`id` IS NOT NULL OR x.`defaultDungeonMapId` < 0, 1, 0) AS `srcPrio`, + IF(multiDM.`id` IS NOT NULL OR x.`defaultDungeonMapId` < 0, 1, 0) AS `multifloor` + FROM + (SELECT 0 AS `id`, `areaId`, `mapId`, `right` AS `minY`, `left` AS `maxY`, `top` AS `maxX`, `bottom` AS `minX`, 0 AS `floor`, 0 AS `worldMapAreaId`, `defaultDungeonMapId` FROM aowow_worldmaparea wma UNION + SELECT dm.`id`, `areaId`, wma.`mapId`, `minY`, `maxY`, `maxX`, `minX`, `floor`, `worldMapAreaId`, `defaultDungeonMapId` FROM aowow_worldmaparea wma + JOIN aowow_dungeonmap dm ON dm.`mapId` = wma.`mapId` WHERE wma.`mapId` NOT IN (0, 1, 530, 571) OR wma.`areaId` = 4395) x + LEFT JOIN + aowow_dungeonmap useDM ON useDM.`mapId` = x.`mapId` AND useDM.`worldMapAreaId` = x.`worldMapAreaId` AND useDM.`floor` = x.`floor` AND useDM.`worldMapAreaId` > 0 + LEFT JOIN + aowow_dungeonmap multiDM ON multiDM.`mapId` = x.`mapId` AND multiDM.`worldMapAreaId` = x.`worldMapAreaId` AND multiDM.`floor` <> x.`floor` AND multiDM.`worldMapAreaId` > 0 + WHERE + x.`mapId` = %i AND x.`areaId` <> 0 AND + x.`minX` <> 0 AND x.`maxX` <> 0 AND x.`minY` <> 0 AND x.`maxY` <> 0 + GROUP BY + x.`id`, x.`areaId`', + $mapId + ) ?: []; + } +} + +?> diff --git a/includes/kernel.php b/includes/kernel.php index 4a999553..6afe3dbb 100644 --- a/includes/kernel.php +++ b/includes/kernel.php @@ -1,82 +1,237 @@ !extension_loaded($x))) + $error .= 'Required Extension '.implode(', ', $ext)." was not found. Please check if it should exist, using \"php -m\"\n\n"; + +if ($ext = array_filter($badExt, fn($x) => extension_loaded($x))) + $error .= 'Loaded Extension '.implode(', ', $ext)." is incompatible and must be disabled.\n\n"; + +if (version_compare(PHP_VERSION, '8.2.0') < 0) + $error .= 'PHP Version 8.2 or higher required! Your version is '.PHP_VERSION.".\nCore functions are unavailable!\n"; + +if ($error) + die(CLI ? strip_tags($error) : $error); + + +require_once 'includes/defines.php'; +require_once 'includes/locale.class.php'; +require_once 'localization/lang.class.php'; +require_once 'localization/datetime.class.php'; +require_once 'includes/libs/autoload.php'; // Composer libraries +require_once 'includes/database.php'; // wrap dg/dibi (https://https://dibi.nette.org/) +require_once 'includes/utilities.php'; // helper functions +require_once 'includes/type.class.php'; // DB types storage and factory +require_once 'includes/cfg.class.php'; // Config holder +require_once 'includes/user.class.php'; // Session handling (could be skipped for CLI context except for username and password validation used in account creation) +require_once 'includes/game/misc.php'; // Misc game related data & functions + +// game client data interfaces +spl_autoload_register(function (string $class) : void +{ + if ($i = strrpos($class, '\\')) + $class = substr($class, $i + 1); + + if (preg_match('/[^\w]/i', $class)) + return; + + if ($class == 'Stat' || $class == 'StatsContainer') // entity statistics conversion + require_once 'includes/game/chrstatistics.php'; + else if (file_exists('includes/game/'.strtolower($class).'.class.php')) + require_once 'includes/game/'.strtolower($class).'.class.php'; + else if (file_exists('includes/game/loot/'.strtolower($class).'.class.php')) + require_once 'includes/game/loot/'.strtolower($class).'.class.php'; +}); + +// our site components +spl_autoload_register(function (string $class) : void +{ + if ($i = strrpos($class, '\\')) + $class = substr($class, $i + 1); + + if (preg_match('/[^\w]/i', $class)) + return; + + if (file_exists('includes/components/'.strtolower($class).'.class.php')) + require_once 'includes/components/'.strtolower($class).'.class.php'; + else if (file_exists('includes/components/frontend/'.strtolower($class).'.class.php')) + require_once 'includes/components/frontend/'.strtolower($class).'.class.php'; + else if (file_exists('includes/components/response/'.strtolower($class).'.class.php')) + require_once 'includes/components/response/'.strtolower($class).'.class.php'; +}); + +// TC systems in components +spl_autoload_register(function (string $class) : void +{ + switch ($class) + { + case __NAMESPACE__.'\SmartAI': + case __NAMESPACE__.'\SmartEvent': + case __NAMESPACE__.'\SmartAction': + case __NAMESPACE__.'\SmartTarget': + require_once 'includes/components/SmartAI/SmartAI.class.php'; + require_once 'includes/components/SmartAI/SmartEvent.class.php'; + require_once 'includes/components/SmartAI/SmartAction.class.php'; + require_once 'includes/components/SmartAI/SmartTarget.class.php'; + break; + case __NAMESPACE__.'\Conditions': + require_once 'includes/components/Conditions/Conditions.class.php'; + break; + } +}); + +// autoload List-classes, associated filters +spl_autoload_register(function (string $class) : void +{ + if ($i = strrpos($class, '\\')) + $class = substr($class, $i + 1); + + if (preg_match('/[^\w]/i', $class)) + return; + + if (!stripos($class, 'list')) + return; + + $class = strtolower(str_replace('ListFilter', 'List', $class)); + + $cl = match ($class) + { + 'localprofilelist', + 'remoteprofilelist' => 'profile', + 'localarenateamlist', + 'remotearenateamlist' => 'arenateam', + 'localguildlist', + 'remoteguildlist' => 'guild', + default => strtr($class, ['list' => '']) + }; + + if (file_exists('includes/dbtypes/'.$cl.'.class.php')) + require_once 'includes/dbtypes/'.$cl.'.class.php'; + else + throw new \Exception('could not register type class: '.$cl); +}); + +set_error_handler(function(int $errNo, string $errStr, string $errFile, int $errLine) : bool +{ + // either from test function or handled separately + if (strstr($errStr, 'mysqli_connect') && $errNo == E_WARNING) + return true; + + // do not log XDebug shenanigans + if (strstr($errFile, 'xdebug://')) + return true; + + // we do not log deprecation notices + if ($errNo & (E_DEPRECATED | E_USER_DEPRECATED)) + return true; + + $logLevel = match($errNo) + { + E_RECOVERABLE_ERROR, E_USER_ERROR => LOG_LEVEL_ERROR, + E_WARNING, E_USER_WARNING => LOG_LEVEL_WARN, + E_NOTICE, E_USER_NOTICE => LOG_LEVEL_INFO, + default => 0 + }; + $errName = match($errNo) + { + E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR', + E_USER_ERROR => 'USER_ERROR', + E_USER_WARNING, E_WARNING => 'WARNING', + E_USER_NOTICE, E_NOTICE => 'NOTICE', + default => 'UNKNOWN_ERROR' // errors not in this list can not be handled by set_error_handler (as per documentation) or are ignored + }; + + if (!empty($_POST['password'])) + $_POST['password'] = '******'; + if (!empty($_POST['c_password'])) + $_POST['c_password'] = '******'; + + if (DB::isConnected(DB_AOWOW)) + DB::Aowow()->qry('INSERT INTO ::errors (`date`, `version`, `phpError`, `file`, `line`, `query`, `post`, `userGroups`, `message`) VALUES (UNIX_TIMESTAMP(), %i, %i, %s, %i, %s, %s, %i, %s) ON DUPLICATE KEY UPDATE `date` = UNIX_TIMESTAMP()', + AOWOW_REVISION, $errNo, $errFile, $errLine, CLI ? 'CLI' : substr($_SERVER['QUERY_STRING'] ?? '', 0, 250), empty($_POST) ? '' : http_build_query($_POST), User::$groups, $errStr + ); + + $logMsg = $errName.' - '.$errStr.' @ '.$errFile. ':'.$errLine; + if (CLI && class_exists(__NAMESPACE__.'\CLI')) + CLI::write($logMsg, $logLevel); + else if (CLI) + fwrite(STDERR, $logMsg); + else if (Cfg::get('DEBUG') >= $logLevel) + Util::addNote($logMsg, U_GROUP_EMPLOYEE, $logLevel); + + return true; +}, E_ALL); + +// handle exceptions +set_exception_handler(function (\Throwable $e) : void +{ + if (!empty($_POST['password'])) + $_POST['password'] = '******'; + if (!empty($_POST['c_password'])) + $_POST['c_password'] = '******'; + + if (DB::isConnected(DB_AOWOW)) + DB::Aowow()->qry('INSERT INTO ::errors (`date`, `version`, `phpError`, `file`, `line`, `query`, `post`, `userGroups`, `message`) VALUES (UNIX_TIMESTAMP(), %i, %i, %s, %i, %s, %s, %i, %s) ON DUPLICATE KEY UPDATE `date` = UNIX_TIMESTAMP()', + AOWOW_REVISION, $e->getCode(), $e->getFile(), $e->getLine(), CLI ? 'CLI' : substr($_SERVER['QUERY_STRING'] ?? '', 0, 250), empty($_POST) ? '' : http_build_query($_POST), User::$groups, $e->getMessage() + ); + + if (CLI) + fwrite(STDERR, "\nException - ".$e->getMessage()."\n ".$e->getFile(). '('.$e->getLine().")\n".$e->getTraceAsString()."\n\n"); + else + { + Util::addNote('Exception - '.$e->getMessage().' @ '.$e->getFile(). ':'.$e->getLine()."\n".$e->getTraceAsString(), U_GROUP_EMPLOYEE, LOG_LEVEL_ERROR); + (new TemplateResponse())->generateError(); + } +}); + +// handle fatal errors +register_shutdown_function(function() : void +{ + // defer undisplayed error/exception notes + if (!CLI && ($n = Util::getNotes())) + $_SESSION['notes'][] = [$n[0], $n[1], 'Deferred issues from previous request']; + + if ($e = error_get_last()) + { + if (!empty($_POST['password'])) + $_POST['password'] = '******'; + if (!empty($_POST['c_password'])) + $_POST['c_password'] = '******'; + + if (DB::isConnected(DB_AOWOW)) + DB::Aowow()->qry('INSERT INTO ::errors (`date`, `version`, `phpError`, `file`, `line`, `query`, `post`, `userGroups`, `message`) VALUES (UNIX_TIMESTAMP(), %i, %i, %s, %i, %s, %s, %i, %s) ON DUPLICATE KEY UPDATE `date` = UNIX_TIMESTAMP()', + AOWOW_REVISION, $e['type'], $e['file'], $e['line'], CLI ? 'CLI' : substr($_SERVER['QUERY_STRING'] ?? '', 0, 250), empty($_POST) ? '' : http_build_query($_POST), User::$groups, $e['message'] + ); + + if (CLI) + fwrite(STDERR, "\nFatal Error - ".$e['message'].' @ '.$e['file']. ':'.$e['line']."\n\n"); + else if (User::isInGroup(U_GROUP_EMPLOYEE)) + echo "\nFatal Error - ".$e['message'].' @ '.$e['file']. ':'.$e['line']."\n\n"; + } +}); + +// Setup DB-Wrapper if (file_exists('config/config.php')) require_once 'config/config.php'; else $AoWoWconf = []; - -mb_internal_encoding('UTF-8'); - - -define('OS_WIN', substr(PHP_OS, 0, 3) == 'WIN'); - - -require_once 'includes/defines.php'; -require_once 'includes/libs/DbSimple/Generic.php'; // Libraray: http://en.dklab.ru/lib/DbSimple (using variant: https://github.com/ivan1986/DbSimple/tree/master) -require_once 'includes/utilities.php'; // helper functions -require_once 'includes/game.php'; // game related data & functions -require_once 'includes/profiler.class.php'; -require_once 'includes/user.class.php'; -require_once 'includes/markup.class.php'; // manipulate markup text -require_once 'includes/database.class.php'; // wrap DBSimple -require_once 'includes/community.class.php'; // handle comments, screenshots and videos -require_once 'includes/loot.class.php'; // build lv-tabs containing loot-information -require_once 'includes/smartAI.class.php'; -require_once 'localization/lang.class.php'; -require_once 'pages/genericPage.class.php'; - - -// autoload List-classes, associated filters and pages -spl_autoload_register(function ($class) { - $class = strtolower(str_replace('ListFilter', 'List', $class)); - - if (class_exists($class)) // already registered - return; - - if (preg_match('/[^\w]/i', $class)) // name should contain only letters - return; - - if (stripos($class, 'list')) - { - require_once 'includes/basetype.class.php'; - - $cl = strtr($class, ['list' => '']); - if ($cl == 'remoteprofile' || $cl == 'localprofile') - $cl = 'profile'; - if ($cl == 'remotearenateam' || $cl == 'localarenateam') - $cl = 'arenateam'; - if ($cl == 'remoteguild' || $cl == 'localguild') - $cl = 'guild'; - - if (file_exists('includes/types/'.$cl.'.class.php')) - require_once 'includes/types/'.$cl.'.class.php'; - else - throw new Exception('could not register type class: '.$cl); - - return; - } - else if (stripos($class, 'ajax') === 0) - { - require_once 'includes/ajaxHandler.class.php'; // handles ajax and jsonp requests - - if (file_exists('includes/ajaxHandler/'.strtr($class, ['ajax' => '']).'.class.php')) - require_once 'includes/ajaxHandler/'.strtr($class, ['ajax' => '']).'.class.php'; - else - throw new Exception('could not register ajaxHandler class: '.$class); - - return; - } - else if (file_exists('pages/'.strtr($class, ['page' => '']).'.php')) - require_once 'pages/'.strtr($class, ['page' => '']).'.php'; -}); - - -// Setup DB-Wrapper if (!empty($AoWoWconf['aowow']['db'])) DB::load(DB_AOWOW, $AoWoWconf['aowow']); @@ -91,188 +246,43 @@ if (!empty($AoWoWconf['characters'])) if (!empty($charDBInfo)) DB::load(DB_CHARACTERS . $realm, $charDBInfo); +$AoWoWconf = null; // empty auths -// load config to constants -function loadConfig(bool $noPHP = false) : void -{ - $sets = DB::isConnectable(DB_AOWOW) ? DB::Aowow()->select('SELECT `key` AS ARRAY_KEY, `value`, `flags` FROM ?_config') : []; - foreach ($sets as $k => $v) - { - $php = $v['flags'] & CON_FLAG_PHP; - if ($php && $noPHP) - continue; - // this should not have been possible - if (!strlen($v['value']) && !($v['flags'] & CON_FLAG_TYPE_STRING) && !$php) - { - trigger_error('Aowow config value CFG_'.strtoupper($k).' is empty - config will not be used!', E_USER_ERROR); - continue; - } +// for CLI and early errors in erb context +Lang::load(Locale::EN); - if ($v['flags'] & CON_FLAG_TYPE_INT) - $val = intVal($v['value']); - else if ($v['flags'] & CON_FLAG_TYPE_FLOAT) - $val = floatVal($v['value']); - else if ($v['flags'] & CON_FLAG_TYPE_BOOL) - $val = (bool)$v['value']; - else if ($v['flags'] & CON_FLAG_TYPE_STRING) - $val = preg_replace("/[\p{C}]/ui", '', $v['value']); - else if ($php) - { - trigger_error('PHP config value '.strtolower($k).' has no type set - config will not be used!', E_USER_ERROR); - continue; - } - else // if (!$php) - { - trigger_error('Aowow config value CFG_'.strtoupper($k).' has no type set - value forced to 0!', E_USER_ERROR); - $val = 0; - } - - if ($php) - ini_set(strtolower($k), $val); - else if (!defined('CFG_'.strtoupper($k))) - define('CFG_'.strtoupper($k), $val); - } -} -loadConfig(); - -// handle non-fatal errors and notices -error_reporting(!empty($AoWoWconf['aowow']) && CFG_DEBUG ? E_AOWOW : 0); -set_error_handler(function($errNo, $errStr, $errFile, $errLine) -{ - $errName = 'unknown error'; // errors not in this list can not be handled by set_error_handler (as per documentation) or are ignored - $uGroup = U_GROUP_EMPLOYEE; - - if ($errNo == E_WARNING) // 0x0002 - $errName = 'E_WARNING'; - else if ($errNo == E_PARSE) // 0x0004 - $errName = 'E_PARSE'; - else if ($errNo == E_NOTICE) // 0x0008 - $errName = 'E_NOTICE'; - else if ($errNo == E_USER_ERROR) // 0x0100 - $errName = 'E_USER_ERROR'; - else if ($errNo == E_USER_WARNING) // 0x0200 - $errName = 'E_USER_WARNING'; - else if ($errNo == E_USER_NOTICE) // 0x0400 - { - $errName = 'E_USER_NOTICE'; - $uGroup = U_GROUP_STAFF; - } - else if ($errNo == E_RECOVERABLE_ERROR) // 0x1000 - $errName = 'E_RECOVERABLE_ERROR'; - - Util::addNote($uGroup, $errName.' - '.$errStr.' @ '.$errFile. ':'.$errLine); - if (CLI) - CLI::write($errName.' - '.$errStr.' @ '.$errFile. ':'.$errLine, $errNo & 0x40A ? CLI::LOG_WARN : CLI::LOG_ERROR); - - if (DB::isConnectable(DB_AOWOW)) - DB::Aowow()->query('INSERT INTO ?_errors (`date`, `version`, `phpError`, `file`, `line`, `query`, `userGroups`, `message`) VALUES (UNIX_TIMESTAMP(), ?d, ?d, ?, ?d, ?, ?d, ?) ON DUPLICATE KEY UPDATE `date` = UNIX_TIMESTAMP()', - AOWOW_REVISION, $errNo, $errFile, $errLine, CLI ? 'CLI' : ($_SERVER['QUERY_STRING'] ?? ''), User::$groups, $errStr - ); - - return true; -}, E_AOWOW); - -// handle exceptions -set_exception_handler(function ($ex) -{ - Util::addNote(U_GROUP_EMPLOYEE, 'Exception - '.$ex->getMessage().' @ '.$ex->getFile(). ':'.$ex->getLine()."\n".$ex->getTraceAsString()); - - if (DB::isConnectable(DB_AOWOW)) - DB::Aowow()->query('INSERT INTO ?_errors (`date`, `version`, `phpError`, `file`, `line`, `query`, `userGroups`, `message`) VALUES (UNIX_TIMESTAMP(), ?d, ?d, ?, ?d, ?, ?d, ?) ON DUPLICATE KEY UPDATE `date` = UNIX_TIMESTAMP()', - AOWOW_REVISION, $ex->getCode(), $ex->getFile(), $ex->getLine(), CLI ? 'CLI' : ($_SERVER['QUERY_STRING'] ?? ''), User::$groups, $ex->getMessage() - ); - - if (!CLI) - (new GenericPage())->error(); - else - echo 'Exception - '.$ex->getMessage()."\n ".$ex->getFile(). '('.$ex->getLine().")\n".$ex->getTraceAsString()."\n"; -}); - -// handle fatal errors -register_shutdown_function(function() -{ - if (($e = error_get_last()) && $e['type'] & (E_ERROR | E_COMPILE_ERROR | E_CORE_ERROR)) - { - Util::addNote(U_GROUP_EMPLOYEE, 'Fatal Error - '.$e['message'].' @ '.$e['file']. ':'.$e['line']); - - if (DB::isConnectable(DB_AOWOW)) - DB::Aowow()->query('INSERT INTO ?_errors (`date`, `version`, `phpError`, `file`, `line`, `query`, `userGroups`, `message`) VALUES (UNIX_TIMESTAMP(), ?d, ?d, ?, ?d, ?, ?d, ?) ON DUPLICATE KEY UPDATE `date` = UNIX_TIMESTAMP()', - AOWOW_REVISION, $e['type'], $e['file'], $e['line'], CLI ? 'CLI' : ($_SERVER['QUERY_STRING'] ?? ''), User::$groups, $e['message'] - ); - - if (CLI) - echo 'Fatal Error - '.$e['message'].' @ '.$e['file']. ':'.$e['line']."\n"; - - // cant generate a page for web view :( - die(); - } -}); - -$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') || (!empty($AoWoWconf['aowow']) && CFG_FORCE_SSL); -if (defined('CFG_STATIC_HOST')) // points js to images & scripts - define('STATIC_URL', ($secure ? 'https://' : 'http://').CFG_STATIC_HOST); - -if (defined('CFG_SITE_HOST')) // points js to executable files - define('HOST_URL', ($secure ? 'https://' : 'http://').CFG_SITE_HOST); +// load config from DB +Cfg::load(); if (!CLI) { - if (!defined('CFG_SITE_HOST') || !defined('CFG_STATIC_HOST')) - die('error: SITE_HOST or STATIC_HOST not configured'); + // not displaying the brb gnomes as static_host is missing, but eh... + if (!DB::isConnected(DB_AOWOW) || !DB::isConnected(DB_WORLD) || !Cfg::get('HOST_URL') || !Cfg::get('STATIC_URL')) + (new TemplateResponse())->generateMaintenance(); // Setup Session - if (CFG_SESSION_CACHE_DIR && Util::writeDir(CFG_SESSION_CACHE_DIR)) - session_save_path(getcwd().'/'.CFG_SESSION_CACHE_DIR); + $cacheDir = Cfg::get('SESSION_CACHE_DIR'); + if ($cacheDir && Util::writeDir($cacheDir)) + session_save_path(getcwd().'/'.$cacheDir); - session_set_cookie_params(15 * YEAR, '/', '', $secure, true); + session_set_cookie_params(15 * YEAR, '/', '', (($_SERVER['HTTPS'] ?? 'off') != 'off') || Cfg::get('FORCE_SSL'), true); session_cache_limiter('private'); if (!session_start()) { trigger_error('failed to start session', E_USER_ERROR); - exit; + (new TemplateResponse())->generateError(); } - if (!empty($AoWoWconf['aowow']) && User::init()) + if (User::init()) User::save(); // save user-variables in session - // set up some logging (~10 queries will execute before we init the user and load the config) - if (CFG_DEBUG && User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN)) - { - DB::Aowow()->setLogger(['DB', 'logger']); - DB::World()->setLogger(['DB', 'logger']); - if (DB::isConnected(DB_AUTH)) - DB::Auth()->setLogger(['DB', 'logger']); - - if (!empty($AoWoWconf['characters'])) - foreach ($AoWoWconf['characters'] as $idx => $__) - if (DB::isConnected(DB_CHARACTERS . $idx)) - DB::Characters($idx)->setLogger(['DB', 'logger']); - } - - // hard-override locale for this call (should this be here..?) - // all strings attached.. - if (!empty($AoWoWconf['aowow'])) - { - if (isset($_GET['locale']) && (int)$_GET['locale'] <= MAX_LOCALES && (int)$_GET['locale'] >= 0) - if (CFG_LOCALES & (1 << $_GET['locale'])) - User::useLocale($_GET['locale']); - - Lang::load(User::$localeString); - } - - // parse page-parameters .. sanitize before use! - $str = explode('&', mb_strtolower($_SERVER['QUERY_STRING'] ?? ''), 2)[0]; - $_ = explode('=', $str, 2); - $pageCall = $_[0]; - $pageParam = $_[1] ?? ''; - - Util::$wowheadLink = 'http://'.Util::$subDomains[User::$localeId].'.wowhead.com/'.$str; + // hard override locale for this call (should this be here..?) + if (isset($_GET['locale']) && ($loc = Locale::tryFrom((int)$_GET['locale']))) + Lang::load($loc); + else + Lang::load(User::$preferedLoc); } -else if (!empty($AoWoWconf['aowow'])) - Lang::load('enus'); - -$AoWoWconf = null; // empty auths ?> diff --git a/includes/libs/DbSimple/CacherImpl.php b/includes/libs/DbSimple/CacherImpl.php deleted file mode 100644 index 986cb130..00000000 --- a/includes/libs/DbSimple/CacherImpl.php +++ /dev/null @@ -1,45 +0,0 @@ -callback = $callback; - } else { - $this->callback = $this->callbackDummy; - } - } - - public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array()) {} - - public function remove($id) {} - - public function test($id) {} - - public function save($data, $id, $tags = array(), $specificLifetime = false) - { - return call_user_func($this->callback, $id, $data); - } - - public function load($id, $doNotTestCacheValidity = false) - { - return call_user_func($this->callback, $id); - } - - public function setDirectives($directives) {} - - protected function callbackDummy($k, $v) - { - return null; - } - -} // CacherImpl class \ No newline at end of file diff --git a/includes/libs/DbSimple/Connect.php b/includes/libs/DbSimple/Connect.php deleted file mode 100644 index b9d19c62..00000000 --- a/includes/libs/DbSimple/Connect.php +++ /dev/null @@ -1,261 +0,0 @@ -нужен для ленивой инициализации коннекта к базе - * - * @package DbSimple - * @method mixed transaction(string $mode=null) - * @method mixed commit() - * @method mixed rollback() - * @method mixed select(string $query [, $arg1] [,$arg2] ...) - * @method mixed selectRow(string $query [, $arg1] [,$arg2] ...) - * @method array selectCol(string $query [, $arg1] [,$arg2] ...) - * @method string selectCell(string $query [, $arg1] [,$arg2] ...) - * @method mixed query(string $query [, $arg1] [,$arg2] ...) - * @method string escape(mixed $s, bool $isIdent=false) - * @method DbSimple_SubQuery subquery(string $query [, $arg1] [,$arg2] ...) - * @method callback setLogger(callback $logger) - * @method callback setCacher(callback $cacher) - * @method string setIdentPrefix($prx) - * @method string setCachePrefix($prx) - */ -class DbSimple_Connect -{ - /** @var DbSimple_Generic_Database База данных */ - protected $DbSimple; - /** @var string DSN подключения */ - protected $DSN; - /** @var string Тип базы данных */ - protected $shema; - /** @var array Что выставить при коннекте */ - protected $init; - /** @var integer код ошибки */ - public $error = null; - /** @var string сообщение об ошибке */ - public $errmsg = null; - - /** - * Конструктор только запоминает переданный DSN - * создание класса и коннект происходит позже - * - * @param string $dsn DSN строка БД - */ - public function __construct($dsn) - { - $this->DbSimple = null; - $this->DSN = $dsn; - $this->init = array(); - $this->shema = ucfirst(substr($dsn, 0, strpos($dsn, ':'))); - } - - /** - * Взять базу из пула коннектов - * - * @param string $dsn DSN строка БД - * @return DbSimple_Connect - */ - public static function get($dsn) - { - static $pool = array(); - return isset($pool[$dsn]) ? $pool[$dsn] : $pool[$dsn] = new self($dsn); - } - - /** - * Возвращает тип базы данных - * - * @return string имя типа БД - */ - public function getShema() - { - return $this->shema; - } - - /** - * Коннект при первом запросе к базе данных - */ - public function __call($method, $params) - { - if ($this->DbSimple === null) - $this->connect($this->DSN); - return call_user_func_array(array(&$this->DbSimple, $method), $params); - } - - /** - * mixed selectPage(int &$total, string $query [, $arg1] [,$arg2] ...) - * Функцию нужно вызвать отдельно из-за передачи по ссылке - */ - public function selectPage(&$total, $query) - { - if ($this->DbSimple === null) - $this->connect($this->DSN); - $args = func_get_args(); - $args[0] = &$total; - return call_user_func_array(array(&$this->DbSimple, 'selectPage'), $args); - } - - /** - * Подключение к базе данных - * @param string $dsn DSN строка БД - */ - protected function connect($dsn) - { - $parsed = $this->parseDSN($dsn); - if (!$parsed) - $this->errorHandler('Ошибка разбора строки DSN', $dsn); - if (!isset($parsed['scheme'])) - $this->errorHandler('Невозможно загрузить драйвер базы данных', $parsed); - $this->shema = ucfirst($parsed['scheme']); - require_once __DIR__.'/'.$this->shema.'.php'; - $class = 'DbSimple_'.$this->shema; - $this->DbSimple = new $class($parsed); - $this->errmsg = &$this->DbSimple->errmsg; - $this->error = &$this->DbSimple->error; - $prefix = isset($parsed['prefix']) ? $parsed['prefix'] : ($this->_identPrefix ? $this->_identPrefix : false); - if ($prefix) - $this->DbSimple->setIdentPrefix($prefix); - if ($this->_cachePrefix) $this->DbSimple->setCachePrefix($this->_cachePrefix); - if ($this->_cacher) $this->DbSimple->setCacher($this->_cacher); - if ($this->_logger) $this->DbSimple->setLogger($this->_logger); - $this->DbSimple->setErrorHandler($this->errorHandler!==null ? $this->errorHandler : array(&$this, 'errorHandler')); - //выставление переменных - foreach($this->init as $query) - call_user_func_array(array(&$this->DbSimple, 'query'), $query); - $this->init = array(); - } - - /** - * Функция обработки ошибок - стандартный обработчик - * Все вызовы без @ прекращают выполнение скрипта - * - * @param string $msg Сообщение об ошибке - * @param array $info Подробная информация о контексте ошибки - */ - public function errorHandler($msg, $info) - { - // Если использовалась @, ничего не делать. - if (!error_reporting()) return; - // Выводим подробную информацию об ошибке. - echo "SQL Error: $msg
";
-		print_r($info);
-		echo "
"; - exit(); - } - - /** - * Выставляет запрос для инициализации - * - * @param string $query запрос - */ - public function addInit(...$args) - { - if ($this->DbSimple !== null) - return call_user_func_array(array(&$this->DbSimple, 'query'), $args); - $this->init[] = $args; - } - - /** - * Устанавливает новый обработчик ошибок - * Обработчик получает 2 аргумента: - * - сообщение об ошибке - * - массив (код, сообщение, запрос, контекст) - * - * @param callback|null|false $handler обработчик ошибок - *
null - по умолчанию - *
false - отключен - * @return callback|null|false предыдущий обработчик - */ - public function setErrorHandler($handler) - { - $prev = $this->errorHandler; - $this->errorHandler = $handler; - if ($this->DbSimple) - $this->DbSimple->setErrorHandler($handler); - return $prev; - } - - /** @var callback обработчик ошибок */ - private $errorHandler = null; - private $_cachePrefix = ''; - private $_identPrefix = null; - private $_logger = null; - private $_cacher = null; - - /** - * callback setLogger(callback $logger) - * Set query logger called before each query is executed. - * Returns previous logger. - */ - public function setLogger($logger) - { - $prev = $this->_logger; - $this->_logger = $logger; - if ($this->DbSimple) - $this->DbSimple->setLogger($logger); - return $prev; - } - - /** - * callback setCacher(callback $cacher) - * Set cache mechanism called during each query if specified. - * Returns previous handler. - */ - public function setCacher(Zend_Cache_Backend_Interface $cacher=null) - { - $prev = $this->_cacher; - $this->_cacher = $cacher; - if ($this->DbSimple) - $this->DbSimple->setCacher($cacher); - return $prev; - } - - /** - * string setIdentPrefix($prx) - * Set identifier prefix used for $_ placeholder. - */ - public function setIdentPrefix($prx) - { - $old = $this->_identPrefix; - if ($prx !== null) $this->_identPrefix = $prx; - if ($this->DbSimple) - $this->DbSimple->setIdentPrefix($prx); - return $old; - } - - /** - * string setCachePrefix($prx) - * Set cache prefix used in key caclulation. - */ - public function setCachePrefix($prx) - { - $old = $this->_cachePrefix; - if ($prx !== null) $this->_cachePrefix = $prx; - if ($this->DbSimple) - $this->DbSimple->setCachePrefix($prx); - return $old; - } - - /** - * Разбирает строку DSN в массив параметров подключения к базе - * - * @param string $dsn строка DSN для разбора - * @return array Параметры коннекта - */ - protected function parseDSN($dsn) - { - return DbSimple_Generic::parseDSN($dsn); - } - -} diff --git a/includes/libs/DbSimple/Database.php b/includes/libs/DbSimple/Database.php deleted file mode 100644 index 467a98a1..00000000 --- a/includes/libs/DbSimple/Database.php +++ /dev/null @@ -1,1404 +0,0 @@ -. - * - * Contains 3 classes: - * - DbSimple_Database: common database methods - * - DbSimple_Blob: common BLOB support - * - DbSimple_LastError: error reporting and tracking - * - * Special result-set fields: - * - ARRAY_KEY* ("*" means "anything") - * - PARENT_KEY - * - * Transforms: - * - GET_ATTRIBUTES - * - CALC_TOTAL - * - GET_TOTAL - * - UNIQ_KEY - * - * Query attributes: - * - BLOB_OBJ - * - CACHE - * - * @author Dmitry Koterov, http://forum.dklab.ru/users/DmitryKoterov/ - * @author Konstantin Zhinko, http://forum.dklab.ru/users/KonstantinGinkoTit/ - * @author Ivan Borzenkov, http://forum.dklab.ru/users/Ivan1986/ - * - * @version 2.x $Id$ - */ - -/** - * Use this constant as placeholder value to skip optional SQL block [...]. - */ -if (!defined('DBSIMPLE_SKIP')) - define('DBSIMPLE_SKIP', log(0)); - -/** - * Names of special columns in result-set which is used - * as array key (or karent key in forest-based resultsets) in - * resulting hash. - */ -if (!defined('DBSIMPLE_ARRAY_KEY')) - define('DBSIMPLE_ARRAY_KEY', 'ARRAY_KEY'); // hash-based resultset support -if (!defined('DBSIMPLE_PARENT_KEY')) - define('DBSIMPLE_PARENT_KEY', 'PARENT_KEY'); // forrest-based resultset support - - -if ( !interface_exists('Zend_Cache_Backend_Interface', false) ) { - require_once __DIR__ . '/Zend/Cache.php'; - require_once __DIR__ . '/Zend/Cache/Backend/Interface.php'; -} - -require_once __DIR__ . '/CacherImpl.php'; - - -/** - * - * Base class for all databases. - * Can create transactions and new BLOBs, parse DSNs. - * - * Logger is COMMON for multiple transactions. - * Error handler is private for each transaction and database. - */ -abstract class DbSimple_Database extends DbSimple_LastError -{ - /** - * Public methods. - */ - - /** - * object blob($blob_id) - * Create new blob - */ - public function blob($blob_id = null) - { - $this->_resetLastError(); - return $this->_performNewBlob($blob_id); - } - - /** - * void transaction($mode) - * Create new transaction. - */ - public function transaction($mode=null) - { - $this->_resetLastError(); - $this->_logQuery('-- START TRANSACTION '.$mode); - return $this->_performTransaction($mode); - } - - /** - * mixed commit() - * Commit the transaction. - */ - public function commit() - { - $this->_resetLastError(); - $this->_logQuery('-- COMMIT'); - return $this->_performCommit(); - } - - /** - * mixed rollback() - * Rollback the transaction. - */ - public function rollback() - { - $this->_resetLastError(); - $this->_logQuery('-- ROLLBACK'); - return $this->_performRollback(); - } - - /** - * mixed select(string $query [, $arg1] [,$arg2] ...) - * Execute query and return the result. - */ - public function select(...$args) - { - $total = false; - return $this->_query($args, $total); - } - - /** - * mixed selectPage(int &$total, string $query [, $arg1] [,$arg2] ...) - * Execute query and return the result. - * Total number of found rows (independent to LIMIT) is returned in $total - * (in most cases second query is performed to calculate $total). - */ - public function selectPage(&$total, ...$args) - { - $total = true; - return $this->_query($args, $total); - } - - /** - * hash selectRow(string $query [, $arg1] [,$arg2] ...) - * Return the first row of query result. - * On errors return false and set last error. - * If no one row found, return array()! It is useful while debugging, - * because PHP DOES NOT generates notice on $row['abc'] if $row === null - * or $row === false (but, if $row is empty array, notice is generated). - */ - public function selectRow(...$args) - { - $total = false; - $rows = $this->_query($args, $total); - if (!is_array($rows)) return $rows; - if (!count($rows)) return array(); - reset($rows); - return current($rows); - } - - /** - * array selectCol(string $query [, $arg1] [,$arg2] ...) - * Return the first column of query result as array. - */ - public function selectCol(...$args) - { - $total = false; - $rows = $this->_query($args, $total); - if (!is_array($rows)) return $rows; - $this->_shrinkLastArrayDimensionCallback($rows); - return $rows; - } - - /** - * scalar selectCell(string $query [, $arg1] [,$arg2] ...) - * Return the first cell of the first column of query result. - * If no one row selected, return null. - */ - public function selectCell(...$args) - { - $total = false; - $rows = $this->_query($args, $total); - if (!is_array($rows)) return $rows; - if (!count($rows)) return null; - reset($rows); - $row = current($rows); - if (!is_array($row)) return $row; - reset($row); - return current($row); - } - - /** - * mixed query(string $query [, $arg1] [,$arg2] ...) - * Alias for select(). May be used for INSERT or UPDATE queries. - */ - public function query(...$args) - { - $total = false; - return $this->_query($args, $total); - } - - /** - * string escape(mixed $s, bool $isIdent=false) - * Enclose the string into database quotes correctly escaping - * special characters. If $isIdent is true, value quoted as identifier - * (e.g.: `value` in MySQL, "value" in Firebird, [value] in MSSQL). - */ - public function escape($s, $isIdent=false) - { - return $this->_performEscape($s, $isIdent); - } - - - /** - * DbSimple_SubQuery subquery(string $query [, $arg1] [,$arg2] ...) - * Выполняет разворачивание плейсхолдеров без коннекта к базе - * Нужно для сложных запросов, состоящих из кусков, которые полезно сохранить - * - */ - public function subquery(...$args) - { - $this->_expandPlaceholders($args,$this->_placeholderNativeArgs !== null); - return new DbSimple_SubQuery($args); - } - - - /** - * callback setLogger(callback $logger) - * Set query logger called before each query is executed. - * Returns previous logger. - */ - public function setLogger($logger) - { - $prev = $this->_logger; - $this->_logger = $logger; - return $prev; - } - - /** - * callback setCacher(callback $cacher) - * Set cache mechanism called during each query if specified. - * Returns previous handler. - */ - public function setCacher($cacher=null) - { - $prev = $this->_cacher; - - if ( is_null($cacher) ) { - return $prev; - } - - if ($cacher instanceof Zend_Cache_Backend_Interface) { - $this->_cacher = $cacher; - return $prev; - } - - if ( is_callable($cacher) ) { - $this->_cacher = new CacherImpl($cacher); - return $prev; - } - - return $prev; - } - - /** - * string setIdentPrefix($prx) - * Set identifier prefix used for $_ placeholder. - */ - public function setIdentPrefix($prx) - { - $old = $this->_identPrefix; - if ($prx !== null) $this->_identPrefix = $prx; - return $old; - } - - /** - * string setCachePrefix($prx) - * Set cache prefix used in key caclulation. - */ - public function setCachePrefix($prx) - { - $old = $this->_cachePrefix; - if ($prx !== null) $this->_cachePrefix = $prx; - return $old; - } - - /** - * Задает имя класса строки - * - *
для следующего запроса каждая строка будет - * заменена классом, конструктору которого передается - * массив поле=>значение для этой строки - * - * @param string $name имя класса - * @return DbSimple_Generic_Database указатель на себя - */ - public function setClassName($name) - { - $this->_className = $name; - return $this; - } - - /** - * array getStatistics() - * Returns various statistical information. - */ - public function getStatistics() - { - return $this->_statistics; - } - - - /** - * string _performEscape(mixed $s, bool $isIdent=false) - */ - abstract protected function _performEscape($s, $isIdent=false); - - /** - * object _performNewBlob($id) - * - * Returns new blob object. - */ - abstract protected function _performNewBlob($id=null); - - /** - * list _performGetBlobFieldNames($resultResource) - * Get list of all BLOB field names in result-set. - */ - abstract protected function _performGetBlobFieldNames($result); - - /** - * mixed _performTransformQuery(array &$query, string $how) - * - * Transform query different way specified by $how. - * May return some information about performed transform. - */ - abstract protected function _performTransformQuery(&$queryMain, $how); - - - /** - * resource _performQuery($arrayQuery) - * Must return: - * - For SELECT queries: ID of result-set (PHP resource). - * - For other queries: query status (scalar). - * - For error queries: false (and call _setLastError()). - */ - abstract protected function _performQuery($arrayQuery); - - /** - * mixed _performFetch($resultResource) - * Fetch ONE NEXT row from result-set. - * Must return: - * - For SELECT queries: all the rows of the query (2d arrray). - * - For INSERT queries: ID of inserted row. - * - For UPDATE queries: number of updated rows. - * - For other queries: query status (scalar). - * - For error queries: false (and call _setLastError()). - */ - abstract protected function _performFetch($result); - - /** - * mixed _performTransaction($mode) - * Start new transaction. - */ - abstract protected function _performTransaction($mode=null); - - /** - * mixed _performCommit() - * Commit the transaction. - */ - abstract protected function _performCommit(); - - /** - * mixed _performRollback() - * Rollback the transaction. - */ - abstract protected function _performRollback(); - - /** - * string _performGetPlaceholderIgnoreRe() - * Return regular expression which matches ignored query parts. - * This is needed to skip placeholder replacement inside comments, constants etc. - */ - protected function _performGetPlaceholderIgnoreRe() - { - return ''; - } - - /** - * Returns marker for native database placeholder. E.g. in FireBird it is '?', - * in PostgreSQL - '$1', '$2' etc. - * - * @param int $n Number of native placeholder from the beginning of the query (begins from 0!). - * @return string String representation of native placeholder marker (by default - '?'). - */ - protected function _performGetNativePlaceholderMarker($n) - { - return '?'; - } - - - /** - * array parseDSN(mixed $dsn) - * Parse a data source name. - * See parse_url() for details. - */ - protected function parseDSN($dsn) - { - if (is_array($dsn)) return $dsn; - $parsed = @parse_url($dsn); - if (!$parsed) return null; - $params = null; - if (!empty($parsed['query'])) { - parse_str($parsed['query'], $params); - $parsed += $params; - } - $parsed['dsn'] = $dsn; - return $parsed; - } - - - /** - * array _query($query, &$total) - * See _performQuery(). - */ - private function _query($query, &$total) - { - $this->_resetLastError(); - - // Fetch query attributes. - $this->attributes = $this->_transformQuery($query, 'GET_ATTRIBUTES'); - - // Modify query if needed for total counting. - if ($total) - $this->_transformQuery($query, 'CALC_TOTAL'); - - $rows = false; - $cache_it = false; - // Кешер у нас либо null либо соответствует Zend интерфейсу - if ( !empty($this->attributes['CACHE']) && ($this->_cacher instanceof Zend_Cache_Backend_Interface) ) - { - - $hash = $this->_cachePrefix . md5(serialize($query)); - // Getting data from cache if possible - $fetchTime = $firstFetchTime = 0; - $qStart = microtime(true); - $cacheData = unserialize($this->_cacher->load($hash)); - $queryTime = microtime(true) - $qStart; - - $invalCache = isset($cacheData['invalCache']) ? $cacheData['invalCache'] : null; - $result = isset($cacheData['result']) ? $cacheData['result'] : null; - $rows = isset($cacheData['rows']) ? $cacheData['rows'] : null; - - - $cache_params = $this->attributes['CACHE']; - - // Calculating cache time to live - $re = '/ - (?> - ([0-9]+) #1 - hours - h)? [ \t]* - (?> - ([0-9]+) #2 - minutes - m)? [ \t]* - (?> - ([0-9]+) #3 - seconds - s?)? (,)? - /sx'; - $m = null; - preg_match($re, $cache_params, $m); - $ttl = (isset($m[3])?$m[3]:0) - + (isset($m[2])?$m[2]:0) * 60 - + (isset($m[1])?$m[1]:0) * 3600; - // Cutting out time param - now there are just fields for uniqKey or nothing - $cache_params = trim(preg_replace($re, '', $cache_params, 1)); - - $uniq_key = null; - - // UNIQ_KEY calculation - if (!empty($cache_params)) { - $dummy = null; - // There is no need in query, cos' needle in $this->attributes['CACHE'] - $this->_transformQuery($dummy, 'UNIQ_KEY'); - $uniq_key = call_user_func(array(&$this, 'select'), $dummy); - $uniq_key = md5(serialize($uniq_key)); - } - // Check TTL? - $ok = empty($ttl) || $cacheData; - - // Invalidate cache? - if ($ok && $uniq_key == $invalCache) { - $this->_logQuery($query); - $this->_logQueryStat($queryTime, $fetchTime, $firstFetchTime, $rows); - - } - else $cache_it = true; - } - - if (false === $rows || true === $cache_it) { - $this->_logQuery($query); - - // Run the query (counting time). - $qStart = microtime(true); - $result = $this->_performQuery($query); - $fetchTime = $firstFetchTime = 0; - - if (is_resource($result) || is_object($result)) { - $rows = array(); - // Fetch result row by row. - $fStart = microtime(true); - $row = $this->_performFetch($result); - $firstFetchTime = microtime(true) - $fStart; - if (!empty($row)) { - $rows[] = $row; - while ($row=$this->_performFetch($result)) { - $rows[] = $row; - } - } - $fetchTime = microtime(true) - $fStart; - } else { - $rows = $result; - } - $queryTime = microtime(true) - $qStart; - - // Log query statistics. - $this->_logQueryStat($queryTime, $fetchTime, $firstFetchTime, $rows); - - // Prepare BLOB objects if needed. - if (is_array($rows) && !empty($this->attributes['BLOB_OBJ'])) { - $blobFieldNames = $this->_performGetBlobFieldNames($result); - foreach ($blobFieldNames as $name) { - for ($r = count($rows)-1; $r>=0; $r--) { - $rows[$r][$name] =& $this->_performNewBlob($rows[$r][$name]); - } - } - } - - // Transform resulting rows. - $result = $this->_transformResult($rows); - - // Storing data in cache - if ($cache_it && $this->_cacher) - { - $this->_cacher->save( - serialize(array( - 'invalCache' => $uniq_key, - 'result' => $result, - 'rows' => $rows - )), - $hash, - array(), - $ttl==0?false:$ttl - ); - } - - } - // Count total number of rows if needed. - if (is_array($result) && $total) { - $this->_transformQuery($query, 'GET_TOTAL'); - $total = call_user_func_array(array(&$this, 'selectCell'), $query); - } - - if ($this->_className) - { - foreach($result as $k=>$v) - $result[$k] = new $this->_className($v); - $this->_className = ''; - } - - return $result; - } - - - /** - * mixed _transformQuery(array &$query, string $how) - * - * Transform query different way specified by $how. - * May return some information about performed transform. - */ - private function _transformQuery(&$query, $how) - { - // Do overriden transformation. - $result = $this->_performTransformQuery($query, $how); - if ($result === true) return $result; - // Common transformations. - switch ($how) { - case 'GET_ATTRIBUTES': - // Extract query attributes. - $options = array(); - $q = $query[0]; - $m = null; - while (preg_match('/^ \s* -- [ \t]+ (\w+): ([^\r\n]+) [\r\n]* /sx', $q, $m)) { - $options[$m[1]] = trim($m[2]); - $q = substr($q, strlen($m[0])); - } - return $options; - case 'UNIQ_KEY': - $q = $this->attributes['CACHE']; - $query = array(); - while(preg_match('/(\w+)\.\w+/sx', $q, $m)) { - $query[] = 'SELECT MAX('.$m[0].') AS M, COUNT(*) AS C FROM '.$m[1]; - $q = substr($q, strlen($m[0])); - } - $query = " -- UNIQ_KEY\n". - join("\nUNION\n", $query); - return true; - } - // No such transform. - $this->_setLastError(-1, "No such transform type: $how", $query); - } - - - /** - * void _expandPlaceholders(array &$queryAndArgs, bool $useNative=false) - * Replace placeholders by quoted values. - * Modify $queryAndArgs. - */ - protected function _expandPlaceholders(&$queryAndArgs, $useNative=false) - { - $cacheCode = null; - if ($this->_logger) { - // Serialize is much faster than placeholder expansion. So use caching. - $cacheCode = md5(serialize($queryAndArgs) . '|' . $useNative . '|' . $this->_identPrefix); - if (isset($this->_placeholderCache[$cacheCode])) { - $queryAndArgs = $this->_placeholderCache[$cacheCode]; - return; - } - } - - if (!is_array($queryAndArgs)) { - $queryAndArgs = array($queryAndArgs); - } - - $this->_placeholderNativeArgs = $useNative? array() : null; - $this->_placeholderArgs = array_reverse($queryAndArgs); - - $query = array_pop($this->_placeholderArgs); // array_pop is faster than array_shift - - // Do all the work. - $this->_placeholderNoValueFound = false; - $query = $this->_expandPlaceholdersFlow($query); - - if ($useNative) { - array_unshift($this->_placeholderNativeArgs, $query); - $queryAndArgs = $this->_placeholderNativeArgs; - } else { - $queryAndArgs = array($query); - } - - if ($cacheCode) { - $this->_placeholderCache[$cacheCode] = $queryAndArgs; - } - } - - - /** - * Do real placeholder processing. - * Imply that all interval variables (_placeholder_*) already prepared. - * May be called recurrent! - */ - private function _expandPlaceholdersFlow($query) - { - $re = '{ - (?> - # Ignored chunks. - (?> - # Comment. - -- [^\r\n]* - ) - | - (?> - # DB-specifics. - ' . trim($this->_performGetPlaceholderIgnoreRe()) . ' - ) - ) - | - (?> - # Optional blocks - \{ - # Use "+" here, not "*"! Else nested blocks are not processed well. - ( (?> (?>(\??)[^{}]+) | (?R) )* ) #1 - \} - ) - | - (?> - # Placeholder - (\?) ( [_dsafn&|\#]? ) #2 #3 - ) - }sx'; - $query = preg_replace_callback( - $re, - array(&$this, '_expandPlaceholdersCallback'), - $query - ); - return $query; - } - - static $join = array( - '|' => array('inner' => ' AND ', 'outer' => ') OR (',), - '&' => array('inner' => ' OR ', 'outer' => ') AND (',), - 'a' => array('inner' => ', ', 'outer' => '), (',), - ); - - /** - * string _expandPlaceholdersCallback(list $m) - * Internal function to replace placeholders (see preg_replace_callback). - */ - private function _expandPlaceholdersCallback($m) - { - // Placeholder. - if (!empty($m[3])) { - $type = $m[4]; - - // Idenifier prefix. - if ($type == '_') { - return $this->_identPrefix; - } - - // Value-based placeholder. - if (!$this->_placeholderArgs) return 'DBSIMPLE_ERROR_NO_VALUE'; - $value = array_pop($this->_placeholderArgs); - - // Skip this value? - if ($value === DBSIMPLE_SKIP) { - $this->_placeholderNoValueFound = true; - return ''; - } - - // First process guaranteed non-native placeholders. - switch ($type) { - case 's': - if (!($value instanceof DbSimple_SubQuery)) - return 'DBSIMPLE_ERROR_VALUE_NOT_SUBQUERY'; - return $value->get($this->_placeholderNativeArgs); - case '|': - case '&': - case 'a': - if (!$value) $this->_placeholderNoValueFound = true; - if (!is_array($value)) return 'DBSIMPLE_ERROR_VALUE_NOT_ARRAY'; - $parts = array(); - $multi = array(); //массив для двойной вложенности - $mult = $type!='a' || is_int(key($value)) && is_array(current($value)); - foreach ($value as $prefix => $field) { - //превращаем $value в двумерный нуменованный массив - if (!is_array($field)) { - $field = array($prefix => $field); - $prefix = 0; - } - $prefix = is_int($prefix) ? '' : - $this->escape($this->_addPrefix2Table($prefix), true) . '.'; - //для мультиинсерта очищаем ключи - их быть не может по синтаксису - if ($mult && $type=='a') - $field = array_values($field); - foreach ($field as $k => $v) - { - if ($v instanceof DbSimple_SubQuery) - $v = $v->get($this->_placeholderNativeArgs); - else - $v = $v === null? 'NULL' : $this->escape($v); - if (!is_int($k)) { - $k = $this->escape($k, true); - $parts[] = "$prefix$k=$v"; - } else { - $parts[] = $v; - } - } - if ($mult) - { - $multi[] = join(self::$join[$type]['inner'], $parts); - $parts = array(); - } - } - return $mult ? join(self::$join[$type]['outer'], $multi) : join(', ', $parts); - case '#': - // Identifier. - if (!is_array($value)) - { - if ($value instanceof DbSimple_SubQuery) - return $value->get($this->_placeholderNativeArgs); - return $this->escape($this->_addPrefix2Table($value), true); - } - $parts = array(); - foreach ($value as $table => $identifiers) - { - if (!is_array($identifiers)) - $identifiers = array($identifiers); - $prefix = ''; - if (!is_int($table)) - $prefix = $this->escape($this->_addPrefix2Table($table), true) . '.'; - foreach ($identifiers as $identifier) - if ($identifier instanceof DbSimple_SubQuery) - $parts[] = $identifier->get($this->_placeholderNativeArgs); - elseif (!is_string($identifier)) - return 'DBSIMPLE_ERROR_ARRAY_VALUE_NOT_STRING'; - else - $parts[] = $prefix . ($identifier=='*' ? '*' : - $this->escape($this->_addPrefix2Table($identifier), true)); - } - return join(', ', $parts); - case 'n': - // NULL-based placeholder. - return empty($value)? 'NULL' : intval($value); - } - - // Native arguments are not processed. - if ($this->_placeholderNativeArgs !== null) { - $this->_placeholderNativeArgs[] = $value; - return $this->_performGetNativePlaceholderMarker(count($this->_placeholderNativeArgs) - 1); - } - - // In non-native mode arguments are quoted. - if ($value === null) return 'NULL'; - switch ($type) { - case '': - if (!is_scalar($value)) return 'DBSIMPLE_ERROR_VALUE_NOT_SCALAR'; - return $this->escape($value); - case 'd': - return intval($value); - case 'f': - return str_replace(',', '.', floatval($value)); - } - // By default - escape as string. - return $this->escape($value); - } - - // Optional block. - if (isset($m[1]) && strlen($block=$m[1])) - { - $prev = $this->_placeholderNoValueFound; - if ($this->_placeholderNativeArgs !== null) - $prevPh = $this->_placeholderNativeArgs; - - // Проверка на {? } - условный блок - $skip = false; - if ($m[2]=='?') - { - $skip = array_pop($this->_placeholderArgs) === DBSIMPLE_SKIP; - $block[0] = ' '; - } - - $block = $this->_expandOptionalBlock($block); - - if ($skip) - $block = ''; - - if ($this->_placeholderNativeArgs !== null) - if ($this->_placeholderNoValueFound) - $this->_placeholderNativeArgs = $prevPh; - $this->_placeholderNoValueFound = $prev; // recurrent-safe - return $block; - } - - // Default: skipped part of the string. - return $m[0]; - } - - - /** - * Заменяет ?_ на текущий префикс - * - * @param string $table имя таблицы - * @return string имя таблицы - */ - private function _addPrefix2Table($table) - { - if (substr($table, 0, 2) == '?_') - $table = $this->_identPrefix . substr($table, 2); - return $table; - } - - - /** - * Разбирает опциональный блок - условие | - * - * @param string $block блок, который нужно разобрать - * @return string что получается в результате разбора блока - */ - private function _expandOptionalBlock($block) - { - $alts = array(); - $alt = ''; - $sub=0; - $exp = explode('|',$block); - // Оптимизация, так как в большинстве случаев | не используется - if (count($exp)==1) - $alts=$exp; - else - foreach ($exp as $v) - { - // Реализуем автоматный магазин для нахождения нужной скобки - // На суммарную парность скобок проверять нет необходимости - об этом заботится регулярка - $sub+=substr_count($v,'{'); - $sub-=substr_count($v,'}'); - if ($sub>0) - $alt.=$v.'|'; - else - { - $alts[]=$alt.$v; - $alt=''; - } - } - $r=''; - foreach ($alts as $block) - { - $this->_placeholderNoValueFound = false; - $block = $this->_expandPlaceholdersFlow($block); - // Необходимо пройти все блоки, так как если пропустить оставшиесь, - // то это нарушит порядок подставляемых значений - if ($this->_placeholderNoValueFound == false && $r=='') - $r = ' '.$block.' '; - } - return $r; - } - - - /** - * void _setLastError($code, $msg, $query) - * Set last database error context. - * Aditionally expand placeholders. - */ - protected function _setLastError($code, $msg, $query) - { - if (is_array($query)) { - $this->_expandPlaceholders($query, false); - $query = $query[0]; - } - return parent::_setLastError($code, $msg, $query); - } - - - /** - * Convert SQL field-list to COUNT(...) clause - * (e.g. 'DISTINCT a AS aa, b AS bb' -> 'COUNT(DISTINCT a, b)'). - */ - private function _fieldList2Count($fields) - { - $m = null; - if (preg_match('/^\s* DISTINCT \s* (.*)/sx', $fields, $m)) { - $fields = $m[1]; - $fields = preg_replace('/\s+ AS \s+ .*? (?=,|$)/sx', '', $fields); - return "COUNT(DISTINCT $fields)"; - } else { - return 'COUNT(*)'; - } - } - - - /** - * array _transformResult(list $rows) - * Transform resulting rows to various formats. - */ - private function _transformResult($rows) - { - // is not array - if (!is_array($rows) || !$rows) - return $rows; - - // Find ARRAY_KEY* AND PARENT_KEY fields in field list. - $pk = null; - $ak = array(); - foreach (array_keys(current($rows)) as $fieldName) - if (0 == strncasecmp($fieldName, DBSIMPLE_ARRAY_KEY, strlen(DBSIMPLE_ARRAY_KEY))) - $ak[] = $fieldName; - elseif (0 == strncasecmp($fieldName, DBSIMPLE_PARENT_KEY, strlen(DBSIMPLE_PARENT_KEY))) - $pk = $fieldName; - - if (!$ak) - return $rows; - - natsort($ak); // sort ARRAY_KEY* using natural comparision - // Tree-based array? Fields: ARRAY_KEY, PARENT_KEY - if ($pk !== null) - return $this->_transformResultToForest($rows, $ak[0], $pk); - // Key-based array? Fields: ARRAY_KEY. - return $this->_transformResultToHash($rows, $ak); - } - - - /** - * Converts rowset to key-based array. - * - * @param array $rows Two-dimensional array of resulting rows. - * @param array $ak List of ARRAY_KEY* field names. - * @return array Transformed array. - */ - private function _transformResultToHash(array $rows, array $arrayKeys) - { - $result = array(); - foreach ($rows as $row) { - // Iterate over all of ARRAY_KEY* fields and build array dimensions. - $current =& $result; - foreach ($arrayKeys as $ak) { - $key = $row[$ak]; - unset($row[$ak]); // remove ARRAY_KEY* field from result row - if ($key !== null) { - $current =& $current[$key]; - } else { - // IF ARRAY_KEY field === null, use array auto-indices. - $tmp = array(); - $current[] =& $tmp; - $current =& $tmp; - unset($tmp); // we use $tmp, because don't know the value of auto-index - } - } - $current = $row; // save the row in last dimension - } - return $result; - } - - - /** - * Converts rowset to the forest. - * - * @param array $rows Two-dimensional array of resulting rows. - * @param string $idName Name of ID field. - * @param string $pidName Name of PARENT_ID field. - * @return array Transformed array (tree). - */ - private function _transformResultToForest(array $rows, $idName, $pidName) - { - $children = array(); // children of each ID - $ids = array(); - // Collect who are children of whom. - foreach ($rows as $i=>$r) { - $row =& $rows[$i]; - $id = $row[$idName]; - if ($id === null) { - // Rows without an ID are totally invalid and makes the result tree to - // be empty (because PARENT_ID = null means "a root of the tree"). So - // skip them totally. - continue; - } - $pid = $row[$pidName]; - if ($id == $pid) $pid = null; - $children[$pid][$id] =& $row; - if (!isset($children[$id])) $children[$id] = array(); - $row['childNodes'] =& $children[$id]; - $ids[$id] = true; - } - // Root elements are elements with non-found PIDs. - $forest = array(); - foreach ($rows as $i=>$r) { - $row =& $rows[$i]; - $id = $row[$idName]; - $pid = $row[$pidName]; - if ($pid == $id) $pid = null; - if (!isset($ids[$pid])) { - $forest[$row[$idName]] =& $row; - } - unset($row[$idName]); - unset($row[$pidName]); - } - return $forest; - } - - - /** - * Replaces the last array in a multi-dimensional array $V by its first value. - * Used for selectCol(), when we need to transform (N+1)d resulting array - * to Nd array (column). - */ - private function _shrinkLastArrayDimensionCallback(&$v) - { - if (!$v) return; - reset($v); - if (!is_array($firstCell = current($v))) { - $v = $firstCell; - } else { - array_walk($v, array(&$this, '_shrinkLastArrayDimensionCallback')); - } - } - - - /** - * void _logQuery($query, $noTrace=false) - * Must be called on each query. - * If $noTrace is true, library caller is not solved (speed improvement). - */ - protected function _logQuery($query, $noTrace=false) - { - if (!$this->_logger) return; - $this->_expandPlaceholders($query, false); - $args = array(); - $args[] =& $this; - $args[] = $query[0]; - $args[] = $noTrace? null : $this->findLibraryCaller(); - return call_user_func_array($this->_logger, $args); - } - - - /** - * void _logQueryStat($queryTime, $fetchTime, $firstFetchTime, $rows) - * Log information about performed query statistics. - */ - private function _logQueryStat($queryTime, $fetchTime, $firstFetchTime, $rows) - { - // Always increment counters. - $this->_statistics['time'] += $queryTime; - $this->_statistics['count']++; - - // If no logger, economize CPU resources and actually log nothing. - if (!$this->_logger) return; - - $dt = round($queryTime * 1000); - $firstFetchTime = round($firstFetchTime*1000); - $tailFetchTime = round($fetchTime * 1000) - $firstFetchTime; - $log = " -- "; - if ($firstFetchTime + $tailFetchTime) { - $log = sprintf(" -- %d ms = %d+%d".($tailFetchTime? "+%d" : ""), $dt, $dt-$firstFetchTime-$tailFetchTime, $firstFetchTime, $tailFetchTime); - } else { - $log = sprintf(" -- %d ms", $dt); - } - $log .= "; returned "; - - if (!is_array($rows)) { - $log .= $this->escape($rows); - } else { - $detailed = null; - if (count($rows) == 1) { - $len = 0; - $values = array(); - foreach ($rows[0] as $k=>$v) { - $len += strlen($v); - if ($len > $this->MAX_LOG_ROW_LEN) { - break; - } - $values[] = $v === null? 'NULL' : $this->escape($v); - } - if ($len <= $this->MAX_LOG_ROW_LEN) { - $detailed = "(" . preg_replace("/\r?\n/", "\\n", join(', ', $values)) . ")"; - } - } - if ($detailed) { - $log .= $detailed; - } else { - $log .= count($rows). " row(s)"; - } - } - - $this->_logQuery($log, true); - } - - - // Identifiers prefix (used for ?_ placeholder). - private $_identPrefix = ''; - - // Queries statistics. - private $_statistics = array( - 'time' => 0, - 'count' => 0, - ); - - private $_cachePrefix = ''; - private $_className = ''; - - private $_logger = null; - private $_cacher = null; - private $_placeholderArgs, $_placeholderNativeArgs, $_placeholderCache=array(); - private $_placeholderNoValueFound; - - /** - * When string representation of row (in characters) is greater than this, - * row data will not be logged. - */ - private $MAX_LOG_ROW_LEN = 128; -} - - -/** - * Database BLOB. - * Can read blob chunk by chunk, write data to BLOB. - */ -interface DbSimple_Blob -{ - /** - * string read(int $length) - * Returns following $length bytes from the blob. - */ - public function read($len); - - /** - * string write($data) - * Appends data to blob. - */ - public function write($data); - - /** - * int length() - * Returns length of the blob. - */ - public function length(); - - /** - * blobid close() - * Closes the blob. Return its ID. No other way to obtain this ID! - */ - public function close(); -} - - -/** - * Класс для хранения подзапроса - результата выполнения функции - * DbSimple_Generic_Database::subquery - * - */ -class DbSimple_SubQuery -{ - private $query=array(); - - public function __construct(array $q) - { - $this->query = $q; - } - - /** - * Возвращает сам запрос и добавляет плейсхолдеры в массив переданный по ссылке - * - * @param &array|null - ссылка на массив плейсхолдеров - * @return string - */ - public function get(&$ph) - { - if ($ph !== null) - $ph = array_merge($ph, array_slice($this->query,1,null,true)); - return $this->query[0]; - } -} - - -/** - * Support for error tracking. - * Can hold error messages, error queries and build proper stacktraces. - */ -abstract class DbSimple_LastError -{ - public $error = null; - public $errmsg = null; - private $errorHandler = null; - private $ignoresInTraceRe = 'DbSimple_.*::.* | call_user_func.*'; - - /** - * abstract void _logQuery($query) - * Must be overriden in derived class. - */ - abstract protected function _logQuery($query); - - /** - * void _resetLastError() - * Reset the last error. Must be called on correct queries. - */ - protected function _resetLastError() - { - $this->error = $this->errmsg = null; - } - - /** - * void _setLastError(int $code, string $message, string $query) - * Fill $this->error property with error information. Error context - * (code initiated the query outside DbSimple) is assigned automatically. - */ - protected function _setLastError($code, $msg, $query) - { - $context = "unknown"; - if ($t = $this->findLibraryCaller()) { - $context = (isset($t['file'])? $t['file'] : '?') . ' line ' . (isset($t['line'])? $t['line'] : '?'); - } - $this->error = array( - 'code' => $code, - 'message' => rtrim($msg), - 'query' => $query, - 'context' => $context, - ); - $this->errmsg = rtrim($msg) . ($context? " at $context" : ""); - - $this->_logQuery(" -- error #".$code.": ".preg_replace('/(\r?\n)+/s', ' ', $this->errmsg)); - - if (is_callable($this->errorHandler)) { - call_user_func($this->errorHandler, $this->errmsg, $this->error); - } - - return false; - } - - - /** - * callback setErrorHandler(callback $handler) - * Set new error handler called on database errors. - * Handler gets 3 arguments: - * - error message - * - full error context information (last query etc.) - */ - public function setErrorHandler($handler) - { - $prev = $this->errorHandler; - $this->errorHandler = $handler; - // In case of setting first error handler for already existed - // error - call the handler now (usual after connect()). - if (!$prev && $this->error && $this->errorHandler) { - call_user_func($this->errorHandler, $this->errmsg, $this->error); - } - return $prev; - } - - /** - * void addIgnoreInTrace($reName) - * Add regular expression matching ClassName::functionName or functionName. - * Matched stack frames will be ignored in stack traces passed to query logger. - */ - public function addIgnoreInTrace($name) - { - $this->ignoresInTraceRe .= "|" . $name; - } - - /** - * array of array findLibraryCaller() - * Return part of stacktrace before calling first library method. - * Used in debug purposes (query logging etc.). - */ - public function findLibraryCaller() - { - $caller = call_user_func( - array(&$this, 'debug_backtrace_smart'), - $this->ignoresInTraceRe, - true - ); - return $caller; - } - - /** - * array debug_backtrace_smart($ignoresRe=null, $returnCaller=false) - * - * Return stacktrace. Correctly work with call_user_func* - * (totally skip them correcting caller references). - * If $returnCaller is true, return only first matched caller, - * not all stacktrace. - * - * @version 2.03 - */ - private function debug_backtrace_smart($ignoresRe=null, $returnCaller=false) - { - $trace = debug_backtrace(); - - if ($ignoresRe !== null) - $ignoresRe = "/^(?>{$ignoresRe})$/six"; - $smart = array(); - $framesSeen = 0; - for ($i=0, $n=count($trace); $i<$n; $i++) { - $t = $trace[$i]; - if (!$t) continue; - - // Next frame. - $next = isset($trace[$i+1])? $trace[$i+1] : null; - - // Dummy frame before call_user_func* frames. - if (!isset($t['file'])) { - $t['over_function'] = $trace[$i+1]['function']; - $t = $t + $trace[$i+1]; - $trace[$i+1] = null; // skip call_user_func on next iteration - $next = isset($trace[$i+2])? $trace[$i+2] : null; // Correct Next frame. - } - - // Skip myself frame. - if (++$framesSeen < 2) continue; - - // 'class' and 'function' field of next frame define where - // this frame function situated. Skip frames for functions - // situated in ignored places. - if ($ignoresRe && $next) { - // Name of function "inside which" frame was generated. - $frameCaller = (isset($next['class'])? $next['class'].'::' : '') . (isset($next['function'])? $next['function'] : ''); - if (preg_match($ignoresRe, $frameCaller)) continue; - } - - // On each iteration we consider ability to add PREVIOUS frame - // to $smart stack. - if ($returnCaller) return $t; - $smart[] = $t; - } - return $smart; - } - -} diff --git a/includes/libs/DbSimple/Generic.php b/includes/libs/DbSimple/Generic.php deleted file mode 100644 index 5dc2f144..00000000 --- a/includes/libs/DbSimple/Generic.php +++ /dev/null @@ -1,193 +0,0 @@ -. - * - * Contains 3 classes: - * - DbSimple_Generic: database factory class - * - DbSimple_Generic_Database: common database methods - * - DbSimple_Generic_Blob: common BLOB support - * - DbSimple_Generic_LastError: error reporting and tracking - * - * Special result-set fields: - * - ARRAY_KEY* ("*" means "anything") - * - PARENT_KEY - * - * Transforms: - * - GET_ATTRIBUTES - * - CALC_TOTAL - * - GET_TOTAL - * - UNIQ_KEY - * - * Query attributes: - * - BLOB_OBJ - * - CACHE - * - * @author Dmitry Koterov, http://forum.dklab.ru/users/DmitryKoterov/ - * @author Konstantin Zhinko, http://forum.dklab.ru/users/KonstantinGinkoTit/ - * - * @version 2.x $Id$ - */ - -/** - * Use this constant as placeholder value to skip optional SQL block [...]. - */ -if (!defined('DBSIMPLE_SKIP')) - define('DBSIMPLE_SKIP', log(0)); - -/** - * Names of special columns in result-set which is used - * as array key (or karent key in forest-based resultsets) in - * resulting hash. - */ -if (!defined('DBSIMPLE_ARRAY_KEY')) - define('DBSIMPLE_ARRAY_KEY', 'ARRAY_KEY'); // hash-based resultset support -if (!defined('DBSIMPLE_PARENT_KEY')) - define('DBSIMPLE_PARENT_KEY', 'PARENT_KEY'); // forrest-based resultset support - - -/** - * DbSimple factory. - */ -class DbSimple_Generic -{ - /** - * DbSimple_Generic connect(mixed $dsn) - * - * Universal static function to connect ANY database using DSN syntax. - * Choose database driver according to DSN. Return new instance - * of this driver. - * - * You can connect to MySQL by socket using this new syntax (like PDO DSN): - * $dsn = 'mysqli:unix_socket=/cloudsql/app:instance;user=root;pass=;dbname=testdb'; - * $dsn = 'mypdo:unix_socket=/cloudsql/app:instance;charset=utf8;user=testuser;pass=mypassword;dbname=testdb'; - * - * Connection by host also can be made with this syntax. - * Or you can use old syntax: - * $dsn = 'mysql://testuser:mypassword@127.0.0.1/testdb'; - * - */ - public static function connect($dsn) - { - // Load database driver and create its instance. - $parsed = DbSimple_Generic::parseDSN($dsn); - if (!$parsed) { - $dummy = null; - return $dummy; - } - $class = 'DbSimple_'.ucfirst($parsed['scheme']); - if (!class_exists($class)) { - $file = __DIR__.'/'.ucfirst($parsed['scheme']). ".php"; - if (is_file($file)) { - require_once($file); - } else { - trigger_error("Error loading database driver: no file $file", E_USER_ERROR); - return null; - } - } - $object = new $class($parsed); - if (isset($parsed['ident_prefix'])) { - $object->setIdentPrefix($parsed['ident_prefix']); - } - $object->setCachePrefix(md5(serialize($parsed['dsn']))); - return $object; - } - - - /** - * array parseDSN(mixed $dsn) - * Parse a data source name. - * See parse_url() for details. - */ - public static function parseDSN($dsn) - { - if (is_array($dsn)) return $dsn; - $parsed = parse_url($dsn); - if (!$parsed) return null; - - $params = null; - if (!empty($parsed['query'])) { - parse_str($parsed['query'], $params); - $parsed += $params; - } - - if ( empty($parsed['host']) && empty($parsed['socket']) ) { - // Parse as DBO DSN string - $parsedPdo = self::parseDsnPdo($parsed['path']); - unset($parsed['path']); - $parsed = array_merge($parsed, $parsedPdo); - } - - $parsed['dsn'] = $dsn; - return $parsed; - } // parseDSN - - - /** - * Parse string as DBO DSN string. - * - * @param $str - * @return array - */ - public static function parseDsnPdo($str) { - - if (substr($str, 0, strlen('mysql:')) == 'mysql:') { - $str = substr($str, strlen('mysql:')); - } - - $arr = explode(';', $str); - - $result = array(); - foreach ($arr as $k=>$v) { - $v = explode('=', $v); - if (count($v) == 2) - $result[ $v[0] ] = $v[1]; - } - - if ( isset($result['unix_socket']) ) { - $result['socket'] = $result['unix_socket']; - unset($result['unix_socket']); - } - - if ( isset($result['dbname']) ) { - $result['path'] = $result['dbname']; - unset($result['dbname']); - } - - if ( isset($result['charset']) ) { - $result['enc'] = $result['charset']; - unset($result['charset']); - } - - return $result; - } // parseDsnPdo - -} // DbSimple_Generic class diff --git a/includes/libs/DbSimple/Mysqli.php b/includes/libs/DbSimple/Mysqli.php deleted file mode 100644 index 82b17279..00000000 --- a/includes/libs/DbSimple/Mysqli.php +++ /dev/null @@ -1,232 +0,0 @@ -_setLastError("-1", "MySQLi extension is not loaded", "mysqli_connect"); - - if (!empty($dsn["persist"])) { - if (version_compare(PHP_VERSION, '5.3') < 0) { - return $this->_setLastError("-1", "Persistent connections in MySQLi is allowable since PHP 5.3", "mysqli_connect"); - } else { - $dsn["host"] = "p:".$dsn["host"]; - } - } - - if ( isset($dsn['socket']) ) { - // Socket connection - $this->link = mysqli_connect( - null // host - ,empty($dsn['user']) ? 'root' : $dsn['user'] // user - ,empty($dsn['pass']) ? '' : $dsn['pass'] // password - ,preg_replace('{^/}s', '', $dsn['path']) // schema - ,null // port - ,$dsn['socket'] // socket - ); - } else if (isset($dsn['host']) ) { - // Host connection - $this->link = mysqli_connect( - $dsn['host'] - ,empty($dsn['user']) ? 'root' : $dsn['user'] - ,empty($dsn['pass']) ? '' : $dsn['pass'] - ,preg_replace('{^/}s', '', $dsn['path']) - ,empty($dsn['port']) ? null : $dsn['port'] - ); - } else { - return $this->_setDbError('mysqli_connect()'); - } - $this->_resetLastError(); - if (!$this->link) return $this->_setDbError('mysqli_connect()'); - - mysqli_set_charset($this->link, isset($dsn['enc']) ? $dsn['enc'] : 'UTF8'); - } - - - protected function _performEscape($s, $isIdent=false) - { - if (!$isIdent) - return "'" . mysqli_real_escape_string($this->link, $s) . "'"; - else - return "`" . str_replace('`', '``', $s) . "`"; - } - - - protected function _performNewBlob($blobid=null) - { - return new DbSimple_Mysqli_Blob($this, $blobid); - } - - - protected function _performGetBlobFieldNames($result) - { - $allFields = mysqli_fetch_fields($result); - $blobFields = array(); - - if (!empty($allFields)) - { - foreach ($allFields as $field) - if (stripos($field["type"], "BLOB") !== false) - $blobFields[] = $field["name"]; - } - return $blobFields; - } - - - protected function _performGetPlaceholderIgnoreRe() - { - return ' - " (?> [^"\\\\]+|\\\\"|\\\\)* " | - \' (?> [^\'\\\\]+|\\\\\'|\\\\)* \' | - ` (?> [^`]+ | ``)* ` | # backticks - /\* .*? \*/ # comments - '; - } - - - protected function _performTransaction($parameters=null) - { - return mysqli_begin_transaction($this->link); - } - - - protected function _performCommit() - { - return mysqli_commit($this->link); - } - - - protected function _performRollback() - { - return mysqli_rollback($this->link); - } - - - protected function _performTransformQuery(&$queryMain, $how) - { - // If we also need to calculate total number of found rows... - switch ($how) - { - // Prepare total calculation (if possible) - case 'CALC_TOTAL': - $m = null; - if (preg_match('/^(\s* SELECT)(.*)/six', $queryMain[0], $m)) - $queryMain[0] = $m[1] . ' SQL_CALC_FOUND_ROWS' . $m[2]; - return true; - - // Perform total calculation. - case 'GET_TOTAL': - // Built-in calculation available? - $queryMain = array('SELECT FOUND_ROWS()'); - return true; - } - - return false; - } - - - protected function _performQuery($queryMain) - { - $this->_lastQuery = $queryMain; - $this->_expandPlaceholders($queryMain, false); - mysqli_ping($this->link); - $result = mysqli_query($this->link, $queryMain[0]); - if ($result === false) - return $this->_setDbError($queryMain[0]); - if (!is_object($result)) { - if (preg_match('/^\s* INSERT \s+/six', $queryMain[0])) - { - // INSERT queries return generated ID. - return mysqli_insert_id($this->link); - } - // Non-SELECT queries return number of affected rows, SELECT - resource. - return mysqli_affected_rows($this->link); - } - return $result; - } - - - protected function _performFetch($result) - { - $row = mysqli_fetch_assoc($result); - if (mysqli_error($this->link)) return $this->_setDbError($this->_lastQuery); - if ($row === false) return null; - return $row; - } - - - protected function _setDbError($query) - { - if ($this->link) { - return $this->_setLastError(mysqli_errno($this->link), mysqli_error($this->link), $query); - } else { - return $this->_setLastError(mysqli_connect_errno(), mysqli_connect_error(), $query); - } - } -} - - -class DbSimple_Mysqli_Blob implements DbSimple_Blob -{ - // MySQL does not support separate BLOB fetching. - private $blobdata = null; - private $curSeek = 0; - - public function __construct(&$database, $blobdata=null) - { - $this->blobdata = $blobdata; - $this->curSeek = 0; - } - - public function read($len) - { - $p = $this->curSeek; - $this->curSeek = min($this->curSeek + $len, strlen($this->blobdata)); - return substr($this->blobdata, $p, $len); - } - - public function write($data) - { - $this->blobdata .= $data; - } - - public function close() - { - return $this->blobdata; - } - - public function length() - { - return strlen($this->blobdata); - } -} diff --git a/includes/libs/DbSimple/Zend/Cache.php b/includes/libs/DbSimple/Zend/Cache.php deleted file mode 100644 index aff2e653..00000000 --- a/includes/libs/DbSimple/Zend/Cache.php +++ /dev/null @@ -1,250 +0,0 @@ -setBackend($backendObject); - return $frontendObject; - } - - /** - * Backend Constructor - * - * @param string $backend - * @param array $backendOptions - * @param boolean $customBackendNaming - * @param boolean $autoload - * @return Zend_Cache_Backend - */ - public static function _makeBackend($backend, $backendOptions, $customBackendNaming = false, $autoload = false) - { - if (!$customBackendNaming) { - $backend = self::_normalizeName($backend); - } - if (in_array($backend, Zend_Cache::$standardBackends)) { - // we use a standard backend - $backendClass = 'Zend_Cache_Backend_' . $backend; - // security controls are explicit - require_once str_replace('_', DIRECTORY_SEPARATOR, $backendClass) . '.php'; - } else { - // we use a custom backend - if (!preg_match('~^[\w\\\\]+$~D', $backend)) { - Zend_Cache::throwException("Invalid backend name [$backend]"); - } - if (!$customBackendNaming) { - // we use this boolean to avoid an API break - $backendClass = 'Zend_Cache_Backend_' . $backend; - } else { - $backendClass = $backend; - } - if (!$autoload) { - $file = str_replace('_', DIRECTORY_SEPARATOR, $backendClass) . '.php'; - if (!(self::_isReadable($file))) { - self::throwException("file $file not found in include_path"); - } - require_once $file; - } - } - return new $backendClass($backendOptions); - } - - /** - * Frontend Constructor - * - * @param string $frontend - * @param array $frontendOptions - * @param boolean $customFrontendNaming - * @param boolean $autoload - * @return Zend_Cache_Core|Zend_Cache_Frontend - */ - public static function _makeFrontend($frontend, $frontendOptions = array(), $customFrontendNaming = false, $autoload = false) - { - if (!$customFrontendNaming) { - $frontend = self::_normalizeName($frontend); - } - if (in_array($frontend, self::$standardFrontends)) { - // we use a standard frontend - // For perfs reasons, with frontend == 'Core', we can interact with the Core itself - $frontendClass = 'Zend_Cache_' . ($frontend != 'Core' ? 'Frontend_' : '') . $frontend; - // security controls are explicit - require_once str_replace('_', DIRECTORY_SEPARATOR, $frontendClass) . '.php'; - } else { - // we use a custom frontend - if (!preg_match('~^[\w\\\\]+$~D', $frontend)) { - Zend_Cache::throwException("Invalid frontend name [$frontend]"); - } - if (!$customFrontendNaming) { - // we use this boolean to avoid an API break - $frontendClass = 'Zend_Cache_Frontend_' . $frontend; - } else { - $frontendClass = $frontend; - } - if (!$autoload) { - $file = str_replace('_', DIRECTORY_SEPARATOR, $frontendClass) . '.php'; - if (!(self::_isReadable($file))) { - self::throwException("file $file not found in include_path"); - } - require_once $file; - } - } - return new $frontendClass($frontendOptions); - } - - /** - * Throw an exception - * - * Note : for perf reasons, the "load" of Zend/Cache/Exception is dynamic - * @param string $msg Message for the exception - * @throws Zend_Cache_Exception - */ - public static function throwException($msg, Exception $e = null) - { - // For perfs reasons, we use this dynamic inclusion - require_once 'Zend/Cache/Exception.php'; - throw new Zend_Cache_Exception($msg, 0, $e); - } - - /** - * Normalize frontend and backend names to allow multiple words TitleCased - * - * @param string $name Name to normalize - * @return string - */ - protected static function _normalizeName($name) - { - $name = ucfirst(strtolower($name)); - $name = str_replace(array('-', '_', '.'), ' ', $name); - $name = ucwords($name); - $name = str_replace(' ', '', $name); - if (stripos($name, 'ZendServer') === 0) { - $name = 'ZendServer_' . substr($name, strlen('ZendServer')); - } - - return $name; - } - - /** - * Returns TRUE if the $filename is readable, or FALSE otherwise. - * This function uses the PHP include_path, where PHP's is_readable() - * does not. - * - * Note : this method comes from Zend_Loader (see #ZF-2891 for details) - * - * @param string $filename - * @return boolean - */ - private static function _isReadable($filename) - { - if (!$fh = @fopen($filename, 'r', true)) { - return false; - } - @fclose($fh); - return true; - } - -} diff --git a/includes/libs/DbSimple/Zend/Cache/Backend/Interface.php b/includes/libs/DbSimple/Zend/Cache/Backend/Interface.php deleted file mode 100644 index 3f44e2e1..00000000 --- a/includes/libs/DbSimple/Zend/Cache/Backend/Interface.php +++ /dev/null @@ -1,99 +0,0 @@ - infinite lifetime) - * @return boolean true if no problem - */ - public function save($data, $id, $tags = array(), $specificLifetime = false); - - /** - * Remove a cache record - * - * @param string $id Cache id - * @return boolean True if no problem - */ - public function remove($id); - - /** - * Clean some cache records - * - * Available modes are : - * Zend_Cache::CLEANING_MODE_ALL (default) => remove all cache entries ($tags is not used) - * Zend_Cache::CLEANING_MODE_OLD => remove too old cache entries ($tags is not used) - * Zend_Cache::CLEANING_MODE_MATCHING_TAG => remove cache entries matching all given tags - * ($tags can be an array of strings or a single string) - * Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG => remove cache entries not {matching one of the given tags} - * ($tags can be an array of strings or a single string) - * Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG => remove cache entries matching any given tags - * ($tags can be an array of strings or a single string) - * - * @param string $mode Clean mode - * @param array $tags Array of tags - * @return boolean true if no problem - */ - public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array()); - -} diff --git a/includes/libs/qqFileUploader.class.php b/includes/libs/qqFileUploader.class.php index 27b27ab8..dae92a58 100644 --- a/includes/libs/qqFileUploader.class.php +++ b/includes/libs/qqFileUploader.class.php @@ -132,7 +132,7 @@ class qqFileUploader private function toBytes(string $str) : int { - $val = trim($str); + $val = substr(trim($str), 0, -1); $last = strtolower(substr($str, -1, 1)); switch ($last) { diff --git a/includes/locale.class.php b/includes/locale.class.php new file mode 100644 index 00000000..a8d8923e --- /dev/null +++ b/includes/locale.class.php @@ -0,0 +1,179 @@ + 'en', + self::KR => 'ko', + self::FR => 'fr', + self::DE => 'de', + self::CN => 'cn', + self::TW => 'tw', + self::ES => 'es', + self::MX => 'mx', + self::RU => 'ru', + self::JP => 'jp', + self::PT => 'pt', + self::IT => 'it' + }; + } + + public function json() : string // internal usage / json string + { + return match ($this) + { + self::EN => 'enus', + self::KR => 'kokr', + self::FR => 'frfr', + self::DE => 'dede', + self::CN => 'zhcn', + self::TW => 'zhtw', + self::ES => 'eses', + self::MX => 'esmx', + self::RU => 'ruru', + self::JP => 'jajp', + self::PT => 'ptpt', + self::IT => 'itit' + }; + } + + public function title() : string // localized language name + { + return match ($this) + { + self::EN => 'English', + self::KR => '한국어', + self::FR => 'Français', + self::DE => 'Deutsch', + self::CN => '简体中文', + self::TW => '繁體中文', + self::ES => 'Español', + self::MX => 'Mexicano', + self::RU => 'Русский', + self::JP => '日本語', + self::PT => 'Português', + self::IT => 'Italiano' + }; + } + + public function gameDirs() : array // setup data source / wow client locale code + { + return match ($this) + { + self::EN => ['enGB', 'enUS', ''], + self::KR => ['koKR'], + self::FR => ['frFR'], + self::DE => ['deDE'], + self::CN => ['zhCN', 'enCN'], + self::TW => ['zhTW', 'enTW'], + self::ES => ['esES'], + self::MX => ['esMX'], + self::RU => ['ruRU'], + self::JP => ['jaJP'], + self::PT => ['ptPT', 'ptBR'], + self::IT => ['itIT'] + }; + } + + public function httpCode() : array // HTTP_ACCEPT_LANGUAGE + { + return match ($this) + { + self::EN => ['en', 'en-au', 'en-bz', 'en-ca', 'en-ie', 'en-jm', 'en-nz', 'en-ph', 'en-za', 'en-tt', 'en-gb', 'en-us', 'en-zw'], + self::KR => ['ko', 'ko-kp', 'ko-kr'], + self::FR => ['fr', 'fr-be', 'fr-ca', 'fr-fr', 'fr-lu', 'fr-mc', 'fr-ch'], + self::DE => ['de', 'de-at', 'de-de', 'de-li', 'de-lu', 'de-ch'], + self::CN => ['zh', 'zh-hk', 'zh-cn', 'zh-sg'], + self::TW => ['tw', 'zh-tw'], + self::ES => ['es', 'es-ar', 'es-bo', 'es-cl', 'es-co', 'es-cr', 'es-do', 'es-ec', 'es-sv', 'es-gt', 'es-hn', 'es-ni', 'es-pa', 'es-py', 'es-pe', 'es-pr', 'es-es', 'es-uy', 'es-ve'], + self::MX => ['mx', 'es-mx'], + self::RU => ['ru', 'ru-mo'], + self::JP => ['ja'], + self::PT => ['pt', 'pt-br'], + self::IT => ['it', 'it-ch'] + }; + } + + public function isLogographic() : bool + { + return $this == Locale::CN || $this == Locale::TW || $this == Locale::KR; + } + + public function validate() : ?self + { + return ($this->maskBit() & self::MASK_ALL & (Cfg::get('LOCALES') ?: 0xFFFF)) ? $this : null; + } + + public function maskBit() : int + { + return (1 << $this->value); + } + + public static function tryFromDomain(string $str) : ?self + { + foreach (self::cases() as $l) + if ($l->validate() && $str == $l->domain()) + return $l; + + return null; + } + + public static function tryFromHttpAcceptLanguage(string $httpAccept) : ?self + { + if (!$httpAccept) + return null; + + $available = []; + + // e.g.: de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 + foreach (explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']) as $loc) + if (preg_match('/([a-z\-]+)(?:\s*;\s*q\s*=\s*([\.\d]+))?/ui', $loc, $m, PREG_UNMATCHED_AS_NULL)) + $available[Util::lower($m[1])] = floatVal($m[2] ?? 1); // no quality set: assume 100% + + arsort($available, SORT_NUMERIC); // highest quality on top + + foreach ($available as $code => $_) + foreach (self::cases() as $l) + if ($l->validate() && in_array($code, $l->httpCode())) + return $l; + + return null; + } + + public static function getFallback() : self + { + foreach (Locale::cases() as $l) + if ($l->validate()) + return $l; + + // wow, you really fucked up your config mate! + trigger_error('Locale::getFallback - there are no valid locales', E_USER_ERROR); + return self::EN; + } +} + +?> diff --git a/includes/loot.class.php b/includes/loot.class.php deleted file mode 100644 index 8224e6c3..00000000 --- a/includes/loot.class.php +++ /dev/null @@ -1,638 +0,0 @@ -results); - - foreach ($this->results as $k => $__) - yield $k => $this->results[$k]; - } - - public function getResult() : array - { - return $this->results; - } - - private function createStack(array $l) : string // issue: TC always has an equal distribution between min/max - { - if (empty($l['min']) || empty($l['max']) || $l['max'] <= $l['min']) - return ''; - - $stack = []; - for ($i = $l['min']; $i <= $l['max']; $i++) - $stack[$i] = round(100 / (1 + $l['max'] - $l['min']), 3); - - // yes, it wants a string .. how weired is that.. - return json_encode($stack, JSON_NUMERIC_CHECK); // do not replace with Util::toJSON ! - } - - private function storeJSGlobals(array $data) : void - { - foreach ($data as $type => $jsData) - { - foreach ($jsData as $k => $v) - { - // was already set at some point with full data - if (isset($this->jsGlobals[$type][$k]) && is_array($this->jsGlobals[$type][$k])) - continue; - - $this->jsGlobals[$type][$k] = $v; - } - } - } - - private function calcChance(array $refs, array $parents = []) : array - { - $retData = []; - $retKeys = []; - - foreach ($refs as $rId => $ref) - { - // check for possible database inconsistencies - if (!$ref['chance'] && !$ref['isGrouped']) - trigger_error('Loot by Item: Ungrouped Item/Ref '.$ref['item'].' has 0% chance assigned!', E_USER_WARNING); - - if ($ref['isGrouped'] && $ref['sumChance'] > 100) - trigger_error('Loot by Item: Group with Item/Ref '.$ref['item'].' has '.number_format($ref['sumChance'], 2).'% total chance! Some items cannot drop!', E_USER_WARNING); - - if ($ref['isGrouped'] && $ref['sumChance'] >= 100 && !$ref['chance']) - trigger_error('Loot by Item: Item/Ref '.$ref['item'].' with adaptive chance cannot drop. Group already at 100%!', E_USER_WARNING); - - $chance = abs($ref['chance'] ?: (100 - $ref['sumChance']) / $ref['nZeroItems']) / 100; - - // apply inherited chanceMods - if (isset($this->chanceMods[$ref['item']])) - { - $chance *= $this->chanceMods[$ref['item']][0]; - $chance = 1 - pow(1 - $chance, $this->chanceMods[$ref['item']][1]); - } - - // save chance for parent-ref - $this->chanceMods[$rId] = [$chance, $ref['multiplier']]; - - // refTemplate doesn't point to a new ref -> we are done - if (!in_array($rId, $parents)) - { - $data = array( - 'percent' => $chance, - 'stack' => [$ref['min'], $ref['max']], - 'count' => 1 // ..and one for the sort script - ); - - if ($_ = self::createStack($ref)) - $data['pctstack'] = $_; - - // sort highest chances first - $i = 0; - for (; $i < count($retData); $i++) - if ($retData[$i]['percent'] < $data['percent']) - break; - - array_splice($retData, $i, 0, [$data]); - array_splice($retKeys, $i, 0, [$rId]); - } - } - - return array_combine($retKeys, $retData); - } - - private function getByContainerRecursive(string $tableName, int $lootId, array &$handledRefs, int $groupId = 0, float $baseChance = 1.0) : ?array - { - $loot = []; - $rawItems = []; - - if (!$tableName || !$lootId) - return null; - - $rows = DB::World()->select('SELECT * FROM ?# WHERE entry = ?d{ AND groupid = ?d}', $tableName, $lootId, $groupId ?: DBSIMPLE_SKIP); - if (!$rows) - return null; - - $groupChances = []; - $nGroupEquals = []; - foreach ($rows as $entry) - { - $set = array( - 'quest' => $entry['QuestRequired'], - 'group' => $entry['GroupId'], - 'parentRef' => $tableName == LOOT_REFERENCE ? $lootId : 0, - 'realChanceMod' => $baseChance, - 'groupChance' => 0 - ); - - // if ($entry['LootMode'] > 1) - // { - $buff = []; - for ($i = 0; $i < 8; $i++) - if ($entry['LootMode'] & (1 << $i)) - $buff[] = $i + 1; - - $set['mode'] = implode(', ', $buff); - // } - // else - // $set['mode'] = 0; - - /* - modes:{"mode":8,"4":{"count":7173,"outof":17619},"8":{"count":7173,"outof":10684}} - ignore lootmodes from sharedDefines.h use different creatures/GOs from each template - modes.mode = b6543210 - ||||||'dungeon heroic - |||||'dungeon normal - ||||' - |||'10man normal - ||'25man normal - |'10man heroic - '25man heroic - */ - - if ($entry['Reference']) - { - // bandaid.. remove when propperly handling lootmodes - if (!in_array($entry['Reference'], $handledRefs)) - { // todo (high): find out, why i used this in the first place. (don't do drugs, kids) - [$data, $raw] = self::getByContainerRecursive(LOOT_REFERENCE, $entry['Reference'], $handledRefs, /*$entry['GroupId'],*/ 0, $entry['Chance'] / 100); - - $handledRefs[] = $entry['Reference']; - - $loot = array_merge($loot, $data); - $rawItems = array_merge($rawItems, $raw); - } - $set['reference'] = $entry['Reference']; - $set['multiplier'] = $entry['MaxCount']; - } - else - { - $rawItems[] = $entry['Item']; - $set['content'] = $entry['Item']; - $set['min'] = $entry['MinCount']; - $set['max'] = $entry['MaxCount']; - } - - if (!isset($groupChances[$entry['GroupId']])) - { - $groupChances[$entry['GroupId']] = 0; - $nGroupEquals[$entry['GroupId']] = 0; - } - - if ($set['quest'] || !$set['group']) - $set['groupChance'] = $entry['Chance']; - else if ($entry['GroupId'] && !$entry['Chance']) - { - $nGroupEquals[$entry['GroupId']]++; - $set['groupChance'] = &$groupChances[$entry['GroupId']]; - } - else if ($entry['GroupId'] && $entry['Chance']) - { - $set['groupChance'] = $entry['Chance']; - - if (!$entry['Reference']) - { - if (empty($groupChances[$entry['GroupId']])) - $groupChances[$entry['GroupId']] = 0; - - $groupChances[$entry['GroupId']] += $entry['Chance']; - } - } - else // shouldn't have happened - { - trigger_error('Unhandled case in calculating chance for item '.$entry['Item'].'!', E_USER_WARNING); - continue; - } - - $loot[] = $set; - } - - foreach (array_keys($nGroupEquals) as $k) - { - $sum = $groupChances[$k]; - if (!$sum) - $sum = 0; - else if ($sum >= 100.01) - { - trigger_error('Loot entry '.$lootId.' / group '.$k.' has a total chance of '.number_format($sum, 2).'%. Some items cannot drop!', E_USER_WARNING); - $sum = 100; - } - // is applied as backReference to items with 0-chance - $groupChances[$k] = (100 - $sum) / ($nGroupEquals[$k] ?: 1); - } - - return [$loot, array_unique($rawItems)]; - } - - public function getByContainer(string $table, int $entry): bool - { - $this->entry = intVal($entry); - - if (!in_array($table, $this->lootTemplates) || !$this->entry) - return false; - - /* - todo (high): implement conditions on loot (and conditions in general) - - also - - // if (is_array($this->entry) && in_array($table, [LOOT_CREATURE, LOOT_GAMEOBJECT]) - // iterate over the 4 available difficulties and assign modes - - - modes:{"mode":1,"1":{"count":4408,"outof":16013},"4":{"count":4408,"outof":22531}} - */ - $handledRefs = []; - $struct = self::getByContainerRecursive($table, $this->entry, $handledRefs); - if (!$struct) - return false; - - $items = new ItemList(array(['i.id', $struct[1]], CFG_SQL_LIMIT_NONE)); - $this->jsGlobals = $items->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED); - $foo = $items->getListviewData(); - - // assign listview LV rows to loot rows, not the other way round! The same item may be contained multiple times - foreach ($struct[0] as $loot) - { - $base = array( - 'percent' => round($loot['groupChance'] * $loot['realChanceMod'], 3), - 'group' => $loot['group'], - 'quest' => $loot['quest'], - 'count' => 1 // satisfies the sort-script - ); - - if ($_ = $loot['mode']) - $base['mode'] = $_; - - if ($_ = $loot['parentRef']) - $base['reference'] = $_; - - if ($_ = self::createStack($loot)) - $base['pctstack'] = $_; - - if (empty($loot['reference'])) // regular drop - { - if (!User::isInGroup(U_GROUP_EMPLOYEE)) - { - if (!isset($this->results[$loot['content']])) - $this->results[$loot['content']] = array_merge($foo[$loot['content']], $base, ['stack' => [$loot['min'], $loot['max']]]); - else - $this->results[$loot['content']]['percent'] += $base['percent']; - } - else // in case of limited trash loot, check if $foo[] exists - $this->results[] = array_merge($foo[$loot['content']], $base, ['stack' => [$loot['min'], $loot['max']]]); - } - else if (User::isInGroup(U_GROUP_EMPLOYEE)) // create dummy for ref-drop - { - $data = array( - 'id' => $loot['reference'], - 'name' => '@REFERENCE: '.$loot['reference'], - 'icon' => 'trade_engineering', - 'stack' => [$loot['multiplier'], $loot['multiplier']] - ); - $this->results[] = array_merge($base, $data); - - $this->jsGlobals[Type::ITEM][$loot['reference']] = $data; - } - } - - // move excessive % to extra loot - if (!User::isInGroup(U_GROUP_EMPLOYEE)) - { - foreach ($this->results as &$_) - { - if ($_['percent'] <= 100) - continue; - - while ($_['percent'] > 200) - { - $_['stack'][0]++; - $_['stack'][1]++; - $_['percent'] -= 100; - } - - $_['stack'][1]++; - $_['percent'] = 100; - } - } - else - { - $fields = ['mode', 'reference']; - $base = []; - $set = 0; - foreach ($this->results as $foo) - { - foreach ($fields as $idx => $field) - { - $val = isset($foo[$field]) ? $foo[$field] : 0; - if (!isset($base[$idx])) - $base[$idx] = $val; - else if ($base[$idx] != $val) - $set |= 1 << $idx; - } - - if ($set == (pow(2, count($fields)) - 1)) - break; - } - - $this->extraCols[] = "\$Listview.funcBox.createSimpleCol('group', 'Group', '7%', 'group')"; - foreach ($fields as $idx => $field) - if ($set & (1 << $idx)) - $this->extraCols[] = "\$Listview.funcBox.createSimpleCol('".$field."', '".Util::ucFirst($field)."', '7%', '".$field."')"; - } - - return true; - } - - public function getByItem(int $entry, int $maxResults = CFG_SQL_LIMIT_DEFAULT, array $lootTableList = []) : bool - { - $this->entry = intVal($entry); - - if (!$this->entry) - return false; - - // [fileName, tabData, tabName, tabId, extraCols, hiddenCols, visibleCols] - $tabsFinal = array( - ['item', [], '$LANG.tab_containedin', 'contained-in-item', [], [], []], - ['item', [], '$LANG.tab_disenchantedfrom', 'disenchanted-from', [], [], []], - ['item', [], '$LANG.tab_prospectedfrom', 'prospected-from', [], [], []], - ['item', [], '$LANG.tab_milledfrom', 'milled-from', [], [], []], - ['creature', [], '$LANG.tab_droppedby', 'dropped-by', [], [], []], - ['creature', [], '$LANG.tab_pickpocketedfrom', 'pickpocketed-from', [], [], []], - ['creature', [], '$LANG.tab_skinnedfrom', 'skinned-from', [], [], []], - ['creature', [], '$LANG.tab_minedfromnpc', 'mined-from-npc', [], [], []], - ['creature', [], '$LANG.tab_salvagedfrom', 'salvaged-from', [], [], []], - ['creature', [], '$LANG.tab_gatheredfromnpc', 'gathered-from-npc', [], [], []], - ['quest', [], '$LANG.tab_rewardfrom', 'reward-from-quest', [], [], []], - ['zone', [], '$LANG.tab_fishedin', 'fished-in-zone', [], [], []], - ['object', [], '$LANG.tab_containedin', 'contained-in-object', [], [], []], - ['object', [], '$LANG.tab_minedfrom', 'mined-from-object', [], [], []], - ['object', [], '$LANG.tab_gatheredfrom', 'gathered-from-object', [], [], []], - ['object', [], '$LANG.tab_fishedin', 'fished-in-object', [], [], []], - ['spell', [], '$LANG.tab_createdby', 'created-by', [], [], []], - ['achievement', [], '$LANG.tab_rewardfrom', 'reward-from-achievement', [], [], []] - ); - $refResults = []; - $query = 'SELECT - lt1.entry AS ARRAY_KEY, - IF(lt1.reference = 0, lt1.item, lt1.reference) AS item, - lt1.chance, - SUM(IF(lt2.chance = 0, 1, 0)) AS nZeroItems, - SUM(IF(lt2.reference = 0, lt2.chance, 0)) AS sumChance, - IF(lt1.groupid > 0, 1, 0) AS isGrouped, - IF(lt1.reference = 0, lt1.mincount, 1) AS min, - IF(lt1.reference = 0, lt1.maxcount, 1) AS max, - IF(lt1.reference > 0, lt1.maxcount, 1) AS multiplier - FROM - ?# lt1 - LEFT JOIN - ?# lt2 ON lt1.entry = lt2.entry AND lt1.groupid = lt2.groupid - WHERE - %s - GROUP BY lt2.entry, lt2.groupid'; - - /* - get references containing the item - */ - $newRefs = DB::World()->select( - sprintf($query, 'lt1.item = ?d AND lt1.reference = 0'), - LOOT_REFERENCE, LOOT_REFERENCE, - $this->entry - ); - - while ($newRefs) - { - $curRefs = $newRefs; - $newRefs = DB::World()->select( - sprintf($query, 'lt1.reference IN (?a)'), - LOOT_REFERENCE, LOOT_REFERENCE, - array_keys($curRefs) - ); - - $refResults += $this->calcChance($curRefs, array_column($newRefs, 'item')); - } - - /* - search the real loot-templates for the itemId and gathered refds - */ - for ($i = 1; $i < count($this->lootTemplates); $i++) - { - if ($lootTableList && !in_array($this->lootTemplates[$i], $lootTableList)) - continue; - - $result = $this->calcChance(DB::World()->select( - sprintf($query, '{lt1.reference IN (?a) OR }(lt1.reference = 0 AND lt1.item = ?d)'), - $this->lootTemplates[$i], $this->lootTemplates[$i], - $refResults ? array_keys($refResults) : DBSIMPLE_SKIP, - $this->entry - )); - - // do not skip here if $result is empty. Additional loot for spells and quest is added separately - - // format for actual use - foreach ($result as $k => $v) - { - unset($result[$k]); - $v['percent'] = round($v['percent'] * 100, 3); - $result[abs($k)] = $v; - } - - // cap fetched entries to the sql-limit to guarantee, that the highest chance items get selected first - // screws with GO-loot and skinnig-loot as these templates are shared for several tabs (fish, herb, ore) (herb, ore, leather) - $ids = array_slice(array_keys($result), 0, $maxResults); - - switch ($this->lootTemplates[$i]) - { - case LOOT_CREATURE: $field = 'lootId'; $tabId = 4; break; - case LOOT_PICKPOCKET: $field = 'pickpocketLootId'; $tabId = 5; break; - case LOOT_SKINNING: $field = 'skinLootId'; $tabId = -6; break; // assigned later - case LOOT_PROSPECTING: $field = 'id'; $tabId = 2; break; - case LOOT_MILLING: $field = 'id'; $tabId = 3; break; - case LOOT_ITEM: $field = 'id'; $tabId = 0; break; - case LOOT_DISENCHANT: $field = 'disenchantId'; $tabId = 1; break; - case LOOT_FISHING: $field = 'id'; $tabId = 11; break; // subAreas are currently ignored - case LOOT_GAMEOBJECT: - if (!$ids) - continue 2; - - $srcObj = new GameObjectList(array(['lootId', $ids])); - if ($srcObj->error) - continue 2; - - $srcData = $srcObj->getListviewData(); - - foreach ($srcObj->iterate() as $curTpl) - { - switch ($curTpl['typeCat']) - { - case 25: $tabId = 15; break; // fishing node - case -3: $tabId = 14; break; // herb - case -4: $tabId = 13; break; // vein - default: $tabId = 12; break; // general chest loot - } - - $tabsFinal[$tabId][1][] = array_merge($srcData[$srcObj->id], $result[$srcObj->getField('lootId')]); - $tabsFinal[$tabId][4][] = '$Listview.extraCols.percent'; - if ($tabId != 15) - $tabsFinal[$tabId][6][] = 'skill'; - } - continue 2; - case LOOT_MAIL: - // quest part - $conditions = array(['rewardChoiceItemId1', $this->entry], ['rewardChoiceItemId2', $this->entry], ['rewardChoiceItemId3', $this->entry], ['rewardChoiceItemId4', $this->entry], ['rewardChoiceItemId5', $this->entry], - ['rewardChoiceItemId6', $this->entry], ['rewardItemId1', $this->entry], ['rewardItemId2', $this->entry], ['rewardItemId3', $this->entry], ['rewardItemId4', $this->entry], - 'OR'); - if ($ids) - $conditions[] = ['rewardMailTemplateId', $ids]; - - $srcObj = new QuestList($conditions); - if (!$srcObj->error) - { - self::storeJSGlobals($srcObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); - $srcData = $srcObj->getListviewData(); - - foreach ($srcObj->iterate() as $_) - $tabsFinal[10][1][] = array_merge($srcData[$srcObj->id], empty($result[$srcObj->id]) ? ['percent' => -1] : $result[$srcObj->id]); - } - - // achievement part - $conditions = array(['itemExtra', $this->entry]); - if ($ar = DB::World()->selectCol('SELECT ID FROM achievement_reward WHERE ItemID = ?d{ OR MailTemplateID IN (?a)}', $this->entry, $ids ?: DBSIMPLE_SKIP)) - array_push($conditions, ['id', $ar], 'OR'); - - $srcObj = new AchievementList($conditions); - if (!$srcObj->error) - { - self::storeJSGlobals($srcObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_REWARDS)); - $srcData = $srcObj->getListviewData(); - - foreach ($srcObj->iterate() as $_) - $tabsFinal[17][1][] = array_merge($srcData[$srcObj->id], empty($result[$srcObj->id]) ? ['percent' => -1] : $result[$srcObj->id]); - - $tabsFinal[17][5][] = 'rewards'; - $tabsFinal[17][6][] = 'category'; - } - continue 2; - case LOOT_SPELL: - $conditions = array( - 'OR', - ['AND', ['effect1CreateItemId', $this->entry], ['OR', ['effect1Id', SpellList::$effects['itemCreate']], ['effect1AuraId', SpellList::$auras['itemCreate']]]], - ['AND', ['effect2CreateItemId', $this->entry], ['OR', ['effect2Id', SpellList::$effects['itemCreate']], ['effect2AuraId', SpellList::$auras['itemCreate']]]], - ['AND', ['effect3CreateItemId', $this->entry], ['OR', ['effect3Id', SpellList::$effects['itemCreate']], ['effect3AuraId', SpellList::$auras['itemCreate']]]], - ); - if ($ids) - $conditions[] = ['id', $ids]; - - $srcObj = new SpellList($conditions); - if (!$srcObj->error) - { - self::storeJSGlobals($srcObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); - $srcData = $srcObj->getListviewData(); - - if (!empty($result)) - $tabsFinal[16][4][] = '$Listview.extraCols.percent'; - - if ($srcObj->hasSetFields(['reagent1'])) - $tabsFinal[16][6][] = 'reagents'; - - foreach ($srcObj->iterate() as $_) - $tabsFinal[16][1][] = array_merge($srcData[$srcObj->id], empty($result[$srcObj->id]) ? ['percent' => -1] : $result[$srcObj->id]); - } - continue 2; - } - - if (!$ids) - continue; - - switch ($tabsFinal[abs($tabId)][0]) - { - case 'creature': // new CreatureList - case 'item': // new ItemList - case 'zone': // new ZoneList - $oName = ucFirst($tabsFinal[abs($tabId)][0]).'List'; - $srcObj = new $oName(array([$field, $ids])); - if (!$srcObj->error) - { - $srcData = $srcObj->getListviewData(); - self::storeJSGlobals($srcObj->getJSGlobals(GLOBALINFO_SELF | GLOBALINFO_RELATED)); - - foreach ($srcObj->iterate() as $curTpl) - { - if ($tabId < 0 && $curTpl['typeFlags'] & NPC_TYPEFLAG_HERBLOOT) - $tabId = 9; - else if ($tabId < 0 && $curTpl['typeFlags'] & NPC_TYPEFLAG_ENGINEERLOOT) - $tabId = 8; - else if ($tabId < 0 && $curTpl['typeFlags'] & NPC_TYPEFLAG_MININGLOOT) - $tabId = 7; - else if ($tabId < 0) - $tabId = abs($tabId); // general case (skinning) - - $tabsFinal[$tabId][1][] = array_merge($srcData[$srcObj->id], $result[$srcObj->getField($field)]); - $tabsFinal[$tabId][4][] = '$Listview.extraCols.percent'; - } - } - break; - } - } - - foreach ($tabsFinal as $tabId => $data) - { - $tabData = array( - 'data' => $data[1], - 'name' => $data[2], - 'id' => $data[3] - ); - - if ($data[4]) - $tabData['extraCols'] = array_unique($data[4]); - - if ($data[5]) - $tabData['hiddenCols'] = array_unique($data[5]); - - if ($data[6]) - $tabData['visibleCols'] = array_unique($data[6]); - - $this->results[$tabId] = [$data[0], $tabData]; - } - - return true; - } -} - -?> diff --git a/includes/markup.class.php b/includes/markup.class.php deleted file mode 100644 index 13df1c50..00000000 --- a/includes/markup.class.php +++ /dev/null @@ -1,148 +0,0 @@ -text = $text; - } - - public function parseGlobalsFromText(&$jsg = []) - { - if (preg_match_all(self::$dbTagPattern, $this->text, $matches, PREG_SET_ORDER)) - { - foreach ($matches as $match) - { - if ($match[1] == 'statistic') - $match[1] = 'achievement'; - else if ($match[1] == 'icondb') - $match[1] = 'icon'; - - if ($match[1] == 'money') - { - if (stripos($match[0], 'items')) - { - if (preg_match('/items=([0-9,]+)/i', $match[0], $submatch)) - { - $sm = explode(',', $submatch[1]); - for ($i = 0; $i < count($sm); $i+=2) - $this->jsGlobals[Type::ITEM][$sm[$i]] = $sm[$i]; - } - } - - if (stripos($match[0], 'currency')) - { - if (preg_match('/currency=([0-9,]+)/i', $match[0], $submatch)) - { - $sm = explode(',', $submatch[1]); - for ($i = 0; $i < count($sm); $i+=2) - $this->jsGlobals[Type::CURRENCY][$sm[$i]] = $sm[$i]; - } - } - } - else if ($type = Type::getIndexFrom(Type::IDX_FILE_STR, $match[1])) - $this->jsGlobals[$type][$match[2]] = $match[2]; - } - } - - Util::mergeJsGlobals($jsg, $this->jsGlobals); - - return $this->jsGlobals; - } - - public function stripTags($globals = []) - { - // since this is an article the db-tags should already be parsed - $text = preg_replace_callback(self::$dbTagPattern, function ($match) use ($globals) { - if ($match[1] == 'statistic') - $match[1] = 'achievement'; - else if ($match[1] == 'icondb') - $match[1] = 'icon'; - else if ($match[1] == 'money') - { - $moneys = []; - if (stripos($match[0], 'items')) - { - if (preg_match('/items=([0-9,]+)/i', $match[0], $submatch)) - { - $sm = explode(',', $submatch[1]); - for ($i = 0; $i < count($sm); $i += 2) - { - if (!empty($globals[Type::ITEM][1][$sm[$i]])) - $moneys[] = $globals[Type::ITEM][1][$sm[$i]]['name']; - else - $moneys[] = Util::ucFirst(Lang::game('item')).' #'.$sm[$i]; - } - } - } - - if (stripos($match[0], 'currency')) - { - if (preg_match('/currency=([0-9,]+)/i', $match[0], $submatch)) - { - $sm = explode(',', $submatch[1]); - for ($i = 0; $i < count($sm); $i += 2) - { - if (!empty($globals[Type::CURRENCY][1][$sm[$i]])) - $moneys[] = $globals[Type::CURRENCY][1][$sm[$i]]['name']; - else - $moneys[] = Util::ucFirst(Lang::game('curency')).' #'.$sm[$i]; - } - } - } - - return Lang::concat($moneys); - } - if ($type = Type::getIndexFrom(Type::IDX_FILE_STR, $match[1])) - { - if (!empty($globals[$type][1][$match[2]])) - return $globals[$type][1][$match[2]]['name']; - else - return Util::ucFirst(Lang::game($match[1])).' #'.$match[2]; - } - - trigger_error('Markup::stripTags() - encountered unhandled db-tag: '.var_export($match)); - return ''; - }, $this->text); - - $text = str_replace('[br]', "\n", $text); - $stripped = ''; - - $inTag = false; - for ($i = 0; $i < strlen($text); $i++) - { - if ($text[$i] == '[') - $inTag = true; - if (!$inTag) - $stripped .= $text[$i]; - if ($text[$i] == ']') - $inTag = false; - } - - return $stripped; - } - - public function fromHtml() - { - } - - public function toHtml() - { - } -} - -?> diff --git a/includes/profiler.class.php b/includes/profiler.class.php deleted file mode 100644 index 0b1fa509..00000000 --- a/includes/profiler.class.php +++ /dev/null @@ -1,921 +0,0 @@ - [INVTYPE_HEAD], // head - 2 => [INVTYPE_NECK], // neck - 3 => [INVTYPE_SHOULDERS], // shoulder - 4 => [INVTYPE_BODY], // shirt - 5 => [INVTYPE_CHEST, INVTYPE_ROBE], // chest - 6 => [INVTYPE_WAIST], // waist - 7 => [INVTYPE_LEGS], // legs - 8 => [INVTYPE_FEET], // feet - 9 => [INVTYPE_WRISTS], // wrists - 10 => [INVTYPE_HANDS], // hands - 11 => [INVTYPE_FINGER], // finger1 - 12 => [INVTYPE_FINGER], // finger2 - 13 => [INVTYPE_TRINKET], // trinket1 - 14 => [INVTYPE_TRINKET], // trinket2 - 15 => [INVTYPE_CLOAK], // chest - 16 => [INVTYPE_WEAPONMAINHAND, INVTYPE_WEAPON, INVTYPE_2HWEAPON], // mainhand - 17 => [INVTYPE_WEAPONOFFHAND, INVTYPE_WEAPON, INVTYPE_HOLDABLE, INVTYPE_SHIELD], // offhand - 18 => [INVTYPE_RANGED, INVTYPE_THROWN, INVTYPE_RELIC], // ranged + relic - 19 => [INVTYPE_TABARD], // tabard - ); - - public static $raidProgression = array( // statisticAchievement => relevantCriterium ; don't forget to enable this in /js/Profiler.js as well - 1361 => 5100, 1362 => 5101, 1363 => 5102, 1365 => 5104, 1366 => 5108, 1364 => 5110, 1369 => 5112, 1370 => 5113, 1371 => 5114, 1372 => 5117, 1373 => 5119, 1374 => 5120, 1375 => 7805, 1376 => 5122, 1377 => 5123, // Naxxramas 10 - 1367 => 5103, 1368 => 5111, 1378 => 5124, 1379 => 5125, 1380 => 5126, 1381 => 5127, 1382 => 5128, 1383 => 7806, 1384 => 5130, 1385 => 5131, 1386 => 5132, 1387 => 5133, 1388 => 5134, 1389 => 5135, 1390 => 5136, // Naxxramas 25 - 2856 => 9938, 2857 => 9939, 2858 => 9940, 2859 => 9941, 2861 => 9943, 2865 => 9947, 2866 => 9948, 2868 => 9950, 2869 => 9951, 2870 => 9952, 2863 => 10558, 2864 => 10559, 2862 => 10560, 2867 => 10565, 2860 => 10580, // Ulduar 10 - 2872 => 9954, 2873 => 9955, 2874 => 9956, 2884 => 9957, 2875 => 9959, 2879 => 9963, 2880 => 9964, 2882 => 9966, 2883 => 9967, 3236 => 10542, 3257 => 10561, 3256 => 10562, 3258 => 10563, 2881 => 10566, 2885 => 10581, // Ulduar 25 - 1098 => 3271, // Onyxia's Lair 10 - 1756 => 13345, // Onyxia's Lair 25 - 4031 => 12230, 4034 => 12234, 4038 => 12238, 4042 => 12242, 4046 => 12246, // Trial of the Crusader 25 nh - 4029 => 12231, 4035 => 12235, 4039 => 12239, 4043 => 12243, 4047 => 12247, // Trial of the Crusader 25 hc - 4030 => 12229, 4033 => 12233, 4037 => 12237, 4041 => 12241, 4045 => 12245, // Trial of the Crusader 10 hc - 4028 => 12228, 4032 => 12232, 4036 => 12236, 4040 => 12240, 4044 => 12244, // Trial of the Crusader 10 nh - 4642 => 13091, 4656 => 13106, 4661 => 13111, 4664 => 13114, 4667 => 13117, 4670 => 13120, 4673 => 13123, 4676 => 13126, 4679 => 13129, 4682 => 13132, 4685 => 13135, 4688 => 13138, // Icecrown Citadel 25 hc - 4641 => 13092, 4655 => 13105, 4660 => 13109, 4663 => 13112, 4666 => 13115, 4669 => 13118, 4672 => 13121, 4675 => 13124, 4678 => 13127, 4681 => 13130, 4683 => 13133, 4687 => 13136, // Icecrown Citadel 25 nh - 4640 => 13090, 4654 => 13104, 4659 => 13110, 4662 => 13113, 4665 => 13116, 4668 => 13119, 4671 => 13122, 4674 => 13125, 4677 => 13128, 4680 => 13131, 4684 => 13134, 4686 => 13137, // Icecrown Citadel 10 hc - 4639 => 13089, 4643 => 13093, 4644 => 13094, 4645 => 13095, 4646 => 13096, 4647 => 13097, 4648 => 13098, 4649 => 13099, 4650 => 13100, 4651 => 13101, 4652 => 13102, 4653 => 13103, // Icecrown Citadel 10 nh - 4823 => 13467, // Ruby Sanctum 25 hc - 4820 => 13465, // Ruby Sanctum 25 nh - 4822 => 13468, // Ruby Sanctum 10 hc - 4821 => 13466, // Ruby Sanctum 10 nh - ); - - public static function getBuyoutForItem($itemId) - { - if (!$itemId) - return 0; - - // try, when having filled char-DB at hand - // return DB::Characters()->selectCell('SELECT SUM(a.buyoutprice) / SUM(ii.count) FROM auctionhouse a JOIN item_instance ii ON ii.guid = a.itemguid WHERE ii.itemEntry = ?d', $itemId); - return 0; - } - - public static function queueStart(&$msg = '') - { - $queuePID = self::queueStatus(); - - if ($queuePID) - { - $msg = 'queue already running'; - return true; - } - - if (OS_WIN) // here be gremlins! .. suggested was "start /B php prQueue" as background process. but that closes itself - pclose(popen('start php prQueue --log=cache/profiling.log', 'r')); - else - exec('php prQueue --log=cache/profiling.log > /dev/null 2>/dev/null &'); - - usleep(500000); - if (self::queueStatus()) - return true; - else - { - $msg = 'failed to start queue'; - return false; - } - } - - public static function queueStatus() - { - if (!file_exists(self::PID_FILE)) - return 0; - - $pid = file_get_contents(self::PID_FILE); - $cmd = OS_WIN ? 'tasklist /NH /FO CSV /FI "PID eq %d"' : 'ps --no-headers p %d'; - - exec(sprintf($cmd, $pid), $out); - if ($out && stripos($out[0], $pid) !== false) - return $pid; - - // have pidFile but no process with this pid - self::queueFree(); - return 0; - } - - public static function queueLock($pid) - { - $queuePID = self::queueStatus(); - if ($queuePID && $queuePID != $pid) - { - trigger_error('pSync - another queue with PID #'.$queuePID.' is already running', E_USER_ERROR); - return false; - } - - // no queue running; create or overwrite pidFile - $ok = false; - if ($fh = fopen(self::PID_FILE, 'w')) - { - if (fwrite($fh, $pid)) - $ok = true; - - fclose($fh); - } - - return $ok; - } - - public static function queueFree() - { - unlink(self::PID_FILE); - } - - public static function urlize($str, $allowLocales = false, $profile = false) - { - $search = ['<', '>', ' / ', "'"]; - $replace = ['<', '>', '-', '' ]; - $str = str_replace($search, $replace, $str); - - if ($profile) - { - $str = str_replace(['(', ')'], ['', ''], $str); - $accents = array( - "ß" => "ss", - "á" => "a", "ä" => "a", "à" => "a", "â" => "a", - "è" => "e", "ê" => "e", "é" => "e", "ë" => "e", - "í" => "i", "î" => "i", "ì" => "i", "ï" => "i", - "ñ" => "n", - "ò" => "o", "ó" => "o", "ö" => "o", "ô" => "o", - "ú" => "u", "ü" => "u", "û" => "u", "ù" => "u", - "œ" => "oe", - "Á" => "A", "Ä" => "A", "À" => "A", "Â" => "A", - "È" => "E", "Ê" => "E", "É" => "E", "Ë" => "E", - "Í" => "I", "Î" => "I", "Ì" => "I", "Ï" => "I", - "Ñ" => "N", - "Ò" => "O", "Ó" => "O", "Ö" => "O", "Ô" => "O", - "Ú" => "U", "Ü" => "U", "Û" => "U", "Ù" => "U", - "Œ" => "Oe" - ); - $str = strtr($str, $accents); - } - - $str = trim($str); - - if ($allowLocales) - $str = str_replace(' ', '-', $str); - else - $str = preg_replace('/[^a-z0-9]/i', '-', $str); - - $str = str_replace('--', '-', $str); - $str = str_replace('--', '-', $str); - - $str = rtrim($str, '-'); - $str = strtolower($str); - - return $str; - } - - public static function getRealms() - { - if (DB::isConnectable(DB_AUTH) && !self::$realms) - { - self::$realms = DB::Auth()->select('SELECT - id AS ARRAY_KEY, - `name`, - CASE - WHEN timezone IN (2, 3, 4) THEN "us" - WHEN timezone IN (8, 9, 10, 11, 12) THEN "eu" - WHEN timezone = 6 THEN "kr" - WHEN timezone = 14 THEN "tw" - WHEN timezone = 16 THEN "cn" - END AS region - FROM - realmlist - WHERE - allowedSecurityLevel = 0 AND - gamebuild = ?d', - WOW_BUILD - ); - - foreach (self::$realms as $rId => $rData) - { - if (DB::isConnectable(DB_CHARACTERS . $rId)) - continue; - - // realm in db but no connection info set - unset(self::$realms[$rId]); - } - } - - return self::$realms; - } - - private static function queueInsert($realmId, $guid, $type, $localId) - { - if ($rData = DB::Aowow()->selectRow('SELECT requestTime AS time, status FROM ?_profiler_sync WHERE realm = ?d AND realmGUID = ?d AND `type` = ?d AND typeId = ?d AND status <> ?d', $realmId, $guid, $type, $localId, PR_QUEUE_STATUS_WORKING)) - { - // not on already scheduled - recalc time and set status to PR_QUEUE_STATUS_WAITING - if ($rData['status'] != PR_QUEUE_STATUS_WAITING) - { - $newTime = CFG_DEBUG ? time() : max($rData['time'] + CFG_PROFILER_RESYNC_DELAY, time()); - DB::Aowow()->query('UPDATE ?_profiler_sync SET requestTime = ?d, status = ?d, errorCode = 0 WHERE realm = ?d AND realmGUID = ?d AND `type` = ?d AND typeId = ?d', $newTime, PR_QUEUE_STATUS_WAITING, $realmId, $guid, $type, $localId); - } - } - else - DB::Aowow()->query('REPLACE INTO ?_profiler_sync (realm, realmGUID, `type`, typeId, requestTime, status, errorCode) VALUES (?d, ?d, ?d, ?d, UNIX_TIMESTAMP(), ?d, 0)', $realmId, $guid, $type, $localId, PR_QUEUE_STATUS_WAITING); - } - - public static function scheduleResync($type, $realmId, $guid) - { - $newId = 0; - - switch ($type) - { - case Type::PROFILE: - if ($newId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_profiles WHERE realm = ?d AND realmGUID = ?d', $realmId, $guid)) - self::queueInsert($realmId, $guid, Type::PROFILE, $newId); - - break; - case Type::GUILD: - if ($newId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_guild WHERE realm = ?d AND realmGUID = ?d', $realmId, $guid)) - self::queueInsert($realmId, $guid, Type::GUILD, $newId); - - break; - case Type::ARENA_TEAM: - if ($newId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_arena_team WHERE realm = ?d AND realmGUID = ?d', $realmId, $guid)) - self::queueInsert($realmId, $guid, Type::ARENA_TEAM, $newId); - - break; - default: - trigger_error('scheduling resync for unknown type #'.$type.' omiting..', E_USER_WARNING); - return 0; - } - - if (!$newId) - trigger_error('Profiler::scheduleResync() - tried to resync type #'.$type.' guid #'.$guid.' from realm #'.$realmId.' without preloaded data', E_USER_ERROR); - else if (!self::queueStart($msg)) - trigger_error('Profiler::scheduleResync() - '.$msg, E_USER_ERROR); - - return $newId; - } - - public static function resyncStatus($type, array $subjectGUIDs) - { - $response = [CFG_PROFILER_ENABLE ? 2 : 0]; // in theory you could have multiple queues; used as divisor for: (15 / x) + 2 - if (!$subjectGUIDs) - $response[] = [PR_QUEUE_STATUS_ENDED, 0, 0, PR_QUEUE_ERROR_CHAR]; - else - { - // error out all profiles with status WORKING, that are older than 60sec - DB::Aowow()->query('UPDATE ?_profiler_sync SET status = ?d, errorCode = ?d WHERE status = ?d AND requestTime < ?d', PR_QUEUE_STATUS_ERROR, PR_QUEUE_ERROR_UNK, PR_QUEUE_STATUS_WORKING, time() - MINUTE); - - $subjectStatus = DB::Aowow()->select('SELECT typeId AS ARRAY_KEY, status, realm, errorCode FROM ?_profiler_sync WHERE `type` = ?d AND typeId IN (?a)', $type, $subjectGUIDs); - $queue = DB::Aowow()->selectCol('SELECT CONCAT(type, ":", typeId) FROM ?_profiler_sync WHERE status = ?d AND requestTime < UNIX_TIMESTAMP() ORDER BY requestTime ASC', PR_QUEUE_STATUS_WAITING); - foreach ($subjectGUIDs as $guid) - { - if (empty($subjectStatus[$guid])) // whelp, thats some error.. - $response[] = [PR_QUEUE_STATUS_ERROR, 0, 0, PR_QUEUE_ERROR_UNK]; - else if ($subjectStatus[$guid]['status'] == PR_QUEUE_STATUS_ERROR) - $response[] = [PR_QUEUE_STATUS_ERROR, 0, 0, $subjectStatus[$guid]['errorCode']]; - else - $response[] = array( - $subjectStatus[$guid]['status'], - $subjectStatus[$guid]['status'] != PR_QUEUE_STATUS_READY ? CFG_PROFILER_RESYNC_PING : 0, - array_search($type.':'.$guid, $queue) + 1, - 0, - 1 // nResycTries - unsure about this one - ); - } - } - - return $response; - } - - public static function getCharFromRealm($realmId, $charGuid) - { - $char = DB::Characters($realmId)->selectRow('SELECT c.* FROM characters c WHERE c.guid = ?d', $charGuid); - if (!$char) - return false; - - // reminder: this query should not fail: a placeholder entry is created as soon as a char listview is created or profile detail page is called - $profile = DB::Aowow()->selectRow('SELECT id, lastupdated FROM ?_profiler_profiles WHERE realm = ?d AND realmGUID = ?d', $realmId, $char['guid']); - if (!$profile) - return false; // well ... it failed - - $profileId = $profile['id']; - - CLI::write('fetching char '.$char['name'].' (#'.$charGuid.') from realm #'.$realmId); - - if (!$char['online'] && $char['logout_time'] <= $profile['lastupdated']) - { - DB::Aowow()->query('UPDATE ?_profiler_profiles SET lastupdated = ?d WHERE id = ?d', time(), $profileId); - CLI::write('char did not log in since last update. skipping...'); - return true; - } - - CLI::write('writing...'); - - $ra = (1 << ($char['race'] - 1)); - $cl = (1 << ($char['class'] - 1)); - - /*************/ - /* equipment */ - /*************/ - - /* enchantment-Indizes - * 0: permEnchant - * 3: tempEnchant - * 6: gem1 - * 9: gem2 - * 12: gem3 - * 15: socketBonus [not used] - * 18: extraSocket [only check existance] - * 21 - 30: randomProp enchantments - */ - - - DB::Aowow()->query('DELETE FROM ?_profiler_items WHERE id = ?d', $profileId); - $items = DB::Characters($realmId)->select('SELECT ci.slot AS ARRAY_KEY, ii.itemEntry, ii.enchantments, ii.randomPropertyId FROM character_inventory ci JOIN item_instance ii ON ci.item = ii.guid WHERE ci.guid = ?d AND bag = 0 AND slot BETWEEN 0 AND 18', $char['guid']); - - $gemItems = []; - $permEnch = []; - $mhItem = 0; - $ohItem = 0; - - foreach ($items as $slot => $item) - { - $ench = explode(' ', $item['enchantments']); - $gEnch = []; - foreach ([6, 9, 12] as $idx) - if ($ench[$idx]) - $gEnch[$idx] = $ench[$idx]; - - if ($gEnch) - { - $gi = DB::Aowow()->selectCol('SELECT gemEnchantmentId AS ARRAY_KEY, id FROM ?_items WHERE class = 3 AND gemEnchantmentId IN (?a)', $gEnch); - foreach ($gEnch as $eId) - { - if (isset($gemItems[$eId])) - $gemItems[$eId][1]++; - else - $gemItems[$eId] = [$gi[$eId], 1]; - } - } - - if ($slot + 1 == 16) - $mhItem = $item['itemEntry']; - if ($slot + 1 == 17) - $ohItem = $item['itemEntry']; - - if ($ench[0]) - $permEnch[$slot] = $ench[0]; - - $data = array( - 'id' => $profileId, - 'slot' => $slot + 1, - 'item' => $item['itemEntry'], - 'subItem' => $item['randomPropertyId'], - 'permEnchant' => $ench[0], - 'tempEnchant' => $ench[3], - 'extraSocket' => (int)!!$ench[18], - 'gem1' => isset($gemItems[$ench[6]]) ? $gemItems[$ench[6]][0] : 0, - 'gem2' => isset($gemItems[$ench[9]]) ? $gemItems[$ench[9]][0] : 0, - 'gem3' => isset($gemItems[$ench[12]]) ? $gemItems[$ench[12]][0] : 0, - 'gem4' => 0 // serverside items cant have more than 3 sockets. (custom profile thing) - ); - - DB::Aowow()->query('INSERT INTO ?_profiler_items (?#) VALUES (?a)', array_keys($data), array_values($data)); - } - - CLI::write(' ..inventory'); - - - /**************/ - /* basic info */ - /**************/ - - $data = array( - 'realm' => $realmId, - 'realmGUID' => $charGuid, - 'name' => $char['name'], - 'renameItr' => 0, - 'race' => $char['race'], - 'class' => $char['class'], - 'level' => $char['level'], - 'gender' => $char['gender'], - 'skincolor' => $char['skin'], - 'facetype' => $char['face'], // maybe features - 'hairstyle' => $char['hairStyle'], - 'haircolor' => $char['hairColor'], - 'features' => $char['facialStyle'], // maybe facetype - 'title' => $char['chosenTitle'] ? DB::Aowow()->selectCell('SELECT id FROM ?_titles WHERE bitIdx = ?d', $char['chosenTitle']) : 0, - 'playedtime' => $char['totaltime'], - 'nomodelMask' => ($char['playerFlags'] & 0x400 ? (1 << SLOT_HEAD) : 0) | ($char['playerFlags'] & 0x800 ? (1 << SLOT_BACK) : 0), - 'talenttree1' => 0, - 'talenttree2' => 0, - 'talenttree3' => 0, - 'talentbuild1' => '', - 'talentbuild2' => '', - 'glyphs1' => '', - 'glyphs2' => '', - 'activespec' => $char['activeTalentGroup'], - 'guild' => null, - 'guildRank' => null, - 'gearscore' => 0, - 'achievementpoints' => 0 - ); - - // char is flagged for rename - if ($char['at_login'] & 0x1) - { - $ri = DB::Aowow()->selectCell('SELECT MAX(renameItr) FROM ?_profiler_profiles WHERE realm = ?d AND realmGUID = ?d AND name = ?', $realmId, $charGuid, $char['name']); - $data['renameItr'] = $ri ? ++$ri : 1; - } - - /********************/ - /* talents + glyphs */ - /********************/ - - $t = DB::Characters($realmId)->selectCol('SELECT talentGroup AS ARRAY_KEY, spell AS ARRAY_KEY2, spell FROM character_talent WHERE guid = ?d', $char['guid']); - $g = DB::Characters($realmId)->select('SELECT talentGroup AS ARRAY_KEY, glyph1 AS g1, glyph2 AS g4, glyph3 AS g5, glyph4 AS g2, glyph5 AS g3, glyph6 AS g6 FROM character_glyphs WHERE guid = ?d', $char['guid']); - for ($i = 0; $i < 2; $i++) - { - // talents - for ($j = 0; $j < 3; $j++) - { - $_ = DB::Aowow()->selectCol('SELECT spell AS ARRAY_KEY, MAX(IF(spell IN (?a), `rank`, 0)) FROM ?_talents WHERE class = ?d AND tab = ?d GROUP BY id ORDER BY `row`, `col` ASC', !empty($t[$i]) ? $t[$i] : [0], $char['class'], $j); - $data['talentbuild'.($i + 1)] .= implode('', $_); - if ($data['activespec'] == $i) - $data['talenttree'.($j + 1)] = array_sum($_); - } - - // glyphs - if (isset($g[$i])) - { - $gProps = []; - for ($j = 1; $j <= 6; $j++) - if ($g[$i]['g'.$j]) - $gProps[$j] = $g[$i]['g'.$j]; - - if ($gProps) - if ($gItems = DB::Aowow()->selectCol('SELECT i.id FROM ?_glyphproperties gp JOIN ?_spell s ON s.effect1MiscValue = gp.id AND s.effect1Id = 74 JOIN ?_items i ON i.class = 16 AND i.spellId1 = s.id WHERE gp.id IN (?a)', $gProps)) - $data['glyphs'.($i + 1)] = implode(':', $gItems); - } - } - - $t = array( - 'spent' => [$data['talenttree1'], $data['talenttree2'], $data['talenttree3']], - 'spec' => 0 - ); - if ($t['spent'][0] > $t['spent'][1] && $t['spent'][0] > $t['spent'][2]) - $t['spec'] = 1; - else if ($t['spent'][1] > $t['spent'][0] && $t['spent'][1] > $t['spent'][2]) - $t['spec'] = 2; - else if ($t['spent'][2] > $t['spent'][1] && $t['spent'][2] > $t['spent'][0]) - $t['spec'] = 3; - - // calc gearscore - if ($items) - $data['gearscore'] += (new ItemList(array(['id', array_column($items, 'itemEntry')])))->getScoreTotal($data['class'], $t, $mhItem, $ohItem); - - if ($gemItems) - { - $gemScores = new ItemList(array(['id', array_column($gemItems, 0)])); - foreach ($gemItems as [$itemId, $mult]) - if (isset($gemScores->json[$itemId]['gearscore'])) - $data['gearscore'] += $gemScores->json[$itemId]['gearscore'] * $mult; - } - - if ($permEnch) // fuck this shit .. we are guestimating this! - { - // enchantId => multiple spells => multiple items with varying itemlevels, quality, whatevs - // cant reasonably get to the castItem from enchantId and slot - - $profSpec = DB::Aowow()->selectCol('SELECT id AS ARRAY_KEY, skillLevel AS "1", skillLine AS "0" FROM ?_itemenchantment WHERE id IN (?a)', $permEnch); - foreach ($permEnch as $eId) - { - if ($x = Util::getEnchantmentScore(0, 0, !!$profSpec[$eId][1], $eId)) - $data['gearscore'] += $x; - else if ($profSpec[$eId][0] != 776) // not runeforging - $data['gearscore'] += 17; // assume high quality enchantment for unknown cases - } - } - - $data['lastupdated'] = time(); - - CLI::write(' ..basic info'); - - - /***************/ - /* hunter pets */ - /***************/ - - if ($cl == CLASS_HUNTER) - { - DB::Aowow()->query('DELETE FROM ?_profiler_pets WHERE owner = ?d', $profileId); - $pets = DB::Characters($realmId)->select('SELECT id AS ARRAY_KEY, id, entry, modelId, name FROM character_pet WHERE owner = ?d', $charGuid); - foreach ($pets as $petGuid => $petData) - { - $morePet = DB::Aowow()->selectRow('SELECT p.`type`, c.family FROM ?_pet p JOIN ?_creature c ON c.family = p.id WHERE c.id = ?d', $petData['entry']); - $petSpells = DB::Characters($realmId)->selectCol('SELECT spell FROM pet_spell WHERE guid = ?d', $petGuid); - - $_ = DB::Aowow()->selectCol('SELECT spell AS ARRAY_KEY, MAX(IF(spell IN (?a), `rank`, 0)) FROM ?_talents WHERE class = 0 AND petTypeMask = ?d GROUP BY id ORDER BY row, col ASC', $petSpells ?: [0], 1 << $morePet['type']); - $pet = array( - 'id' => $petGuid, - 'owner' => $profileId, - 'name' => $petData['name'], - 'family' => $morePet['family'], - 'npc' => $petData['entry'], - 'displayId' => $petData['modelId'], - 'talents' => implode('', $_) - ); - - DB::Aowow()->query('INSERT INTO ?_profiler_pets (?#) VALUES (?a)', array_keys($pet), array_values($pet)); - } - - CLI::write(' ..hunter pets'); - } - - - /*******************/ - /* completion data */ - /*******************/ - - DB::Aowow()->query('DELETE FROM ?_profiler_completion WHERE id = ?d', $profileId); - - // done quests - if ($quests = DB::Characters($realmId)->select('SELECT ?d AS id, ?d AS `type`, quest AS typeId FROM character_queststatus_rewarded WHERE guid = ?d', $profileId, Type::QUEST, $char['guid'])) - foreach (Util::createSqlBatchInsert($quests) as $q) - DB::Aowow()->query('INSERT INTO ?_profiler_completion (?#) VALUES '.$q, array_keys($quests[0])); - - CLI::write(' ..quests'); - - - // known skills (professions only) - $skAllowed = DB::Aowow()->selectCol('SELECT id FROM ?_skillline WHERE typeCat IN (9, 11) AND (cuFlags & ?d) = 0', CUSTOM_EXCLUDE_FOR_LISTVIEW); - $skills = DB::Characters($realmId)->select('SELECT ?d AS id, ?d AS `type`, skill AS typeId, `value` AS cur, max FROM character_skills WHERE guid = ?d AND skill IN (?a)', $profileId, Type::SKILL, $char['guid'], $skAllowed); - - // manually apply racial profession bonuses - foreach ($skills as &$sk) - { - // Blood Elves - Arcane Affinity - if ($sk['typeId'] == 333 && $char['race'] == 10) - { - $sk['cur'] += 10; - $sk['max'] += 10; - } - // Draenei - Gemcutting - if ($sk['typeId'] == 755 && $char['race'] == 11) - { - $sk['cur'] += 5; - $sk['max'] += 5; - } - // Tauren - Cultivation - // Gnomes - Engineering Specialization - if (($sk['typeId'] == 182 && $char['race'] == 6) || - ($sk['typeId'] == 202 && $char['race'] == 7)) - { - $sk['cur'] += 15; - $sk['max'] += 15; - } - } - unset($sk); - - if ($skills) - { - // apply auto-learned trade skills - DB::Aowow()->query(' - INSERT INTO ?_profiler_completion - SELECT ?d, ?d, spellId, NULL, NULL - FROM dbc_skilllineability - WHERE skillLineId IN (?a) AND - acquireMethod = 1 AND - (reqRaceMask = 0 OR reqRaceMask & ?d) AND - (reqClassMask = 0 OR reqClassMask & ?d)', - $profileId, Type::SPELL, - array_column($skills, 'typeId'), - 1 << ($char['race'] - 1), - 1 << ($char['class'] - 1) - ); - - foreach (Util::createSqlBatchInsert($skills) as $sk) - DB::Aowow()->query('INSERT INTO ?_profiler_completion (?#) VALUES '.$sk, array_keys($skills[0])); - } - - CLI::write(' ..professions'); - - - // reputation - - // get base values for this race/class - $reputation = []; - $baseRep = DB::Aowow()->selectCol(' - SELECT id AS ARRAY_KEY, baseRepValue1 FROM aowow_factions WHERE baseRepValue1 && (baseRepRaceMask1 & ?d || (!baseRepRaceMask1 AND baseRepClassMask1)) && - ((baseRepClassMask1 & ?d) || !baseRepClassMask1) UNION - SELECT id AS ARRAY_KEY, baseRepValue2 FROM aowow_factions WHERE baseRepValue2 && (baseRepRaceMask2 & ?d || (!baseRepRaceMask2 AND baseRepClassMask2)) && - ((baseRepClassMask2 & ?d) || !baseRepClassMask2) UNION - SELECT id AS ARRAY_KEY, baseRepValue3 FROM aowow_factions WHERE baseRepValue3 && (baseRepRaceMask3 & ?d || (!baseRepRaceMask3 AND baseRepClassMask3)) && - ((baseRepClassMask3 & ?d) || !baseRepClassMask3) UNION - SELECT id AS ARRAY_KEY, baseRepValue4 FROM aowow_factions WHERE baseRepValue4 && (baseRepRaceMask4 & ?d || (!baseRepRaceMask4 AND baseRepClassMask4)) && - ((baseRepClassMask4 & ?d) || !baseRepClassMask4) - ', $ra, $cl, $ra, $cl, $ra, $cl, $ra, $cl); - - if ($reputation = DB::Characters($realmId)->select('SELECT ?d AS id, ?d AS `type`, faction AS typeId, standing AS cur FROM character_reputation WHERE guid = ?d AND (flags & 0x4) = 0', $profileId, Type::FACTION, $char['guid'])) - { - // merge back base values for encountered factions - foreach ($reputation as &$set) - { - if (empty($baseRep[$set['typeId']])) - continue; - - $set['cur'] += $baseRep[$set['typeId']]; - unset($baseRep[$set['typeId']]); - } - } - - // insert base values for not yet encountered factions - foreach ($baseRep as $id => $val) - $reputation[] = array( - 'id' => $profileId, - 'type' => Type::FACTION, - 'typeId' => $id, - 'cur' => $val - ); - - foreach (Util::createSqlBatchInsert($reputation) as $rep) - DB::Aowow()->query('INSERT INTO ?_profiler_completion (?#) VALUES '.$rep, array_keys($reputation[0])); - - CLI::write(' ..reputation'); - - - // known titles - $tBlocks = explode(' ', $char['knownTitles']); - $indizes = []; - for ($i = 0; $i < 6; $i++) - for ($j = 0; $j < 32; $j++) - if ($tBlocks[$i] & (1 << $j)) - $indizes[] = $j + ($i * 32); - - if ($indizes) - DB::Aowow()->query('INSERT INTO ?_profiler_completion SELECT ?d, ?d, id, NULL, NULL FROM ?_titles WHERE bitIdx IN (?a)', $profileId, Type::TITLE, $indizes); - - CLI::write(' ..titles'); - - - // achievements - if ($achievements = DB::Characters($realmId)->select('SELECT ?d AS id, ?d AS `type`, achievement AS typeId, date AS cur FROM character_achievement WHERE guid = ?d', $profileId, Type::ACHIEVEMENT, $char['guid'])) - { - foreach (Util::createSqlBatchInsert($achievements) as $a) - DB::Aowow()->query('INSERT INTO ?_profiler_completion (?#) VALUES '.$a, array_keys($achievements[0])); - - $data['achievementpoints'] = DB::Aowow()->selectCell('SELECT SUM(points) FROM ?_achievement WHERE id IN (?a)', array_column($achievements, 'typeId')); - } - - CLI::write(' ..achievements'); - - - // raid progression - if ($progress = DB::Characters($realmId)->select('SELECT ?d AS id, ?d AS `type`, criteria AS typeId, date AS cur, counter AS `max` FROM character_achievement_progress WHERE guid = ?d AND criteria IN (?a)', $profileId, Type::ACHIEVEMENT, $char['guid'], self::$raidProgression)) - { - array_walk($progress, function (&$val) { $val['typeId'] = array_search($val['typeId'], self::$raidProgression); }); - foreach (Util::createSqlBatchInsert($progress) as $p) - DB::Aowow()->query('INSERT INTO ?_profiler_completion (?#) VALUES '.$p, array_keys($progress[0])); - } - - CLI::write(' ..raid progression'); - - - // known spells - if ($spells = DB::Characters($realmId)->select('SELECT ?d AS id, ?d AS `type`, spell AS typeId FROM character_spell WHERE guid = ?d AND disabled = 0', $profileId, Type::SPELL, $char['guid'])) - foreach (Util::createSqlBatchInsert($spells) as $s) - DB::Aowow()->query('INSERT INTO ?_profiler_completion (?#) VALUES '.$s, array_keys($spells[0])); - - CLI::write(' ..known spells (vanity pets & mounts)'); - - - /****************/ - /* related data */ - /****************/ - - // guilds - if ($guild = DB::Characters($realmId)->selectRow('SELECT g.name AS name, g.guildid AS id, gm.rank FROM guild_member gm JOIN guild g ON g.guildid = gm.guildid WHERE gm.guid = ?d', $char['guid'])) - { - $guildId = 0; - if (!($guildId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_guild WHERE realm = ?d AND realmGUID = ?d', $realmId, $guild['id']))) - { - $gData = array( // only most basic data - 'realm' => $realmId, - 'realmGUID' => $guild['id'], - 'name' => $guild['name'], - 'nameUrl' => self::urlize($guild['name']), - 'cuFlags' => PROFILER_CU_NEEDS_RESYNC - ); - - $guildId = DB::Aowow()->query('INSERT IGNORE INTO ?_profiler_guild (?#) VALUES (?a)', array_keys($gData), array_values($gData)); - } - - $data['guild'] = $guildId; - $data['guildRank'] = $guild['rank']; - } - - - // arena teams - $teams = DB::Characters($realmId)->select('SELECT at.arenaTeamId AS ARRAY_KEY, at.name, at.type, IF(at.captainGuid = atm.guid, 1, 0) AS captain, atm.* FROM arena_team at JOIN arena_team_member atm ON atm.arenaTeamId = at.arenaTeamId WHERE atm.guid = ?d', $char['guid']); - foreach ($teams as $rGuid => $t) - { - $teamId = 0; - if (!($teamId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_arena_team WHERE realm = ?d AND realmGUID = ?d', $realmId, $rGuid))) - { - $team = array( // only most basic data - 'realm' => $realmId, - 'realmGUID' => $rGuid, - 'name' => $t['name'], - 'nameUrl' => self::urlize($t['name']), - 'type' => $t['type'], - 'cuFlags' => PROFILER_CU_NEEDS_RESYNC - ); - - $teamId = DB::Aowow()->query('INSERT IGNORE INTO ?_profiler_arena_team (?#) VALUES (?a)', array_keys($team), array_values($team)); - } - - $member = array( - 'arenaTeamId' => $teamId, - 'profileId' => $profileId, - 'captain' => $t['captain'], - 'weekGames' => $t['weekGames'], - 'weekWins' => $t['weekWins'], - 'seasonGames' => $t['seasonGames'], - 'seasonWins' => $t['seasonWins'], - 'personalRating' => $t['personalRating'] - ); - - DB::Aowow()->query('INSERT INTO ?_profiler_arena_team_member (?#) VALUES (?a) ON DUPLICATE KEY UPDATE ?a', array_keys($member), array_values($member), array_slice($member, 2)); - } - - CLI::write(' ..associated arena teams'); - - /*********************/ - /* mark char as done */ - /*********************/ - - if (DB::Aowow()->query('UPDATE ?_profiler_profiles SET ?a WHERE realm = ?d AND realmGUID = ?d', $data, $realmId, $charGuid) !== null) - DB::Aowow()->query('UPDATE ?_profiler_profiles SET cuFlags = cuFlags & ?d WHERE id = ?d', ~PROFILER_CU_NEEDS_RESYNC, $profileId); - - return true; - } - - public static function getGuildFromRealm($realmId, $guildGuid) - { - $guild = DB::Characters($realmId)->selectRow('SELECT guildId, name, createDate, info, backgroundColor, emblemStyle, emblemColor, borderStyle, borderColor FROM guild WHERE guildId = ?d', $guildGuid); - if (!$guild) - return false; - - // reminder: this query should not fail: a placeholder entry is created as soon as a team listview is created or team detail page is called - $guildId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_guild WHERE realm = ?d AND realmGUID = ?d', $realmId, $guild['guildId']); - - CLI::write('fetching guild #'.$guildGuid.' from realm #'.$realmId); - CLI::write('writing...'); - - - /**************/ - /* Guild Data */ - /**************/ - - unset($guild['guildId']); - $guild['nameUrl'] = self::urlize($guild['name']); - - DB::Aowow()->query('UPDATE ?_profiler_guild SET ?a WHERE realm = ?d AND realmGUID = ?d', $guild, $realmId, $guildGuid); - - // ranks - DB::Aowow()->query('DELETE FROM ?_profiler_guild_rank WHERE guildId = ?d', $guildId); - if ($ranks = DB::Characters($realmId)->select('SELECT ?d AS guildId, rid AS `rank`, rname AS name FROM guild_rank WHERE guildid = ?d', $guildId, $guildGuid)) - foreach (Util::createSqlBatchInsert($ranks) as $r) - DB::Aowow()->query('INSERT INTO ?_profiler_guild_rank (?#) VALUES '.$r, array_keys(reset($ranks))); - - CLI::write(' ..guild data'); - - - /***************/ - /* Member Data */ - /***************/ - - $conditions = array( - ['g.guildid', $guildGuid], - ['deleteInfos_Account', null], - ['level', MAX_LEVEL, '<='], // prevents JS errors - [['extra_flags', self::CHAR_GMFLAGS, '&'], 0] // not a staff char - ); - - // this here should all happen within ProfileList - $members = new RemoteProfileList($conditions, ['sv' => $realmId]); - if (!$members->error) - $members->initializeLocalEntries(); - else - return false; - - CLI::write(' ..guild members'); - - - /*********************/ - /* mark guild as done */ - /*********************/ - - DB::Aowow()->query('UPDATE ?_profiler_guild SET cuFlags = cuFlags & ?d WHERE id = ?d', ~PROFILER_CU_NEEDS_RESYNC, $guildId); - - return true; - } - - public static function getArenaTeamFromRealm($realmId, $teamGuid) - { - $team = DB::Characters($realmId)->selectRow('SELECT arenaTeamId, name, type, captainGuid, rating, seasonGames, seasonWins, weekGames, weekWins, `rank`, backgroundColor, emblemStyle, emblemColor, borderStyle, borderColor FROM arena_team WHERE arenaTeamId = ?d', $teamGuid); - if (!$team) - return false; - - // reminder: this query should not fail: a placeholder entry is created as soon as a team listview is created or team detail page is called - $teamId = DB::Aowow()->selectCell('SELECT id FROM ?_profiler_arena_team WHERE realm = ?d AND realmGUID = ?d', $realmId, $team['arenaTeamId']); - - CLI::write('fetching arena team #'.$teamGuid.' from realm #'.$realmId); - CLI::write('writing...'); - - - /*************/ - /* Team Data */ - /*************/ - - $captain = $team['captainGuid']; - unset($team['captainGuid']); - unset($team['arenaTeamId']); - $team['nameUrl'] = self::urlize($team['name']); - - DB::Aowow()->query('UPDATE ?_profiler_arena_team SET ?a WHERE realm = ?d AND realmGUID = ?d', $team, $realmId, $teamGuid); - - CLI::write(' ..team data'); - - - /***************/ - /* Member Data */ - /***************/ - - $members = DB::Characters($realmId)->select(' - SELECT - atm.guid AS ARRAY_KEY, atm.arenaTeamId, atm.weekGames, atm.weekWins, atm.seasonGames, atm.seasonWins, atm.personalrating - FROM - arena_team_member atm - JOIN - characters c ON c.guid = atm.guid AND - c.deleteInfos_Account IS NULL AND - c.level <= ?d AND - (c.extra_flags & ?d) = 0 - WHERE - arenaTeamId = ?d', - MAX_LEVEL, - self::CHAR_GMFLAGS, - $teamGuid - ); - - $conditions = array( - ['c.guid', array_keys($members)], - ['deleteInfos_Account', null], - ['level', MAX_LEVEL, '<='], // prevents JS errors - [['extra_flags', self::CHAR_GMFLAGS, '&'], 0] // not a staff char - ); - - $mProfiles = new RemoteProfileList($conditions, ['sv' => $realmId]); - if (!$mProfiles->error) - { - $mProfiles->initializeLocalEntries(); - foreach ($mProfiles->iterate() as $__) - { - - $mGuid = $mProfiles->getField('guid'); - - $members[$mGuid]['arenaTeamId'] = $teamId; - $members[$mGuid]['captain'] = (int)($mGuid == $captain); - $members[$mGuid]['profileId'] = $mProfiles->getField('id'); - } - - DB::Aowow()->query('DELETE FROM ?_profiler_arena_team_member WHERE arenaTeamId = ?d', $teamId); - - foreach (Util::createSqlBatchInsert($members) as $m) - DB::Aowow()->query('INSERT INTO ?_profiler_arena_team_member (?#) VALUES '.$m, array_keys(reset($members))); - - } - else - return false; - - CLI::write(' ..team members'); - - /*********************/ - /* mark team as done */ - /*********************/ - - DB::Aowow()->query('UPDATE ?_profiler_arena_team SET cuFlags = cuFlags & ?d WHERE id = ?d', ~PROFILER_CU_NEEDS_RESYNC, $teamId); - - return true; - } -} - -?> diff --git a/includes/setup/cli.class.php b/includes/setup/cli.class.php new file mode 100644 index 00000000..ef16296f --- /dev/null +++ b/includes/setup/cli.class.php @@ -0,0 +1,350 @@ + $row) + { + if (!is_array($out[0])) + { + unset($out[$i]); + continue; + } + + $nCols = max($nCols, count($row)); + + for ($j = 0; $j < $nCols; $j++) + $pads[$j] = max($pads[$j] ?? 0, mb_strlen(self::purgeEscapes($row[$j] ?? ''))); + } + + foreach ($out as $i => $row) + { + for ($j = 0; $j < $nCols; $j++) + { + if (!isset($row[$j])) + break; + + $len = ($pads[$j] - mb_strlen(self::purgeEscapes($row[$j]))); + for ($k = 0; $k < $len; $k++) // can't use str_pad(). it counts invisible chars. + $row[$j] .= ' '; + } + + if ($i || $headless) + self::write(' '.implode(' ' . self::tblDelim(' ') . ' ', $row), self::LOG_NONE, $timestamp); + else + self::write(self::tblHead(' '.implode(' ', $row)), self::LOG_NONE, $timestamp); + } + + if (!$headless) + self::write(self::tblHead(str_pad('', array_sum($pads) + count($pads) * 3 - 2)), self::LOG_NONE, $timestamp); + + self::write(); + } + + + /***********/ + /* logging */ + /***********/ + + public static function initLogFile(string $file = '') : void + { + if (!$file) + return; + + $file = self::nicePath($file); + if (!file_exists($file)) + self::$logHandle = fopen($file, 'w'); + else + { + $logFileParts = pathinfo($file); + + $i = 1; + while (file_exists($logFileParts['dirname'].'/'.$logFileParts['filename'].$i.(isset($logFileParts['extension']) ? '.'.$logFileParts['extension'] : ''))) + $i++; + + $file = $logFileParts['dirname'].'/'.$logFileParts['filename'].$i.(isset($logFileParts['extension']) ? '.'.$logFileParts['extension'] : ''); + self::$logHandle = fopen($file, 'w'); + } + } + + private static function tblHead(string $str) : string + { + return CLI_HAS_E ? "\e[1;48;5;236m".$str."\e[0m" : $str; + } + + private static function tblDelim(string $str) : string + { + return CLI_HAS_E ? "\e[48;5;236m".$str."\e[0m" : $str; + } + + public static function grey(string $str) : string + { + return CLI_HAS_E ? "\e[90m".$str."\e[0m" : $str; + } + + public static function red(string $str) : string + { + return CLI_HAS_E ? "\e[31m".$str."\e[0m" : $str; + } + + public static function green(string $str) : string + { + return CLI_HAS_E ? "\e[32m".$str."\e[0m" : $str; + } + + public static function yellow(string $str) : string + { + return CLI_HAS_E ? "\e[33m".$str."\e[0m" : $str; + } + + public static function blue(string $str) : string + { + return CLI_HAS_E ? "\e[36m".$str."\e[0m" : $str; + } + + public static function bold(string $str) : string + { + return CLI_HAS_E ? "\e[1m".$str."\e[0m" : $str; + } + + public static function write(string $txt = '', int $lvl = self::LOG_BLANK, bool $timestamp = true, bool $tmpRow = false) : void + { + $msg = ''; + if ($txt) + { + if ($timestamp) + $msg = str_pad(date('H:i:s'), 10); + + $msg .= match ($lvl) + { + self::LOG_ERROR => '['.self::red('ERR').'] ', // red critical error + self::LOG_WARN => '['.self::yellow('WARN').'] ', // yellow notice + self::LOG_OK => '['.self::green('OK').'] ', // green success + self::LOG_INFO => '['.self::blue('INFO').'] ', // blue info + default => ' ' + }; + + $msg .= $txt; + } + + // https://shiroyasha.svbtle.com/escape-sequences-a-quick-guide-1#movement_1 + $msg = (self::$overwriteLast && CLI_HAS_E ? "\e[1G\e[0K" : "\n") . $msg; + self::$overwriteLast = $tmpRow; + + fwrite($lvl == self::LOG_ERROR ? STDERR : STDOUT, $msg); + + if (self::$logHandle) // remove control sequences from log + fwrite(self::$logHandle, self::purgeEscapes($msg)); + + flush(); + } + + private static function purgeEscapes(string $msg) : string + { + return preg_replace(["/\e\[[\d;]+[mK]/", "/\e\[\d+G/"], ['', "\n"], $msg); + } + + public static function nicePath(string $fileOrPath, string ...$pathParts) : string + { + $path = ''; + + if ($pathParts) + { + foreach ($pathParts as &$pp) + $pp = trim($pp); + + $path .= implode(DIRECTORY_SEPARATOR, $pathParts); + } + + $path .= ($path ? DIRECTORY_SEPARATOR : '').trim($fileOrPath); + + // remove double quotes (from erroneous user input), single quotes are + // valid chars for filenames and removing those mutilates several wow icons + $path = str_replace('"', '', $path); + + if (!$path) // empty strings given. (faulty dbc data?) + return ''; + + if (DIRECTORY_SEPARATOR == '/') // *nix + { + $path = str_replace('\\', '/', $path); + $path = preg_replace('/\/+/i', '/', $path); + } + else if (DIRECTORY_SEPARATOR == '\\') // win + { + $path = str_replace('/', '\\', $path); + $path = preg_replace('/\\\\+/i', '\\', $path); + } + else + self::write('Dafuq! Your directory separator is "'.DIRECTORY_SEPARATOR.'". Please report this!', self::LOG_ERROR); + + // resolve *nix home shorthand + if (!OS_WIN) + { + if (preg_match('/^~(\w+)\/.*/i', $path, $m)) + $path = '/home/'.substr($path, 1); + else if (substr($path, 0, 2) == '~/') + $path = getenv('HOME').substr($path, 1); + else if ($path[0] == DIRECTORY_SEPARATOR && substr($path, 0, 6) != '/home/') + $path = substr($path, 1); + } + + return $path; + } + + + /**************/ + /* read input */ + /**************/ + + /* + since the CLI on WIN ist not interactive, the following things have to be considered + you do not receive keystrokes but whole strings upon pressing (wich also appends a \r) + as such and probably other control chars can not be registered + this also means, you can't hide input at all, least process it + */ + + public static function read(array $fields, ?array &$userInput = []) : bool + { + // first time set + if (self::$hasReadline === null) + self::$hasReadline = function_exists('readline_callback_handler_install'); + + // prevent default output if able + if (self::$hasReadline) + readline_callback_handler_install('', function() { }); + + if (!STDIN) + return false; + + stream_set_blocking(STDIN, false); + + // pad default values onto $fields + array_walk($fields, function(&$val, $_, $pad) { $val += $pad; }, ['', false, false, '']); + + foreach ($fields as $name => [$desc, $isHidden, $singleChar, $validPattern]) + { + $charBuff = ''; + + if ($desc) + fwrite(STDOUT, "\n".$desc.": "); + + while (true) { + if (feof(STDIN)) + return false; + + $r = [STDIN]; + $w = $e = null; + $n = stream_select($r, $w, $e, 200000); + + if (!$n || !in_array(STDIN, $r)) + continue; + + // stream_get_contents is always blocking under WIN - fgets should work similary as php always receives a terminated line of text + $chars = str_split(OS_WIN ? fgets(STDIN) : stream_get_contents(STDIN)); + + // $chars can be empty if used non-interactive + if (!$chars) + return false; + + $ordinals = array_map('ord', $chars); + + if ($ordinals[0] == self::CHR_ESC) + { + if (count($ordinals) == 1) + { + fwrite(STDOUT, chr(self::CHR_BELL)); + return false; + } + else + continue; + } + + foreach ($chars as $idx => $char) + { + $keyId = $ordinals[$idx]; + + // skip char if horizontal tab or \r if followed by \n + if ($keyId == self::CHR_TAB || ($keyId == self::CHR_CR && ($ordinals[$idx + 1] ?? '') == self::CHR_LF)) + continue; + + if ($keyId == self::CHR_BACKSPACE) + { + if (!$charBuff) + continue 2; + + $charBuff = mb_substr($charBuff, 0, -1); + if (!$isHidden && self::$hasReadline) + fwrite(STDOUT, chr(self::CHR_BACK)." ".chr(self::CHR_BACK)); + } + // standalone \n or \r + else if ($keyId == self::CHR_LF || $keyId == self::CHR_CR) + { + $userInput[$name] = $charBuff; + break 2; + } + else if (!$validPattern || preg_match($validPattern, $char)) + { + $charBuff .= $char; + if (!$isHidden && self::$hasReadline) + fwrite(STDOUT, $char); + + if ($singleChar && self::$hasReadline) + { + $userInput[$name] = $charBuff; + break 2; + } + } + } + } + } + + fwrite(STDOUT, chr(self::CHR_BELL)); + + foreach ($userInput as $ui) + if (strlen($ui)) + return true; + + $userInput = null; + return true; + } +} + +?> diff --git a/includes/setup/datatypes/primitives.php b/includes/setup/datatypes/primitives.php new file mode 100644 index 00000000..2bf355bc --- /dev/null +++ b/includes/setup/datatypes/primitives.php @@ -0,0 +1,104 @@ +data = $data; + else + $this->data = $data->read(static::SIZE); + } + + public function pack() : string + { + return $this->data; + } + + public function unpack() : mixed + { + return current(unpack(static::PACK_FMT, $this->data)); + } + + public function __debugInfo() : array + { + return [$this->unpack()]; + } +} + +class Char extends Primitive +{ + public const /* int */ SIZE = 1; + public const /* string */ PACK_FMT = 'C'; + + public function unpack() : string + { + return chr(parent::unpack()); + } +} + +class Boolean extends Primitive +{ + public const /* int */ SIZE = 1; + public const /* string */ PACK_FMT = 'C'; + + public function unpack() : string + { + return !!(parent::unpack()); + } +} + +class UInt8 extends Primitive +{ + public const /* int */ SIZE = 1; + public const /* string */ PACK_FMT = 'C'; +} + +class Int8 extends Primitive +{ + public const /* int */ SIZE = 1; + public const /* string */ PACK_FMT = 'c'; +} + +class UInt16 extends Primitive +{ + public const /* int */ SIZE = 2; + public const /* string */ PACK_FMT = 'v'; +} + +class Int16 extends Primitive +{ + public const /* int */ SIZE = 2; + public const /* string */ PACK_FMT = 's'; +} + +class UInt32 extends Primitive +{ + public const /* int */ SIZE = 4; + public const /* string */ PACK_FMT = 'V'; +} + +class Int32 extends Primitive +{ + public const /* int */ SIZE = 4; + public const /* string */ PACK_FMT = 'l'; +} + +class Double extends Primitive +{ + public const /* int */ SIZE = 4; + public const /* string */ PACK_FMT = 'f'; +} + +?> diff --git a/includes/setup/files/binaryfile.class.php b/includes/setup/files/binaryfile.class.php new file mode 100644 index 00000000..c34dba3d --- /dev/null +++ b/includes/setup/files/binaryfile.class.php @@ -0,0 +1,202 @@ +error = 'file '.$file.' not found'; + return; + } + + if (!$this->handle = fopen($file, 'rb')) + { + $this->error = 'failed to open file '.$file; + return; + } + + $this->filesize = filesize($file); + + if ($inRAM) + $this->data = file_get_contents($file); + } + + public function __destruct() + { + $this->close(); + } + + + /**********************/ + /* direct file access */ + /**********************/ + + public function read(int $bytes) : ?string + { + if ($this->error || !is_resource($this->handle) || $bytes < 0) + return null; + + $start = $this->pos; + $this->pos += $bytes; + + if ($this->inRAM) + return substr($this->data, $start, $bytes); + else + return fread($this->handle, $bytes); + } + + public function readOffset(int $bytes, int $offset, bool $jumpBack = true) : ?string + { + if ($this->error || !is_resource($this->handle)) + return null; + + if ($jumpBack) + $curPos = $this->inRAM ? $this->pos : ftell($this->handle); + + $this->seek($offset); + + $str = $this->read($bytes); + + if ($jumpBack) + $this->seek($curPos); + + return $str; + } + + public function seek(int $pos) : int + { + if (!is_resource($this->handle)) + return 0; + + if ($pos < 0) + $pos = 0; + if ($pos > $this->filesize) + $pos = $this->filesize; + + $this->pos = $pos; + + if (!$this->inRAM) + fseek($this->handle, $pos, SEEK_SET); + + return $pos; + } + + public function ffwd(int $bytes) : int + { + if (!is_resource($this->handle)) + return 0; + + $curPos = $this->inRAM ? $this->pos : ftell($this->handle); + + if ($curPos + $bytes < 0) + $bytes -= $curPos; + if ($curPos + $bytes > $this->filesize) + $bytes -= $this->filesize; + + $this->pos += $bytes; + + if ($this->inRAM) + return $this->pos; + + fseek($this->handle, $bytes, SEEK_CUR); + return ftell($this->handle); + } + + public function close() : void + { + if (is_resource($this->handle)) + fclose($this->handle); + } + + public function tell() : int + { + if (!is_resource($this->handle)) + return 0; + + return $this->inRAM ? $this->pos : ftell($this->handle); + } + + /******************/ + /* read Primitive */ + /******************/ + + public function readInt8() : ?Int8 + { + if (!is_resource($this->handle)) + return null; + return new Int8($this); + } + + public function readInt16() : ?Int16 + { + if (!is_resource($this->handle)) + return null; + return new Int16($this); + } + + public function readInt32() : ?Int32 + { + if (!is_resource($this->handle)) + return null; + return new Int32($this); + } + + public function readUInt8() : ?UInt8 + { + if (!is_resource($this->handle)) + return null; + return new UInt8($this); + } + + public function readUInt16() : ?UInt16 + { + if (!is_resource($this->handle)) + return null; + return new UInt16($this); + } + + public function readUInt32() : ?UInt32 + { + if (!is_resource($this->handle)) + return null; + return new UInt32($this); + } + + public function readFloat() : ?Double + { + if (!is_resource($this->handle)) + return null; + return new Double($this); + } + + public function readChar() : ?Char + { + if (!is_resource($this->handle)) + return null; + return new Char($this); + } + + public function readBool() : ?Boolean + { + if (!is_resource($this->handle)) + return null; + return new Boolean($this); + } +} + +?> diff --git a/includes/setup/files/dbcfile.class.php b/includes/setup/files/dbcfile.class.php new file mode 100644 index 00000000..25d9d170 --- /dev/null +++ b/includes/setup/files/dbcfile.class.php @@ -0,0 +1,83 @@ +filesize < strlen(self::MAGIC) + self::HEADER_SIZE) + { + $this->error = 'file '.$file.' too small for a dbc'; + $this->close(); + return; + } + + if ($this->read(4) != self::MAGIC) + { + $this->error = 'file '.$file.' has incorrect magic bytes'; + $this->close(); + return; + } + + [, $this->nRows, $this->nCols, $this->recordSize, $this->stringSize] = unpack(UInt32::PACK_FMT.'4', $this->read(self::HEADER_SIZE)); + $this->stringOffset = strlen(self::MAGIC) + self::HEADER_SIZE + $this->recordSize * $this->nRows; + + if ($this->stringOffset + $this->stringSize != $this->filesize) + { + $this->error = 'file '.$file.' has unexpected size - expected: '.($this->stringOffset + $this->stringSize).' has: '.$this->filesize; + $this->close(); + return; + } + } + + public function readRecord(string $colFmt = "V*") : array + { + return unpack($colFmt, $this->read($this->recordSize)); + } + + public function readString() : ?string + { + $x = $this->readUInt32(); + if (is_null($x)) + return null; + + return $this->getStringFromBlock($x->unpack()); + } + + public function getStringFromBlock(int $offset) : ?string + { + $curPos = $this->tell(); + + $this->seek($this->stringOffset + $offset); + + // apparently it is more efficient to read more than one byte at once..? + $str = ''; + while (($pos = strpos($str, "\0")) === false) + $str .= $this->read(255); + + $str = substr($str, 0, $pos); + + $this->seek($curPos); + + return $pos ? $str : null; + } +} + +?> diff --git a/includes/setup/timer.class.php b/includes/setup/timer.class.php new file mode 100644 index 00000000..7c36620d --- /dev/null +++ b/includes/setup/timer.class.php @@ -0,0 +1,39 @@ +intv = $intervall / 1000; // in msec + $this->t_cur = microtime(true); + } + + public function update() : bool + { + $this->t_new = microtime(true); + if ($this->t_new > $this->t_cur + $this->intv) + { + $this->t_cur = $this->t_cur + $this->intv; + return true; + } + + return false; + } + + public function reset() : void + { + $this->t_cur = microtime(true) - $this->intv; + } +} + +?> diff --git a/includes/shared.php b/includes/shared.php deleted file mode 100644 index 48b2adbf..00000000 --- a/includes/shared.php +++ /dev/null @@ -1,26 +0,0 @@ -'.$r."
was not found. Please check if it should exist, using \"php -m\"\n\n"; - -if (version_compare(PHP_VERSION, '7.4.0') < 0) - $error .= 'PHP Version 7.4 or higher required! Your version is '.PHP_VERSION.".\nCore functions are unavailable!\n"; - -if ($error) -{ - echo CLI ? strip_tags($error) : $error; - die(); -} - - -// include all necessities, set up basics -require_once 'includes/kernel.php'; - -?> diff --git a/includes/smartAI.class.php b/includes/smartAI.class.php deleted file mode 100644 index 0929b740..00000000 --- a/includes/smartAI.class.php +++ /dev/null @@ -1,1622 +0,0 @@ - [1 => $npcId], - SAI_ACTION_MOUNT_TO_ENTRY_OR_MODEL => [1 => $npcId] - ); - - if ($npcGuids = DB::Aowow()->selectCol('SELECT guid FROM ?_spawns WHERE `type` = ?d AND `typeId` = ?d', Type::NPC, $npcId)) - if ($groups = DB::World()->selectCol('SELECT `groupId` FROM spawn_group WHERE `spawnType` = 0 AND `spawnId` IN (?a)', $npcGuids)) - foreach ($groups as $g) - $lookup[SAI_ACTION_SPAWN_SPAWNGROUP][1] = $g; - - $result = self::getActionOwner($lookup, $typeFilter); - - // can skip lookups for SAI_ACTION_SUMMON_CREATURE_GROUP as creature_summon_groups already contains summoner info - if ($sgs = DB::World()->select('SELECT `summonerType` AS "0", `summonerId` AS "1" FROM creature_summon_groups WHERE `entry` = ?d', $npcId)) - foreach ($sgs as [$type, $typeId]) - $result[$type][] = $typeId; - - return $result; - } - - public static function getOwnerOfObjectSummon(int $objectId, int $typeFilter = 0) : array - { - if ($objectId <= 0) - return []; - - $lookup = array( - SAI_ACTION_SUMMON_GO => [1 => $objectId] - ); - - if ($objGuids = DB::Aowow()->selectCol('SELECT guid FROM ?_spawns WHERE `type` = ?d AND `typeId` = ?d', Type::OBJECT, $objectId)) - if ($groups = DB::World()->selectCol('SELECT `groupId` FROM spawn_group WHERE `spawnType` = 1 AND `spawnId` IN (?a)', $objGuids)) - foreach ($groups as $g) - $lookup[SAI_ACTION_SPAWN_SPAWNGROUP][1] = $g; - - return self::getActionOwner($lookup, $typeFilter); - } - - public static function getOwnerOfSpellCast(int $spellId, int $typeFilter = 0) : array - { - if ($spellId <= 0) - return []; - - $lookup = array( - SAI_ACTION_CAST => [1 => $spellId], - SAI_ACTION_ADD_AURA => [1 => $spellId], - SAI_ACTION_SELF_CAST => [1 => $spellId], - SAI_ACTION_CROSS_CAST => [1 => $spellId], - SAI_ACTION_INVOKER_CAST => [1 => $spellId] - ); - - return self::getActionOwner($lookup, $typeFilter); - } - - public static function getOwnerOfSoundPlayed(int $soundId, int $typeFilter = 0) : array - { - if ($soundId <= 0) - return []; - - $lookup = array( - SAI_ACTION_SOUND => [1 => $soundId] - ); - - return self::getActionOwner($lookup, $typeFilter); - } - - private static function getActionOwner(array $lookup, int $typeFilter = 0) : array - { - $qParts = []; - $result = []; - $genLimit = $talLimit = []; - switch ($typeFilter) - { - case Type::NPC: - $genLimit = [SAI_SRC_TYPE_CREATURE, SAI_SRC_TYPE_ACTIONLIST]; - $talLimit = [SAI_SRC_TYPE_CREATURE]; - break; - case Type::OBJECT: - $genLimit = [SAI_SRC_TYPE_OBJECT, SAI_SRC_TYPE_ACTIONLIST]; - $talLimit = [SAI_SRC_TYPE_OBJECT]; - break; - case Type::AREATRIGGER: - $genLimit = [SAI_SRC_TYPE_AREATRIGGER, SAI_SRC_TYPE_ACTIONLIST]; - $talLimit = [SAI_SRC_TYPE_AREATRIGGER]; - break; - } - - foreach ($lookup as $action => $params) - { - $aq = '(`action_type` = '.(int)$action.' AND ('; - $pq = []; - foreach ($params as $idx => $p) - $pq[] = '`action_param'.(int)$idx.'` = '.(int)$p; - - if ($pq) - $qParts[] = $aq.implode(' OR ', $pq).'))'; - } - - $smartS = DB::World()->select(sprintf('SELECT `source_type` AS "0", `entryOrGUID` AS "1" FROM smart_scripts WHERE (%s){ AND `source_type` IN (?a)}', $qParts ? implode(' OR ', $qParts) : '0'), $genLimit ?: DBSIMPLE_SKIP); - - // filter for TAL shenanigans - if ($smartTAL = array_filter($smartS, function ($x) {return $x[0] == SAI_SRC_TYPE_ACTIONLIST;})) - { - $smartS = array_diff_key($smartS, $smartTAL); - - $q = []; - foreach ($smartTAL as [, $eog]) - { - // SAI_ACTION_CALL_TIMED_ACTIONLIST - $q[] = '`action_type` = '.SAI_ACTION_CALL_TIMED_ACTIONLIST.' AND `action_param1` = '.$eog; - - // SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST - $q[] = '`action_type` = '.SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST.' AND (`action_param1` = '.$eog.' OR `action_param2` = '.$eog.' OR `action_param3` = '.$eog.' OR `action_param4` = '.$eog.' OR `action_param5` = '.$eog.')'; - - // SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST - $q[] = '`action_type` = '.SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST.' AND `action_param1` <= '.$eog.' AND `action_param2` >= '.$eog; - } - - if ($_ = DB::World()->select(sprintf('SELECT `source_type` AS "0", `entryOrGUID` AS "1" FROM smart_scripts WHERE ((%s)){ AND `source_type` IN (?a)}', $q ? implode(') OR (', $q) : '0'), $talLimit ?: DBSIMPLE_SKIP)) - $smartS = array_merge($smartS, $_); - } - - // filter guids for entries - if ($smartG = array_filter($smartS, function ($x) {return $x[1] < 0;})) - { - $smartS = array_diff_key($smartS, $smartG); - - $q = []; - foreach ($smartG as [$st, $eog]) - { - if ($st == SAI_SRC_TYPE_CREATURE) - $q[] = '`type` = '.Type::NPC.' AND `guid` = '.-$eog; - else if ($st == SAI_SRC_TYPE_OBJECT) - $q[] = '`type` = '.Type::OBJECT.' AND `guid` = '.-$eog; - } - - if ($q) - $result = DB::Aowow()->selectCol(sprintf('SELECT `type` AS ARRAY_KEY, `typeId` FROM ?_spawns WHERE (%s)', implode(') OR (', $q))); - } - - foreach ($smartS as [$st, $eog]) - { - if ($st == SAI_SRC_TYPE_CREATURE) - $result[Type::NPC][] = $eog; - else if ($st == SAI_SRC_TYPE_OBJECT) - $result[Type::OBJECT][] = $eog; - else if ($st == SAI_SRC_TYPE_AREATRIGGER) - $result[Type::AREATRIGGER][] = $eog; - } - - return $result; - } - - - /********************/ - /* Lookups by owner */ - /********************/ - - public static function getNPCSummonsForOwner(int $entry, int $srcType = SAI_SRC_TYPE_CREATURE) : array - { - // action => paramIdx with npcIds/spawnGoupIds - $lookup = array( - SAI_ACTION_SUMMON_CREATURE => [1], - SAI_ACTION_MOUNT_TO_ENTRY_OR_MODEL => [1], - SAI_ACTION_SPAWN_SPAWNGROUP => [1] - ); - - $result = self::getOwnerAction($srcType, $entry, $lookup); - - // can skip lookups for SAI_ACTION_SUMMON_CREATURE_GROUP as creature_summon_groups already contains summoner info - if ($srcType == SAI_SRC_TYPE_CREATURE || $srcType == SAI_SRC_TYPE_OBJECT) - { - $st = $srcType == SAI_SRC_TYPE_CREATURE ? 0 : 1;// 0:SUMMONER_TYPE_CREATURE; 1:SUMMONER_TYPE_GAMEOBJECT - if ($csg = DB::World()->selectCol('SELECT `entry` FROM creature_summon_groups WHERE `summonerType` = ?d AND `summonerId` = ?d', $st, $entry)) - $result = array_merge($result, $csg); - } - - if (!empty($moreInfo[SAI_ACTION_SPAWN_SPAWNGROUP])) - { - $grp = $moreInfo[SAI_ACTION_SPAWN_SPAWNGROUP]; - if ($sgs = DB::World()->selectCol('SELECT `spawnId` FROM spawn_group WHERE `spawnType` = ?d AND `groupId` IN (?a)', 0 /*0:SUMMONER_TYPE_CREATURE*/, $grp)) - if ($ids = DB::Aowow()->selectCol('SELECT DISTINCT `typeId` FROM ?_spawns WHERE `type` = ?d AND `guid` IN (?a)', Type::NPC, $sgs)) - $result = array_merge($result, $ids); - } - - return $result; - } - - public static function getObjectSummonsForOwner(int $entry, int $srcType = SAI_SRC_TYPE_CREATURE) : array - { - // action => paramIdx with gobIds/spawnGoupIds - $lookup = array( - SAI_ACTION_SUMMON_GO => [1], - SAI_ACTION_SPAWN_SPAWNGROUP => [1] - ); - - $result = self::getOwnerAction($srcType, $entry, $lookup, $moreInfo); - - if (!empty($moreInfo[SAI_ACTION_SPAWN_SPAWNGROUP])) - { - $grp = $moreInfo[SAI_ACTION_SPAWN_SPAWNGROUP]; - if ($sgs = DB::World()->selectCol('SELECT `spawnId` FROM spawn_group WHERE `spawnType` = ?d AND `groupId` IN (?a)', 1 /*1:SUMMONER_TYPE_GAMEOBJECT*/, $grp)) - if ($ids = DB::Aowow()->selectCol('SELECT DISTINCT `typeId` FROM ?_spawns WHERE `type` = ?d AND `guid` IN (?a)', Type::OBJECT, $sgs)) - $result = array_merge($result, $ids); - } - - return $result; - } - - public static function getSpellCastsForOwner(int $entry, int $srcType = SAI_SRC_TYPE_CREATURE) : array - { - // action => paramIdx with spellIds - $lookup = array( - SAI_SRC_TYPE_CREATURE => [1], - SAI_ACTION_CAST => [1], - SAI_ACTION_ADD_AURA => [1], - SAI_ACTION_INVOKER_CAST => [1], - SAI_ACTION_CROSS_CAST => [1] - ); - - return self::getOwnerAction($srcType, $entry, $lookup); - } - - public static function getSoundsPlayedForOwner(int $entry, int $srcType = SAI_SRC_TYPE_CREATURE) : array - { - // action => paramIdx with soundIds - $lookup = [SAI_ACTION_SOUND => [1]]; - - return self::getOwnerAction($srcType, $entry, $lookup); - } - - private static function getOwnerAction(int $sourceType, int $entry, array $lookup, ?array $moreInfo = []) : array - { - if ($entry < 0) // please not individual entities :( - return []; - - $smartScripts = DB::World()->select('SELECT action_type, action_param1, action_param2, action_param3, action_param4, action_param5, action_param6 FROM smart_scripts WHERE source_type = ?d AND action_type IN (?a) AND entryOrGUID = ?d', $sourceType, array_merge(array_keys($lookup), SAI_ACTION_ALL_TIMED_ACTION_LISTS), $entry); - $smartResults = []; - $smartTALs = []; - foreach ($smartScripts as $s) - { - if ($s['action_type'] == SAI_ACTION_SPAWN_SPAWNGROUP) - $moreInfo[SAI_ACTION_SPAWN_SPAWNGROUP][] = $s['action_param1']; - else if (in_array($s['action_type'], array_keys($lookup))) - { - foreach ($lookup[$s['action_type']] as $p) - $smartResults[] = $s['action_param'.$p]; - } - else if ($s['action_type'] == SAI_ACTION_CALL_TIMED_ACTIONLIST) - $smartTALs[] = $s['action_param1']; - else if ($s['action_type'] == SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST) - { - for ($i = 1; $i < 7; $i++) - if ($s['action_param'.$i]) - $smartTALs[] = $s['action_param'.$i]; - } - else if ($s['action_type'] == SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST) - { - for ($i = $s['action_param1']; $i <= $s['action_param2']; $i++) - $smartTALs[] = $i; - } - } - - if ($smartTALs) - { - if ($TALActList = DB::World()->select('SELECT action_type, action_param1, action_param2, action_param3, action_param4, action_param5, action_param6 FROM smart_scripts WHERE source_type = ?d AND action_type IN (?a) AND entryOrGUID IN (?a)', SAI_SRC_TYPE_ACTIONLIST, array_keys($lookup), $smartTALs)) - { - foreach ($TALActList as $e) - { - foreach ($lookup[$e['action_type']] as $i) - { - if ($e['action_type'] == SAI_ACTION_SPAWN_SPAWNGROUP) - $moreInfo[SAI_ACTION_SPAWN_SPAWNGROUP][] = $e['action_param'.$i]; - else - $smartResults[] = $e['action_param'.$i]; - } - } - } - } - - return $smartResults; - } - - - /******************************/ - /* Structured Lisview Display */ - /******************************/ - - public function __construct(int $srcType, int $entry, array $miscData = []) - { - $this->srcType = $srcType; - $this->entry = $entry; - $this->miscData = $miscData; - - $raw = DB::World()->select('SELECT id, link, event_type, event_phase_mask, event_chance, event_flags, event_param1, event_param2, event_param3, event_param4, event_param5, action_type, action_param1, action_param2, action_param3, action_param4, action_param5, action_param6, target_type, target_param1, target_param2, target_param3, target_param4, target_x, target_y, target_z, target_o FROM smart_scripts WHERE entryorguid = ?d AND source_type = ?d ORDER BY id ASC', $this->entry, $this->srcType); - foreach ($raw as $r) - { - $this->rawData[$r['id']] = array( - 'id' => $r['id'], - 'link' => $r['link'], - 'event' => array( - 'type' => $r['event_type'], - 'phases' => Util::mask2bits($r['event_phase_mask'], 1) ?: [0], - 'chance' => $r['event_chance'], - 'flags' => $r['event_flags'], - 'param' => [$r['event_param1'], $r['event_param2'], $r['event_param3'], $r['event_param4'], $r['event_param5']] - ), - 'action' => array( - 'type' => $r['action_type'], - 'param' => [$r['action_param1'], $r['action_param2'], $r['action_param3'], $r['action_param4'], $r['action_param5'], $r['action_param6']] - ), - 'target' => array( - 'type' => $r['target_type'], - 'param' => [$r['target_param1'], $r['target_param2'], $r['target_param3'], $r['target_param4']], - 'pos' => [$r['target_x'], $r['target_y'], $r['target_z'], $r['target_o']] - ) - ); - } - } - - public function prepare() : bool - { - if (!$this->rawData) - return false; - - if ($this->result) - return true; - - $hidePhase = - $hideChance = true; - - foreach ($this->iterate() as $_) - { - $this->rowKey = Util::createHash(8); - - if ($ts = $this->getTalkSource()) - $this->getQuotes($ts); - - [$evtBody, $evtFooter] = $this->event(); - [$actBody, $actFooter] = $this->action(); - - if ($ef = $this->eventFlags()) - { - if ($evtFooter) - $evtFooter = $ef.', '.$evtFooter; - else - $evtFooter = $ef; - } - - if ($this->itr['event']['phases'] != [0]) - $hidePhase = false; - - if ($this->itr['event']['chance'] != 100) - $hideChance = false; - - $this->result[] = array( - $this->itr['id'], - implode(', ', $this->itr['event']['phases']), - $evtBody.($evtFooter ? '[div float=right margin=0px][i][small class=q0]'.$evtFooter.'[/small][/i][/div]' : null), - $this->itr['event']['chance'].'%', - $actBody.($actFooter ? '[div float=right margin=0px clear=both][i][small class=q0]'.$actFooter.'[/small][/i][/div]' : null) - ); - } - - $th = array( - '#' => 16, - 'Phase' => 32, - 'Event' => 350, - 'Chance' => 24, - 'Action' => 0 - ); - - if ($hidePhase) - { - unset($th['Phase']); - foreach ($this->result as &$r) - unset($r[1]); - } - unset($r); - - if ($hideChance) - { - unset($th['Chance']); - foreach ($this->result as &$r) - unset($r[3]); - } - unset($r); - - $tbl = '[tr]'; - foreach ($th as $n => $w) - $tbl .= '[td header '.($w ? 'width='.$w.'px' : null).']'.$n.'[/td]'; - $tbl .= '[/tr]'; - - foreach ($this->result as $r) - $tbl .= '[tr][td]'.implode('[/td][td]', $r).'[/td][/tr]'; - - if ($this->srcType == SAI_SRC_TYPE_ACTIONLIST) - $this->tabs[$this->entry] = $tbl; - else - $this->tabs[0] = $tbl; - - return true; - } - - public function getMarkdown() : string - { - # id | event (footer phase) | chance | action + target - - if (!$this->rawData) - return ''; - - $return = '[style]#text-generic .grid { clear:left; } #text-generic .tabbed-contents { padding:0px; clear:left; }[/style][pad][h3][toggler id=sai]SmartAI'.(!empty($this->miscData['title']) ? $this->miscData['title'] : null).'[/toggler][/h3][div id=sai clear=left]%s[/div]'; - if (count($this->tabs) > 1) - { - $wrapper = '[tabs name=sai width=942px]%s[/tabs]'; - $tabs = ''; - foreach ($this->tabs as $guid => $data) - { - $buff = '[tab name=\"'.($guid ? 'ActionList #'.$guid : 'Main').'\"][table class=grid width=940px]'.$data.'[/table][/tab]'; - if ($guid) - $tabs .= $buff; - else - $tabs = $buff . $tabs; - } - - return sprintf($return, sprintf($wrapper, $tabs)); - } - else - return sprintf($return, '[table class=grid width=940px]'.$this->tabs[0].'[/table]'); - } - - public function getJSGlobals() : array - { - return $this->jsGlobals; - } - - public function getTabs() : array - { - return $this->tabs; - } - - - private function &iterate() : iterable - { - reset($this->rawData); - - foreach ($this->rawData as $k => $__) - { - $this->itr = &$this->rawData[$k]; - - yield $this->itr; - } - } - - private function numRange(string $f, int $n, bool $isTime = false) : string - { - if (!isset($this->itr[$f]['param'][$n]) || !isset($this->itr[$f]['param'][$n + 1])) - return 0; - - if (empty($this->itr[$f]['param'][$n]) && empty($this->itr[$f]['param'][$n + 1])) - return 0; - - $str = $isTime ? Util::formatTime($this->itr[$f]['param'][$n], true) : $this->itr[$f]['param'][$n]; - if ($this->itr[$f]['param'][$n + 1] > $this->itr[$f]['param'][$n]) - $str .= ' – '.($isTime ? Util::formatTime($this->itr[$f]['param'][$n + 1], true) : $this->itr[$f]['param'][$n + 1]); - - return $str; - } - - private function getQuotes(int $creatureId) : void - { - if (isset($this->quotes[$creatureId])) - return; - - [$quotes, , ] = Game::getQuotesForCreature($creatureId); - - $this->quotes[$creatureId] = $quotes; - - if (!empty($this->quotes[$creatureId])) - $this->quotes[$creatureId]['src'] = CreatureList::getName($creatureId); - } - - private function getTalkSource(bool &$emptySource = false) : int - { - if ($this->itr['action']['type'] != SAI_ACTION_TALK && - $this->itr['action']['type'] != SAI_ACTION_SIMPLE_TALK) - return 0; - - switch ($this->itr['target']['type']) - { - case SAI_TARGET_CREATURE_GUID: - if ($id = DB::World()->selectCell('SELECT id FROM creature WHERE guid = ?d', $this->itr['target']['param'][0])) - return $id; - - break; - case SAI_TARGET_CREATURE_RANGE: - case SAI_TARGET_CREATURE_DISTANCE: - case SAI_TARGET_CLOSEST_CREATURE: - return $this->itr['target']['param'][0]; - case SAI_TARGET_CLOSEST_PLAYER: - $emptySource = true; - case SAI_TARGET_SELF: - case SAI_TARGET_ACTION_INVOKER: - case SAI_TARGET_CLOSEST_FRIENDLY: // unsure about this - default: - return empty($this->miscData['baseEntry']) ? $this->entry : $this->miscData['baseEntry']; - } - - return 0; - } - - private function eventFlags() : string - { - $ef = []; - for ($i = 1; $i <= SAI_EVENT_FLAG_WHILE_CHARMED; $i <<= 1) - if ($this->itr['event']['flags'] & $i) - if ($x = Lang::smartAI('eventFlags', $i)) - $ef[] = $x; - - return Lang::concat($ef); - } - - private function castFlags(string $f, int $n) : string - { - $cf = []; - for ($i = 1; $i <= SAI_CAST_FLAG_COMBAT_MOVE; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::smartAI('castFlags', $i)) - $cf[] = $x; - - return Lang::concat($cf); - } - - private function npcFlags(string $f, int $n) : string - { - $nf = []; - for ($i = 1; $i <= NPC_FLAG_MAILBOX; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::npc('npcFlags', $i)) - $nf[] = $x; - - return Lang::concat($nf ?: [Lang::smartAI('empty')]); - } - - private function unitFlags(string $f, int $n) : string - { - $uf = []; - for ($i = 1; $i <= UNIT_FLAG_UNK_31; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::unit('flags', $i)) - $uf[] = $x; - - return Lang::concat($uf ?: [Lang::smartAI('empty')]); - } - - private function unitFlags2(string $f, int $n) : string - { - $uf = []; - for ($i = 1; $i <= UNIT_FLAG2_ALLOW_CHEAT_SPELLS; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::unit('flags2', $i)) - $uf[] = $x; - - return Lang::concat($uf ?: [Lang::smartAI('empty')]); - } - - private function unitFieldBytes1(int $idx, int $val) : string - { - if ($idx === 0) - { - if ($standState = Lang::unit('bytes1', $idx, $val)) - return $standState; - else - return Lang::unit('bytes1', 'valueUNK', [$val, $idx]); - } - else if ($idx === 2 || $idx == 3) - { - $buff = []; - for ($i = 1; $i <= 0x10; $i <<= 1) - if ($val & $i) - if ($x = Lang::unit('bytes1', $idx, $val)) - $buff[] = $x; - - return $buff ? Lang::concat($buff) : Lang::unit('bytes1', 'valueUNK', [$val, $idx]); - } - else - return Lang::unit('bytes1', 'idxUNK', [$idx]); - } - - private function summonType(int $summonType) : string - { - if ($summonType = Lang::smartAI('summonTypes', $summonType)) - return $summonType; - else - return Lang::smartAI('summonType', 'summonTypeUNK', [$summonType]); - } - - private function dynFlags(string $f, int $n) : string - { - $df = []; - for ($i = 1; $i <= UNIT_DYNFLAG_TAPPED_BY_ALL_THREAT_LIST; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::unit('dynFlags', $i)) - $df[] = $x; - - return Lang::concat($df ?: [Lang::smartAI('empty')]); - } - - private function goFlags(string $f, int $n) : string - { - $gf = []; - for ($i = 1; $i <= GO_FLAG_DESTROYED; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::gameObject('goFlags', $i)) - $gf[] = $x; - - return Lang::concat($gf ?: [Lang::smartAI('empty')]); - } - - private function spawnFlags(string $f, int $n) : string - { - $sf = []; - for ($i = 1; $i <= SAI_SPAWN_FLAG_NOSAVE_RESPAWN; $i <<= 1) - if ($this->itr[$f]['param'][$n] & $i) - if ($x = Lang::smartAI('spawnFlags', $i)) - $sf[] = $x; - - return Lang::concat($sf ?: [Lang::smartAI('empty')]); - } - - private function aiTemplate(int $aiNum) : string - { - if ($standState = Lang::smartAI('aiTpl', $aiNum)) - return $standState; - else - return Lang::smartAI('aiTplUNK', [$aiNum]); - } - - private function reactState(int $stateNum) : string - { - if ($reactState = Lang::smartAI('reactStates', $stateNum)) - return $reactState; - else - return Lang::smartAI('reactStateUNK', [$stateNum]); - } - - private function target(array $override = []) : string - { - $target = ''; - - $t = $override ?: $this->itr['target']; - - $getDist = function ($min, $max) { return ($min && $max) ? min($min, $max).' – '.max($min, $max) : max($min, $max); }; - $tooltip = '[tooltip name=t-'.$this->rowKey.']'.Lang::smartAI('targetTT', array_merge([$t['type']], $t['param'], $t['pos'])).'[/tooltip][span class=tip tooltip=t-'.$this->rowKey.']%s[/span]'; - - // additional parameters - $t['param'] = array_pad($t['param'], 15, ''); - - switch ($t['type']) - { - // direct param use - case SAI_TARGET_SELF: // 1 - case SAI_TARGET_VICTIM: // 2 - case SAI_TARGET_HOSTILE_SECOND_AGGRO: // 3 - case SAI_TARGET_HOSTILE_LAST_AGGRO: // 4 - case SAI_TARGET_HOSTILE_RANDOM: // 5 - case SAI_TARGET_HOSTILE_RANDOM_NOT_TOP: // 6 - case SAI_TARGET_ACTION_INVOKER: // 7 - case SAI_TARGET_POSITION: // 8 - case SAI_TARGET_STORED: // 12 - case SAI_TARGET_INVOKER_PARTY: // 16 - case SAI_TARGET_CLOSEST_PLAYER: // 21 - case SAI_TARGET_ACTION_INVOKER_VEHICLE: // 22 - case SAI_TARGET_OWNER_OR_SUMMONER: // 23 - case SAI_TARGET_THREAT_LIST: // 24 - case SAI_TARGET_CLOSEST_ENEMY: // 25 - case SAI_TARGET_CLOSEST_FRIENDLY: // 26 - case SAI_TARGET_LOOT_RECIPIENTS: // 27 - case SAI_TARGET_FARTHEST: // 28 - break; - case SAI_TARGET_VEHICLE_PASSENGER: // 29 - if ($t['param'][0]) - $t['param'][10] = Lang::concat(Util::mask2bits($t['param'][0])); - break; - // distance - case SAI_TARGET_PLAYER_RANGE: // 17 - $t['param'][10] = $getDist($t['param'][0], $t['param'][1]); - break; - case SAI_TARGET_PLAYER_DISTANCE: // 18 - $t['param'][10] = $getDist(0, $t['param'][0]); - break; - // creature link - case SAI_TARGET_CREATURE_RANGE: // 9 - if ($t['param'][0]) - $this->jsGlobals[Type::NPC][] = $t['param'][0]; - - $t['param'][10] = $getDist($t['param'][1], $t['param'][2]); - break; - case SAI_TARGET_CREATURE_GUID: // 10 - if ($t['param'][10] = DB::World()->selectCell('SELECT id FROM creature WHERE guid = ?d', $t['param'][0])) - $this->jsGlobals[Type::NPC][] = $t['param'][10]; - else - trigger_error('SmartAI::resloveTarget - creature with guid '.$t['param'][0].' not in DB'); - break; - case SAI_TARGET_CREATURE_DISTANCE: // 11 - case SAI_TARGET_CLOSEST_CREATURE: // 19 - $t['param'][10] = $getDist(0, $t['param'][1]); - - if ($t['param'][0]) - $this->jsGlobals[Type::NPC][] = $t['param'][0]; - break; - // gameobject link - case SAI_TARGET_GAMEOBJECT_GUID: // 14 - if ($t['param'][10] = DB::World()->selectCell('SELECT id FROM gameobject WHERE guid = ?d', $t['param'][0])) - $this->jsGlobals[Type::OBJECT][] = $t['param'][10]; - else - trigger_error('SmartAI::resloveTarget - gameobject with guid '.$t['param'][0].' not in DB'); - break; - case SAI_TARGET_GAMEOBJECT_RANGE: // 13 - $t['param'][10] = $getDist($t['param'][1], $t['param'][2]); - - if ($t['param'][0]) - $this->jsGlobals[Type::OBJECT][] = $t['param'][0]; - break; - case SAI_TARGET_GAMEOBJECT_DISTANCE: // 15 - case SAI_TARGET_CLOSEST_GAMEOBJECT: // 20 - case SAI_TARGET_CLOSEST_UNSPAWNED_GO: // 30 - $t['param'][10] = $getDist(0, $t['param'][1]); - - if ($t['param'][0]) - $this->jsGlobals[Type::OBJECT][] = $t['param'][0]; - break; - // error - default: - $target = Lang::smartAI('targetUNK', [$t['type']]); - } - - $target = $target ?: Lang::smartAI('targets', $t['type'], $t['param']); - - // resolve conditionals - $target = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):([^;]*);/i', function ($m) { return $m[1] ? $m[2] : $m[3]; }, $target); - - // wrap in tooltip (suspend action-tooltip) - return '[/span]'.sprintf($tooltip, $target).'[span tooltip=a-'.$this->rowKey.']'; - } - - private function event() : array - { - $body = - $footer = ''; - - $e = &$this->itr['event']; - - $tooltip = '[tooltip name=e-'.$this->rowKey.']'.Lang::smartAI('eventTT', array_merge([$e['type'], $e['phases'], $e['chance'], $e['flags']], $e['param'])).'[/tooltip][span tooltip=e-'.$this->rowKey.']%s[/span]'; - - // additional parameters - $e['param'] = array_pad($e['param'], 15, ''); - - switch ($e['type']) - { - // simple - case SAI_EVENT_AGGRO: // 4 - On Creature Aggro - case SAI_EVENT_DEATH: // 6 - On Creature Death - case SAI_EVENT_EVADE: // 7 - On Creature Evade Attack - case SAI_EVENT_RESPAWN: // 11 - On Creature/Gameobject Respawn - case SAI_EVENT_REACHED_HOME: // 21 - On Creature Reached Home - case SAI_EVENT_RESET: // 25 - After Combat, On Respawn or Spawn - case SAI_EVENT_CHARMED: // 29 - On Creature Charmed - case SAI_EVENT_CHARMED_TARGET: // 30 - On Target Charmed - case SAI_EVENT_MOVEMENTINFORM: // 34 - WAYPOINT_MOTION_TYPE = 2, POINT_MOTION_TYPE = 8 - case SAI_EVENT_CORPSE_REMOVED: // 36 - On Creature Corpse Removed - case SAI_EVENT_AI_INIT: // 37 - - case SAI_EVENT_WAYPOINT_START: // 39 - On Creature Waypoint ID Started - case SAI_EVENT_WAYPOINT_REACHED: // 40 - On Creature Waypoint ID Reached - case SAI_EVENT_AREATRIGGER_ONTRIGGER: // 46 - - case SAI_EVENT_JUST_SUMMONED: // 54 - On Creature Just spawned - case SAI_EVENT_WAYPOINT_PAUSED: // 55 - On Creature Paused at Waypoint ID - case SAI_EVENT_WAYPOINT_RESUMED: // 56 - On Creature Resumed after Waypoint ID - case SAI_EVENT_WAYPOINT_STOPPED: // 57 - On Creature Stopped On Waypoint ID - case SAI_EVENT_WAYPOINT_ENDED: // 58 - On Creature Waypoint Path Ended - case SAI_EVENT_TIMED_EVENT_TRIGGERED: // 59 - - case SAI_EVENT_JUST_CREATED: // 63 - - case SAI_EVENT_FOLLOW_COMPLETED: // 65 - - case SAI_EVENT_GO_STATE_CHANGED: // 70 - - case SAI_EVENT_GO_EVENT_INFORM: // 71 - - case SAI_EVENT_ACTION_DONE: // 72 - - case SAI_EVENT_ON_SPELLCLICK: // 73 - - case SAI_EVENT_COUNTER_SET: // 77 - If the value of specified counterID is equal to a specified value - break; - // num range [+ time footer] - case SAI_EVENT_HEALTH_PCT: // 2 - Health Percentage - case SAI_EVENT_MANA_PCT: // 3 - Mana Percentage - case SAI_EVENT_RANGE: // 9 - On Target In Range - case SAI_EVENT_TARGET_HEALTH_PCT: // 12 - On Target Health Percentage - case SAI_EVENT_TARGET_MANA_PCT: // 18 - On Target Mana Percentage - case SAI_EVENT_DAMAGED: // 32 - On Creature Damaged - case SAI_EVENT_DAMAGED_TARGET: // 33 - On Target Damaged - case SAI_EVENT_RECEIVE_HEAL: // 53 - On Creature Received Healing - case SAI_EVENT_FRIENDLY_HEALTH_PCT: // 74 - - $e['param'][10] = $this->numRange('event', 0); - // do not break; - case SAI_EVENT_OOC_LOS: // 10 - On Target In Distance Out of Combat - case SAI_EVENT_FRIENDLY_HEALTH: // 14 - On Friendly Health Deficit - case SAI_EVENT_FRIENDLY_MISSING_BUFF: // 16 - On Friendly Lost Buff - case SAI_EVENT_IC_LOS: // 26 - On Target In Distance In Combat - case SAI_EVENT_DATA_SET: // 38 - On Creature/Gameobject Data Set, Can be used with SMART_ACTION_SET_DATA - if ($time = $this->numRange('event', 2, true)) - $footer = $time; - break; - // SAI updates - case SAI_EVENT_UPDATE_IC: // 0 - In combat. - case SAI_EVENT_UPDATE_OOC: // 1 - Out of combat. - if ($this->srcType == SAI_SRC_TYPE_ACTIONLIST) - $e['param'][11] = 1; - // do not break; - case SAI_EVENT_UPDATE: // 60 - - $e['param'][10] = $this->numRange('event', 0, true); - if ($time = $this->numRange('event', 2, true)) - $footer = $time; - break; - case SAI_EVENT_GOSSIP_HELLO: // 64 - On Right-Click Creature/Gameobject that have gossip enabled. - if ($this->srcType == SAI_SRC_TYPE_OBJECT) - $footer = array( - $e['param'][0] == 1, - $e['param'][0] == 2, - ); - break; - case SAI_EVENT_KILL: // 5 - On Creature Kill - if ($time = $this->numRange('event', 0, true)) - $footer = $time; - - if ($e['param'][3] && !$e['param'][2]) - $this->jsGlobals[Type::NPC][] = $e['param'][3]; - break; - case SAI_EVENT_SPELLHIT: // 8 - On Creature/Gameobject Spell Hit - case SAI_EVENT_HAS_AURA: // 23 - On Creature Has Aura - case SAI_EVENT_TARGET_BUFFED: // 24 - On Target Buffed With Spell - case SAI_EVENT_SPELLHIT_TARGET: // 31 - On Target Spell Hit - if ($time = $this->numRange('event', 2, true)) - $footer = $time; - - if ($e['param'][1]) - $e['param'][10] = Lang::getMagicSchools($e['param'][1]); - - if ($e['param'][0]) - $this->jsGlobals[Type::SPELL][] = $e['param'][0]; - break; - case SAI_EVENT_VICTIM_CASTING: // 13 - On Target Casting Spell - if ($e['param'][2]) - $this->jsGlobals[Type::SPELL][$e['param'][2]]; - // do not break; - case SAI_EVENT_PASSENGER_BOARDED: // 27 - - case SAI_EVENT_PASSENGER_REMOVED: // 28 - - case SAI_EVENT_IS_BEHIND_TARGET: // 67 - On Creature is behind target. - if ($time = $this->numRange('event', 0, true)) - $footer = $time; - break; - case SAI_EVENT_SUMMONED_UNIT: // 17 - On Creature/Gameobject Summoned Unit - case SAI_EVENT_SUMMONED_UNIT_DIES: // 82 - On Summoned Unit Dies - if ($e['param'][0]) - $this->jsGlobals[Type::NPC][] = $e['param'][0]; - // do not break; - case SAI_EVENT_FRIENDLY_IS_CC: // 15 - - case SAI_EVENT_SUMMON_DESPAWNED: // 35 - On Summoned Unit Despawned - if ($time = $this->numRange('event', 1, true)) - $footer = $time; - break; - case SAI_EVENT_ACCEPTED_QUEST: // 19 - On Target Accepted Quest - case SAI_EVENT_REWARD_QUEST: // 20 - On Target Rewarded Quest - if ($e['param'][0]) - $this->jsGlobals[Type::QUEST][] = $e['param'][0]; - if ($time = $this->numRange('event', 1, true)) - $footer = $time; - break; - case SAI_EVENT_RECEIVE_EMOTE: // 22 - On Receive Emote. - $this->jsGlobals[Type::EMOTE][] = $e['param'][0]; - - if ($time = $this->numRange('event', 1, true)) - $footer = $time; - break; - case SAI_EVENT_TEXT_OVER: // 52 - On TEXT_OVER Event Triggered After SMART_ACTION_TALK - if ($e['param'][1]) - $this->jsGlobals[Type::NPC][] = $e['param'][1]; - break; - case SAI_EVENT_LINK: // 61 - Used to link together multiple events as a chain of events. - $e['param'][10] = LANG::concat(DB::World()->selectCol('SELECT CONCAT("#[b]", id, "[/b]") FROM smart_scripts WHERE link = ?d AND entryorguid = ?d AND source_type = ?d', $this->itr['id'], $this->entry, $this->srcType), false); - break; - case SAI_EVENT_GOSSIP_SELECT: // 62 - On gossip clicked (gossip_menu_option335). - $gmo = DB::World()->selectRow('SELECT gmo.OptionText AS text_loc0 {, gmol.OptionText AS text_loc?d} - FROM gossip_menu_option gmo LEFT JOIN gossip_menu_option_locale gmol ON gmo.MenuID = gmol.MenuID AND gmo.OptionID = gmol.OptionID AND gmol.Locale = ?d - WHERE gmo.MenuId = ?d AND gmo.OptionID = ?d', - User::$localeId ? Util::$localeStrings[User::$localeId] : DBSIMPLE_SKIP, - User::$localeId, - $e['param'][0], - $e['param'][1] - ); - - if ($gmo) - $e['param'][10] = Util::localizedString($gmo, 'text'); - else - trigger_error('SmartAI::event - could not find gossip menu option for event #'.$e['type']); - break; - case SAI_EVENT_GAME_EVENT_START: // 68 - On game_event started. - case SAI_EVENT_GAME_EVENT_END: // 69 - On game_event ended. - $this->jsGlobals[Type::WORLDEVENT][] = $e['param'][0]; - break; - case SAI_EVENT_DISTANCE_CREATURE: // 75 - On creature guid OR any instance of creature entry is within distance. - if ($e['param'][0]) - $e['param'][10] = DB::World()->selectCell('SELECT id FROM creature WHERE guid = ?d', $e['param'][0]); - // do not break; - case SAI_EVENT_DISTANCE_GAMEOBJECT: // 76 - On gameobject guid OR any instance of gameobject entry is within distance. - if ($e['param'][0] && !$e['param'][10]) - $e['param'][10] = DB::World()->selectCell('SELECT id FROM gameobject WHERE guid = ?d', $e['param'][0]); - else if ($e['param'][1]) - $e['param'][10] = $e['param'][1]; - else if (!$e['param'][10]) - trigger_error('SmartAI::event - entity for event #'.$e['type'].' not defined'); - - if ($e['param'][10]) - $this->jsGlobals[Type::NPC][] = $e['param'][10]; - - if ($e['param'][3]) - $footer = Util::formatTime($e['param'][3], true); - break; - case SAI_EVENT_EVENT_PHASE_CHANGE: // 66 - On event phase mask set - $e['param'][10] = Lang::concat(Util::mask2bits($e['param'][0]), false); - break; - default: - $body = '[span class=q10]Unhandled Event[/span] #'.$e['type']; - } - - $body = $body ?: Lang::smartAI('events', $e['type'], 0, $e['param']); - if ($footer) - $footer = Lang::smartAI('events', $e['type'], 1, (array)$footer); - - // resolve conditionals - $footer = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):([^;]*);/i', function ($m) { return $m[1] ? $m[2] : $m[3]; }, $footer); - $body = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):([^;]*);/i', function ($m) { return $m[1] ? $m[2] : $m[3]; }, $body); - $body = str_replace('#target#', $this->target(), $body); - - // wrap body in tooltip - return [sprintf($tooltip, $body), $footer]; - } - - private function action() : array - { - $body = - $footer = ''; - - $a = &$this->itr['action']; - - $tooltip = '[tooltip name=a-'.$this->rowKey.']'.Lang::smartAI('actionTT', array_merge([$a['type']], $a['param'])).'[/tooltip][span tooltip=a-'.$this->rowKey.']%s[/span]'; - - // init additional parameters - $a['param'] = array_pad($a['param'], 15, ''); - - switch ($a['type']) - { - // simple - case SAI_ACTION_ACTIVATE_GOBJECT: // 9 -> any target - case SAI_ACTION_AUTO_ATTACK: // 20 -> any target - case SAI_ACTION_ALLOW_COMBAT_MOVEMENT: // 21 -> self - case SAI_ACTION_SET_EVENT_PHASE: // 22 -> any target - case SAI_ACTION_INC_EVENT_PHASE: // 23 -> any target - case SAI_ACTION_EVADE: // 24 -> any target - case SAI_ACTION_COMBAT_STOP: // 27 -> self - case SAI_ACTION_RANDOM_PHASE_RANGE: // 31 -> self - case SAI_ACTION_RESET_GOBJECT: // 32 -> any target - case SAI_ACTION_SET_INST_DATA: // 34 -> self, invoker, irrelevant - case SAI_ACTION_DIE: // 37 -> self - case SAI_ACTION_SET_IN_COMBAT_WITH_ZONE: // 38 -> self - case SAI_ACTION_SET_INVINCIBILITY_HP_LEVEL: // 42 -> self - case SAI_ACTION_SET_DATA: // 45 -> any target - case SAI_ACTION_ATTACK_STOP: // 46 -> self - case SAI_ACTION_SET_VISIBILITY: // 47 -> any target - case SAI_ACTION_SET_ACTIVE: // 48 -> any target - case SAI_ACTION_ATTACK_START: // 49 -> any target - case SAI_ACTION_KILL_UNIT: // 51 -> any target - case SAI_ACTION_SET_RUN: // 59 -> self - case SAI_ACTION_SET_DISABLE_GRAVITY: // 60 -> self - case SAI_ACTION_SET_SWIM: // 61 -> self - case SAI_ACTION_SET_COUNTER: // 63 -> any target - case SAI_ACTION_STORE_TARGET_LIST: // 64 -> any target - case SAI_ACTION_WP_RESUME: // 65 -> self - case SAI_ACTION_PLAYMOVIE: // 68 -> invoker - case SAI_ACTION_CLOSE_GOSSIP: // 72 -> any target .. doesn't matter though - case SAI_ACTION_TRIGGER_TIMED_EVENT: // 73 -> self - case SAI_ACTION_REMOVE_TIMED_EVENT: // 74 -> self - case SAI_ACTION_OVERRIDE_SCRIPT_BASE_OBJECT: // 76 -> any?? - case SAI_ACTION_RESET_SCRIPT_BASE_OBJECT: // 77 -> self - case SAI_ACTION_CALL_SCRIPT_RESET: // 78 -> self - case SAI_ACTION_SET_RANGED_MOVEMENT: // 79 -> self - case SAI_ACTION_RANDOM_MOVE: // 89 -> any target - case SAI_ACTION_SEND_GO_CUSTOM_ANIM: // 93 -> self - case SAI_ACTION_SEND_GOSSIP_MENU: // 98 -> invoker - case SAI_ACTION_SEND_TARGET_TO_TARGET: // 100 -> any target - case SAI_ACTION_SET_HEALTH_REGEN: // 102 -> any target - case SAI_ACTION_SET_ROOT: // 103 -> any target - case SAI_ACTION_DISABLE_EVADE: // 117 -> self - case SAI_ACTION_SET_CAN_FLY: // 119 -> self - case SAI_ACTION_SET_SIGHT_DIST: // 121 -> any target - case SAI_ACTION_REMOVE_ALL_GAMEOBJECTS: // 126 -> any target - case SAI_ACTION_PLAY_CINEMATIC: // 135 -> player target - break; - case SAI_ACTION_PAUSE_MOVEMENT: // 127 -> any target [ye, not gonna resolve this nonsense] - $a['param'][6] = Util::formatTime($a['param'][1], true); - if ($a['param'][2]) - $footer = true; - break; - // simple type as param[0] - case SAI_ACTION_PLAY_EMOTE: // 5 -> any target - case SAI_ACTION_SET_EMOTE_STATE: // 17 -> any target - if ($a['param'][0]) - $this->jsGlobals[Type::EMOTE][] = $a['param'][0]; - break; - case SAI_ACTION_FAIL_QUEST: // 6 -> any target - case SAI_ACTION_OFFER_QUEST: // 7 -> invoker - case SAI_ACTION_CALL_AREAEXPLOREDOREVENTHAPPENS:// 15 -> any target - case SAI_ACTION_CALL_GROUPEVENTHAPPENS: // 26 -> invoker - if ($a['param'][0]) - $this->jsGlobals[Type::QUEST][] = $a['param'][0]; - break; - case SAI_ACTION_REMOVEAURASFROMSPELL: // 28 -> any target - if ($a['param'][2]) - $footer = true; - case SAI_ACTION_ADD_AURA: // 75 -> any target - if ($a['param'][0]) - $this->jsGlobals[Type::SPELL][] = $a['param'][0]; - break; - case SAI_ACTION_CALL_KILLEDMONSTER: // 33 -> any target - case SAI_ACTION_UPDATE_TEMPLATE: // 36 -> self - if ($a['param'][0]) - $this->jsGlobals[Type::NPC][] = $a['param'][0]; - break; - case SAI_ACTION_ADD_ITEM: // 56 -> invoker - case SAI_ACTION_REMOVE_ITEM: // 57 -> invoker - if ($a['param'][0]) - $this->jsGlobals[Type::ITEM][] = $a['param'][0]; - break; - case SAI_ACTION_GAME_EVENT_STOP: // 111 -> doesnt matter - case SAI_ACTION_GAME_EVENT_START: // 112 -> doesnt matter - if ($a['param'][0]) - $this->jsGlobals[Type::WORLDEVENT][] = $a['param'][0]; - break; - // simple preparse from param[0] to param[6] - case SAI_ACTION_SET_REACT_STATE: // 8 -> any target - $a['param'][6] = $this->reactState($a['param'][0]); - break; - case SAI_ACTION_SET_NPC_FLAG: // 81 -> any target - case SAI_ACTION_ADD_NPC_FLAG: // 82 -> any target - case SAI_ACTION_REMOVE_NPC_FLAG: // 83 -> any target - $a['param'][6] = $this->npcFlags('action', 0); - break; - case SAI_ACTION_SET_UNIT_FIELD_BYTES_1: // 90 -> any target - case SAI_ACTION_REMOVE_UNIT_FIELD_BYTES_1: // 91 -> any target - $a['param'][6] = $this->unitFieldBytes1($a['param'][1], $a['param'][0]); - break; - case SAI_ACTION_SET_DYNAMIC_FLAG: // 94 -> any target - case SAI_ACTION_ADD_DYNAMIC_FLAG: // 95 -> any target - case SAI_ACTION_REMOVE_DYNAMIC_FLAG: // 96 -> any target - $a['param'][6] = $this->dynFlags('action', 0); - break; - case SAI_ACTION_SET_GO_FLAG: // 104 -> any target - case SAI_ACTION_ADD_GO_FLAG: // 105 -> any target - case SAI_ACTION_REMOVE_GO_FLAG: // 106 -> any target - $a['param'][6] = $this->goFlags('action', 0); - break; - case SAI_ACTION_SET_POWER: // 108 -> any target - case SAI_ACTION_ADD_POWER: // 109 -> any target - case SAI_ACTION_REMOVE_POWER: // 110 -> any target - $a['param'][6] = Lang::spell('powerTypes', $a['param'][0]); - break; - // misc - case SAI_ACTION_TALK: // 1 -> any target - $noSrc = false; - if ($src = $this->getTalkSource($noSrc)) - { - if ($a['param'][6] = isset($this->quotes[$src][$a['param'][0]])) - { - $quotes = $this->quotes[$src][$a['param'][0]]; - foreach ($quotes as $quote) - { - $a['param'][7] .= sprintf($quote['text'], $noSrc ? '' : sprintf($quote['prefix'], $this->quotes[$src]['src']), $this->quotes[$src]['src']); - if ($a['param'][1]) - $footer = [Util::formatTime($a['param'][1], true)]; - } - - // todo (low): undestand what action_param2 does - } - } - else - trigger_error('SmartAI::action - could not determine talk source for action #'.$a['type']); - - break; - case SAI_ACTION_SET_FACTION: // 2 -> any target - if ($a['param'][0]) - { - $a['param'][6] = DB::Aowow()->selectCell('SELECT factionId FROM ?_factiontemplate WHERE id = ?d', $a['param'][0]); - $this->jsGlobals[Type::FACTION][] = $a['param'][6]; - } - break; - case SAI_ACTION_MORPH_TO_ENTRY_OR_MODEL: // 3 -> self - if ($a['param'][0]) - $this->jsGlobals[Type::NPC][] = $a['param'][0]; - else if (!$a['param'][1]) - $a['param'][6] = 1; - - break; - case SAI_ACTION_SOUND: // 4 -> self [param3 set in DB but not used in core?] - $this->jsGlobals[Type::SOUND][] = $a['param'][0]; - if ($a['param'][2]) - $footer = true; - - break; - case SAI_ACTION_RANDOM_EMOTE: // 10 -> any target - $buff = []; - for ($i = 0; $i < 6; $i++) - { - if (empty($a['param'][$i])) - continue; - - $buff[] = '[emote='.$a['param'][$i].']'; - $this->jsGlobals[Type::EMOTE][] = $a['param'][$i]; - } - $a['param'][6] = Lang::concat($buff, false); - break; - case SAI_ACTION_CAST: // 11 -> any target - $this->jsGlobals[Type::SPELL][] = $a['param'][0]; - if ($_ = $this->castFlags('action', 1)) - $footer = $_; - - break; - case SAI_ACTION_SUMMON_CREATURE: // 12 -> any target - $this->jsGlobals[Type::NPC][] = $a['param'][0]; - if ($a['param'][2]) - $a['param'][6] = Util::formatTime($a['param'][2], true); - - $footer = $this->summonType($a['param'][1]); - break; - case SAI_ACTION_THREAT_SINGLE_PCT: // 13 -> victim - case SAI_ACTION_THREAT_ALL_PCT: // 14 -> self - case SAI_ACTION_ADD_THREAT: // 123 -> any target - $a['param'][6] = $a['param'][0] - $a['param'][1]; - break; - case SAI_ACTION_SET_UNIT_FLAG: // 18 -> any target - case SAI_ACTION_REMOVE_UNIT_FLAG: // 19 -> any target - $a['param'][6] = $a['param'][1] ? $this->unitFlags2('action', 0) : $this->unitFlags('action', 0); - break; - case SAI_ACTION_FLEE_FOR_ASSIST: // 25 -> none - case SAI_ACTION_CALL_FOR_HELP: // 39 -> self - if ($a['param'][0]) - $footer = true; - break; - case SAI_ACTION_FOLLOW: // 29 -> any target [what the heck are param 4 & 5] - $this->jsGlobals[Type::NPC][] = $a['param'][2]; - if ($a['param'][1]) - $a['param'][6] = Util::O2Deg($a['param'][1])[0]; - if ($a['param'][3] || $a['param'][4]) - $a['param'][7] = 1; - - if ($a['param'][6] || $a['param'][7]) - $footer = true; - - break; - case SAI_ACTION_RANDOM_PHASE: // 30 -> self - $buff = []; - for ($i = 0; $i < 7; $i++) - if ($_ = $a['param'][$i]) - $buff[] = $_; - - $a['param'][6] = Lang::concat($buff); - break; - case SAI_ACTION_SET_SHEATH: // 40 -> self - if ($sheath = Lang::smartAI('sheaths', $a['param'][0])) - $a['param'][6] = $sheath; - else - $a['param'][6] = lang::smartAI('sheathUNK', $a['param'][0]); - - break; - case SAI_ACTION_FORCE_DESPAWN: // 41 -> any target - $a['param'][6] = Util::formatTime($a['param'][0], true); - $a['param'][7] = Util::formatTime($a['param'][1] * 1000, true); - break; - case SAI_ACTION_MOUNT_TO_ENTRY_OR_MODEL: // 43 -> self - if ($a['param'][0]) - $this->jsGlobals[Type::NPC][] = $a['param'][0]; - else if (!$a['param'][1]) - $a['param'][6] = 1; - break; - case SAI_ACTION_SET_INGAME_PHASE_MASK: // 44 -> any target - $a['param'][6] = $a['param'][0] ? Lang::concat(Util::mask2bits($a['param'][0])) : 0; - break; - case SAI_ACTION_SUMMON_GO: // 50 -> self, world coords - $this->jsGlobals[Type::OBJECT][] = $a['param'][0]; - $a['param'][6] = Util::formatTime($a['param'][1] * 1000, true); - - if (!$a['param'][2]) - $footer = true; - - break; - case SAI_ACTION_ACTIVATE_TAXI: // 52 -> invoker - $nodes = DB::Aowow()->selectRow(' - SELECT tn1.name_loc0 AS start_loc0, tn1.name_loc?d AS start_loc?d, tn2.name_loc0 AS end_loc0, tn2.name_loc?d AS end_loc?d - FROM ?_taxipath tp - JOIN ?_taxinodes tn1 ON tp.startNodeId = tn1.id - JOIN ?_taxinodes tn2 ON tp.endNodeId = tn2.id - WHERE tp.id = ?d', - User::$localeId, User::$localeId, User::$localeId, User::$localeId, $a['param'][0] - ); - $a['param'][6] = Util::localizedString($nodes, 'start'); - $a['param'][7] = Util::localizedString($nodes, 'end'); - break; - case SAI_ACTION_WP_START: // 53 -> any .. why tho? - $a['param'][7] = $this->reactState($a['param'][5]); - if ($a['param'][3]) - $this->jsGlobals[Type::QUEST][] = $a['param'][3]; - if ($a['param'][4]) - $a['param'][6] = Util::formatTime($a['param'][4], true); - if ($a['param'][2]) - $footer = true; - - break; - case SAI_ACTION_WP_PAUSE: // 54 -> self - $a['param'][6] = Util::formatTime($a['param'][0], true); - break; - case SAI_ACTION_WP_STOP: // 55 -> self - if ($a['param'][0]) - $a['param'][6] = Util::formatTime($a['param'][0], true); - - if ($a['param'][1]) - { - $this->jsGlobals[Type::QUEST][] = $a['param'][1]; - $a['param'][$a['param'][2] ? 7 : 8] = 1; - } - - break; - case SAI_ACTION_INSTALL_AI_TEMPLATE: // 58 -> self - $a['param'][6] = $this->aiTemplate($a['param'][0]); - break; - case SAI_ACTION_TELEPORT: // 62 -> invoker [resolved coords already stored in areatrigger entry] - $a['param'][6] = $this->miscData['teleportA']; - $this->jsGlobals[Type::ZONE][] = $a['param'][6]; - break; - case SAI_ACTION_SET_ORIENTATION: // 66 -> any target - if ($this->itr['target']['type'] == SAI_TARGET_POSITION) - $a['param'][6] = Util::O2Deg($this->itr['target']['pos'][3])[1]; - else if ($this->itr['target']['type'] != SAI_TARGET_SELF) - $a['param'][6] = '#target#'; - break; - case SAI_ACTION_CREATE_TIMED_EVENT: // 67 -> self - $a['param'][6] = $this->numRange('action', 1, true); - $a['param'][7] = ($a['param'][5] < 100); - if ($repeat = $this->numRange('action', 3, true)) - $footer = [$repeat]; - break; - case SAI_ACTION_MOVE_TO_POS: // 69 -> any target - if ($a['param'][2]) - $footer = true; - break; - case SAI_ACTION_ENABLE_TEMP_GOBJ: // 70 -> any target - case SAI_ACTION_SET_CORPSE_DELAY: // 116 -> ??? - case SAI_ACTION_FLEE: // 122 -> any target - $a['param'][6] = Util::formatTime($a['param'][0] * 1000, true); - break; - case SAI_ACTION_EQUIP: // 71 -> any - $buff = []; - if ($a['param'][0]) - { - $slots = [1, 2, 3]; - if ($a['param'][1]) - $slots = Util::mask2bits($a['param'][1], 1); - - $items = DB::World()->selectRow('SELECT ItemID1, ItemID2, ItemID3 FROM creature_equip_template WHERE CreatureID = ?d AND ID = ?d', $this->miscData['baseEntry'] ?: $this->entry, $a['param'][0]); - foreach ($items as $i) - $this->jsGlobals[Type::ITEM][] = $i; - - foreach ($slots as $s) - if ($_ = $items['ItemID'.$s]) - $buff[] = '[item='.$_.']'; - } - else if ($a['param'][2] || $a['param'][3] || $a['param'][4]) - { - if ($_ = $a['param'][2]) - { - $this->jsGlobals[Type::ITEM][] = $_; - $buff[] = '[item='.$_.']'; - } - if ($_ = $a['param'][3]) - { - $this->jsGlobals[Type::ITEM][] = $_; - $buff[] = '[item='.$_.']'; - } - if ($_ = $a['param'][4]) - { - $this->jsGlobals[Type::ITEM][] = $_; - $buff[] = '[item='.$_.']'; - } - } - else - $a['param'][7] = 1; - - $a['param'][6] = Lang::concat($buff); - - $footer = true; - - break; - case SAI_ACTION_CALL_TIMED_ACTIONLIST: // 80 -> any target - switch ($a['param'][1]) - { - case 0: - case 1: - case 2: - $a['param'][6] = Lang::smartAI('saiUpdate', $a['param'][1]); - break; - default: - $a['param'][6] = Lang::smartAI('saiUpdateUNK', [$a['param'][1]]); - } - - $tal = new SmartAI(SAI_SRC_TYPE_ACTIONLIST, $a['param'][0], array_merge(['baseEntry' => $this->entry], $this->miscData)); - $tal->prepare(); - foreach ($tal->getJSGlobals() as $type => $data) - { - if (empty($this->jsGlobals[$type])) - $this->jsGlobals[$type] = []; - - $this->jsGlobals[$type] = array_merge($this->jsGlobals[$type], $data); - } - - foreach ($tal->getTabs() as $guid => $tt) - $this->tabs[$guid] = $tt; - - break; - case SAI_ACTION_SIMPLE_TALK: // 84 -> any target - $noSrc = false; - if ($src = $this->getTalkSource($noSrc)) - { - if (isset($this->quotes[$src][$a['param'][0]])) - { - $quotes = $this->quotes[$src][$a['param'][0]]; - foreach ($quotes as $quote) - $a['param'][6] .= sprintf($quote['text'], $noSrc ? '' : sprintf($quote['prefix'], $this->quotes[$src]['src']), $this->quotes[$src]['src']); - } - } - else - trigger_error('SmartAI::action - could not determine talk source for action #'.$a['type']); - - break; - case SAI_ACTION_CROSS_CAST: // 86 -> entity by TargetingBlock(param3, param4, param5, param6) cross cast spell at any target - $a['param'][6] = $this->target(array( - 'type' => $a['param'][2], - 'param' => [$a['param'][3], $a['param'][4], $a['param'][5], 0], - 'pos' => [0, 0, 0, 0] - )); - // do not break; - case SAI_ACTION_SELF_CAST: // 85 -> self - case SAI_ACTION_INVOKER_CAST: // 134 -> any target - $this->jsGlobals[Type::SPELL][] = $a['param'][0]; - if ($_ = $this->castFlags('action', 1)) - $footer = $_; - break; - case SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST: // 87 -> self - $talBuff = []; - for ($i = 0; $i < 6; $i++) - { - if (!$a['param'][$i]) - continue; - - $talBuff[] = '#'.$a['param'][$i].''; - - $tal = new SmartAI(SAI_SRC_TYPE_ACTIONLIST, $a['param'][$i], array_merge(['baseEntry' => $this->entry], $this->miscData)); - $tal->prepare(); - foreach ($tal->getJSGlobals() as $type => $data) - { - if (empty($this->jsGlobals[$type])) - $this->jsGlobals[$type] = []; - - $this->jsGlobals[$type] = array_merge($this->jsGlobals[$type], $data); - } - - foreach ($tal->getTabs() as $guid => $tt) - $this->tabs[$guid] = $tt; - } - $a['param'][6] = Lang::concat($talBuff, false); - break; - case SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST:// 88 -> self - $talBuff = []; - for ($i = $a['param'][0]; $i <= $a['param'][1]; $i++) - { - $talBuff[] = '#'.$i.''; - - $tal = new SmartAI(SAI_SRC_TYPE_ACTIONLIST, $i, array_merge(['baseEntry' => $this->entry], $this->miscData)); - $tal->prepare(); - foreach ($tal->getJSGlobals() as $type => $data) - { - if (empty($this->jsGlobals[$type])) - $this->jsGlobals[$type] = []; - - $this->jsGlobals[$type] = array_merge($this->jsGlobals[$type], $data); - } - - foreach ($tal->getTabs() as $guid => $tt) - $this->tabs[$guid] = $tt; - } - $a['param'][6] = Lang::concat($talBuff, false); - - break; - case SAI_ACTION_INTERRUPT_SPELL: // 92 -> self - if ($_ = $a['param'][1]) - $this->jsGlobals[Type::SPELL][] = $a['param'][1]; - - if ($a['param'][0] || $a['param'][2]) - $footer = [$a['param'][0]]; - - break; - case SAI_ACTION_SET_HOME_POS: // 101 -> self - if ($this->itr['target']['type'] == SAI_TARGET_SELF) - $a['param'][9] = 1; - // do not break; - case SAI_ACTION_JUMP_TO_POS: // 97 -> self - case SAI_ACTION_MOVE_OFFSET: // 114 -> self - $a['param'][6] = $this->itr['target']['pos'][0]; - $a['param'][7] = $this->itr['target']['pos'][1]; - $a['param'][8] = $this->itr['target']['pos'][2]; - break; - case SAI_ACTION_GO_SET_LOOT_STATE: // 99 -> any target - switch ($a['param'][0]) - { - case 0: - case 1: - case 2: - case 3: - $a['param'][6] = Lang::smartAI('lootStates', $a['param'][0]); - break; - default: - $a['param'][6] = Lang::smartAI('lootStateUNK', [$a['param'][0]]); - } - break; - - break; - case SAI_ACTION_SUMMON_CREATURE_GROUP: // 107 -> untargeted - if ($this->summons === null) - $this->summons = DB::World()->selectCol('SELECT groupId AS ARRAY_KEY, entry AS ARRAY_KEY2, COUNT(*) AS n FROM creature_summon_groups WHERE summonerId = ?d GROUP BY groupId, entry', empty($this->miscData['baseEntry']) ? $this->entry : $this->miscData['baseEntry']); - - $buff = []; - if (!empty($this->summons[$a['param'][0]])) - { - foreach ($this->summons[$a['param'][0]] as $id => $n) - { - $this->jsGlobals[Type::NPC][] = $id; - $buff[] = $n.'x [npc='.$id.']'; - } - } - - if ($buff) - $a['param'][6] = Lang::concat($buff); - - break; - case SAI_ACTION_START_CLOSEST_WAYPOINT: // 113 -> any target - $buff = []; - for ($i = 0; $i < 6; $i++) - if ($a['param'][$i]) - $buff[] = '#[b]'.$a['param'][$i].'[/b]'; - - $a['param'][6] = Lang::concat($buff, false); - break; - case SAI_ACTION_RANDOM_SOUND: // 115 -> self - for ($i = 0; $i < 4; $i++) - { - if ($x = $a['param'][$i]) - { - $this->jsGlobals[Type::SOUND][] = $x; - $a['param'][6] .= '[sound='.$x.']'; - } - } - - if ($a['param'][5]) - $footer = true; - - break; - case SAI_ACTION_GO_SET_GO_STATE: // 118 -> ??? - switch ($a['param'][0]) - { - case 0: - case 1: - case 2: - $a['param'][6] = Lang::smartAI('GOStates', $a['param'][0]); - break; - default: - $a['param'][6] = Lang::smartAI('GOStateUNK', [$a['param'][0]]); - } - break; - case SAI_ACTION_REMOVE_AURAS_BY_TYPE: // 120 -> any target - $a['param'][6] = Lang::spell('auras', $a['param'][0]); - break; - case SAI_ACTION_LOAD_EQUIPMENT: // 124 -> any target - $buff = []; - if ($a['param'][0]) - { - $items = DB::World()->selectRow('SELECT ItemID1, ItemID2, ItemID3 FROM creature_equip_template WHERE CreatureID = ?d AND ID = ?d', $this->miscData['baseEntry'] ?: $this->entry, $a['param'][0]); - foreach ($items as $i) - { - if (!$i) - continue; - - $this->jsGlobals[Type::ITEM][] = $i; - $buff[] = '[item='.$i.']'; - } - } - else if (!$a['param'][1]) - trigger_error('SmartAI::action - action #124 (SAI_ACTION_LOAD_EQIPMENT) is malformed'); - - $a['param'][6] = Lang::concat($buff); - $footer = true; - - break; - case SAI_ACTION_TRIGGER_RANDOM_TIMED_EVENT: // 125 -> self - $a['param'][6] = $this->numRange('action', 0); - break; - case SAI_ACTION_SPAWN_SPAWNGROUP: // 131 - case SAI_ACTION_DESPAWN_SPAWNGROUP: // 132 - $a['param'][6] = DB::World()->selectCell('SELECT `GroupName` FROM spawn_group_template WHERE `groupId` = ?d', $a['param'][0]); - $entities = DB::World()->select('SELECT `spawnType` AS "0", `spawnId` AS "1" FROM spawn_group WHERE `groupId` = ?d', $a['param'][0]); - - $n = 5; - foreach ($entities as [$spawnType, $guid]) - { - $type = Type::NPC; - if ($spawnType == 1) - $type == Type::GAMEOBJECT; - - $a['param'][7] = $this->spawnFlags('action', 3); - - if ($_ = DB::Aowow()->selectCell('SELECT `typeId` FROM ?_spawns WHERE `type` = ?d AND `guid` = ?d', $type, $guid)) - { - $this->jsGlobals[$type][] = $_; - $a['param'][8] .= '[li]['.Type::getFileString($type).'='.$_.'][small class=q0] (GUID: '.$guid.')[/small][/li]'; - } - else - $a['param'][8] .= '[li]'.Lang::smartAI('entityUNK').'[small class=q0] (GUID: '.$guid.')[/small][/li]'; - - if (!--$n) - break; - } - - if (count($entities) > 5) - $a['param'][8] .= '[li]+'.(count($entities) - 5).'…[/li]'; - - $a['param'][8] = '[ul]'.$a['param'][8].'[/ul]'; - - if ($time = $this->numRange('action', 1, true)) - $footer = [$time]; - break; - case SAI_ACTION_RESPAWN_BY_SPAWNID: // 133 - $type = Type::NPC; - if ($a['param'][0] == 1) - $type == Type::GAMEOBJECT; - - if ($_ = DB::Aowow()->selectCell('SELECT `typeId` FROM ?_spawns WHERE `type` = ?d AND `guid` = ?d', $type, $a['param'][1])) - $a['param'][6] = '['.Type::getFileString($type).'='.$_.']'; - else - $a['param'][6] = Lang::smartAI('entityUNK'); - break; - case SAI_ACTION_SET_MOVEMENT_SPEED: // 136 - $a['param'][6] = $a['param'][1] + $a['param'][2] / pow(10, floor(log10($a['param'][2] ?: 1.0) + 1)); // i know string concatenation is a thing. don't @ me! - break; - case SAI_ACTION_OVERRIDE_LIGHT: // 138 - $this->jsGlobals[Type::ZONE][] = $a['param'][0]; - $footer = [Util::formatTime($a['param'][2], true)]; - break; - case SAI_ACTION_OVERRIDE_WEATHER: // 139 - $this->jsGlobals[Type::ZONE][] = $a['param'][0]; - if (!($a['param'][6] = Lang::smartAI('weatherStates', $a['param'][1]))) - $a['param'][6] = Lang::smartAI('weatherStateUNK', [$a['param'][1]]); - break; - default: - $body = Lang::smartAI('actionUNK', [$a['type']]); - } - - $body = $body ?: Lang::smartAI('actions', $a['type'], 0, $a['param']); - if (gettype($footer) != 'string') - $footer = Lang::smartAI('actions', $a['type'], 1, (array)$footer); - - // resolve conditionals - $footer = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):([^;]*);/i', function ($m) { return $m[1] ? $m[2] : $m[3]; }, $footer); - $body = preg_replace_callback('/\(([^\)]*?)\)\?([^:]*):([^;]*);/i', function ($m) { return $m[1] ? $m[2] : $m[3]; }, $body); - $body = str_replace('#target#', $this->target(), $body); - - // wrap body in tooltip - return [sprintf($tooltip, $body), $footer]; - } -} - -?> diff --git a/includes/type.class.php b/includes/type.class.php new file mode 100644 index 00000000..6676009b --- /dev/null +++ b/includes/type.class.php @@ -0,0 +1,260 @@ + [CreatureList::class, 'npc', 'g_npcs', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::OBJECT => [GameObjectList::class, 'object', 'g_objects', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::ITEM => [ItemList::class, 'item', 'g_items', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::ITEMSET => [ItemsetList::class, 'itemset', 'g_itemsets', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::QUEST => [QuestList::class, 'quest', 'g_quests', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::SPELL => [SpellList::class, 'spell', 'g_spells', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::ZONE => [ZoneList::class, 'zone', 'g_gatheredzones', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE], + self::FACTION => [FactionList::class, 'faction', 'g_factions', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE], + self::PET => [PetList::class, 'pet', 'g_pets', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::ACHIEVEMENT => [AchievementList::class, 'achievement', 'g_achievements', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::TITLE => [TitleList::class, 'title', 'g_titles', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE], + self::WORLDEVENT => [WorldEventList::class, 'event', 'g_holidays', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::CHR_CLASS => [CharClassList::class, 'class', 'g_classes', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE], + self::CHR_RACE => [CharRaceList::class, 'race', 'g_races', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE], + self::SKILL => [SkillList::class, 'skill', 'g_skills', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::STATISTIC => [AchievementList::class, 'achievement', 'g_achievements', self::FLAG_NONE], // alias for achievements; exists only for Markup + self::CURRENCY => [CurrencyList::class, 'currency', 'g_gatheredcurrencies', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::SOUND => [SoundList::class, 'sound', 'g_sounds', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::ICON => [IconList::class, 'icon', 'g_icons', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE | self::FLAG_HAS_ICON], + self::GUIDE => [GuideList::class, 'guide', '', self::FLAG_DB_TYPE], + self::PROFILE => [ProfileList::class, 'profile', '', self::FLAG_FILTRABLE], // x - not known in javascript + self::GUILD => [GuildList::class, 'guild', '', self::FLAG_FILTRABLE], // x + self::ARENA_TEAM => [ArenaTeamList::class, 'arena-team', '', self::FLAG_FILTRABLE], // x + self::USER => [UserList::class, 'user', 'g_users', self::FLAG_NONE], // x + self::EMOTE => [EmoteList::class, 'emote', 'g_emotes', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE], + self::ENCHANTMENT => [EnchantmentList::class, 'enchantment', 'g_enchantments', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::AREATRIGGER => [AreatriggerList::class, 'areatrigger', '', self::FLAG_FILTRABLE | self::FLAG_DB_TYPE], + self::MAIL => [MailList::class, 'mail', '', self::FLAG_RANDOM_SEARCHABLE | self::FLAG_DB_TYPE] + ); + + + /********************/ + /* Field Operations */ + /********************/ + + public static function newList(int $type, array $conditions = []) : ?DBTypeList + { + if (!self::exists($type)) + return null; + + return new (self::$data[$type][self::IDX_LIST_OBJ])($conditions); + } + + public static function newFilter(string $fileStr, array|string $data, array $opts = []) : ?Filter + { + $x = self::getFileStringsFor(self::FLAG_FILTRABLE); + if ($type = array_search($fileStr, $x)) + return new (self::$data[$type][self::IDX_LIST_OBJ].'Filter')($data, $opts); + + return null; + } + + public static function validateIds(int $type, int|array $ids) : array + { + if (!self::exists($type)) + return []; + + if (!(self::$data[$type][self::IDX_FLAGS] & self::FLAG_DB_TYPE)) + return []; + + return DB::Aowow()->selectCol('SELECT `id` FROM %n WHERE `id` IN %in', self::$data[$type][self::IDX_LIST_OBJ]::$dataTable, (array)$ids); + } + + public static function hasIcon(int $type) : bool + { + return self::exists($type) && self::$data[$type][self::IDX_FLAGS] & self::FLAG_HAS_ICON; + } + + public static function isRandomSearchable(int $type) : bool + { + return self::exists($type) && self::$data[$type][self::IDX_FLAGS] & self::FLAG_RANDOM_SEARCHABLE; + } + + public static function getFileString(int $type) : string + { + if (!self::exists($type)) + return ''; + + return self::$data[$type][self::IDX_FILE_STR]; + } + + public static function getJSGlobalString(int $type) : string + { + if (!self::exists($type)) + return ''; + + return self::$data[$type][self::IDX_JSG_TPL]; + } + + public static function getJSGlobalTemplate(int $type) : array + { + if (!self::exists($type) || !self::$data[$type][self::IDX_JSG_TPL]) + return []; + + // [key, [data], [extraData]] + return [self::$data[$type][self::IDX_JSG_TPL], [], []]; + } + + public static function checkClassAttrib(int $type, string $attr, ?int $attrVal = null) : bool + { + if (!self::exists($type)) + return false; + + return isset((self::$data[$type][self::IDX_LIST_OBJ])::$$attr) && ($attrVal === null || ((self::$data[$type][self::IDX_LIST_OBJ])::$$attr & $attrVal)); + } + + public static function getClassAttrib(int $type, string $attr) : mixed + { + if (!self::exists($type)) + return null; + + return (self::$data[$type][self::IDX_LIST_OBJ])::$$attr ?? null; + } + + public static function exists(int $type) : ?int + { + return !empty(self::$data[$type]) ? $type : null; + } + + public static function getIndexFrom(int $idx, string $match) : int + { + $i = array_search($match, array_column(self::$data, $idx)); + if ($i === false) + return 0; + + return array_keys(self::$data)[$i]; + } + + + /*********************/ + /* Column Operations */ + /*********************/ + + public static function getClassesFor(int $flags = 0x0, string $attr = '', ?int $attrVal = null) : array + { + $x = []; + foreach (self::$data as $k => [$o, , , $f]) + if ($o && (!$flags || $flags & $f)) + if (!$attr || self::checkClassAttrib($k, $attr, $attrVal)) + $x[$k] = $o; + + return $x; + } + + public static function getFileStringsFor(int $flags = 0x0) : array + { + $x = []; + foreach (self::$data as $k => [, $s, , $f]) + if ($s && (!$flags || $flags & $f)) + $x[$k] = $s; + + return $x; + } + + public static function getJSGTemplatesFor(int $flags = 0x0) : array + { + $x = []; + foreach (self::$data as $k => [, , $a, $f]) + if ($a && (!$flags || $flags & $f)) + $x[$k] = $a; + + return $x; + } +} + +?> diff --git a/includes/types/areatrigger.class.php b/includes/types/areatrigger.class.php deleted file mode 100644 index 80c5bee2..00000000 --- a/includes/types/areatrigger.class.php +++ /dev/null @@ -1,102 +0,0 @@ - [['s']], - 's' => ['j' => ['?_spawns s ON s.type = 503 AND s.typeId = a.id', true], 's' => ', s.areaId'] - ); - - public function __construct($conditions) - { - parent::__construct($conditions); - - foreach ($this->iterate() as $id => &$_curTpl) - if (!$_curTpl['name']) - $_curTpl['name'] = 'Unnamed Areatrigger #' . $id; - } - - public function getListviewData() : array - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->curTpl['id'], - 'type' => $this->curTpl['type'], - 'name' => $this->curTpl['name'], - ); - - if ($_ = $this->curTpl['areaId']) - $data[$this->id]['location'] = [$_]; - } - - return $data; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) - { - return []; - } - - public function renderTooltip() { } -} - -class AreaTriggerListFilter extends Filter -{ - protected $genericFilter = array( - 2 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT] // id - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_LIST, [2], true ], // criteria ids - 'crs' => [FILTER_V_RANGE, [1, 6], true ], // criteria operators - 'crv' => [FILTER_V_RANGE, [0, 99999], true ], // criteria values - all criteria are numeric here - 'na' => [FILTER_V_REGEX, '/[\p{C};\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'ty' => [FILTER_V_RANGE, [0, 5], true ] // types - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = &$this->fiData['v']; - - // name [str] - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name'])) - $parts[] = $_; - - // type [list] - if (isset($_v['ty'])) - $parts[] = ['type', $_v['ty']]; - - return $parts; - } -} - -?> diff --git a/includes/types/arenateam.class.php b/includes/types/arenateam.class.php deleted file mode 100644 index a2e8bcc2..00000000 --- a/includes/types/arenateam.class.php +++ /dev/null @@ -1,346 +0,0 @@ -iterate() as $__) - { - $data[$this->id] = array( - 'name' => $this->curTpl['name'], - 'realm' => Profiler::urlize($this->curTpl['realmName'], true), - 'realmname' => $this->curTpl['realmName'], - // 'battlegroup' => Profiler::urlize($this->curTpl['battlegroup']), // was renamed to subregion somewhere around cata release - // 'battlegroupname' => $this->curTpl['battlegroup'], - 'region' => Profiler::urlize($this->curTpl['region']), - 'faction' => $this->curTpl['faction'], - 'size' => $this->curTpl['type'], - 'rank' => $this->curTpl['rank'], - 'wins' => $this->curTpl['seasonWins'], - 'games' => $this->curTpl['seasonGames'], - 'rating' => $this->curTpl['rating'], - 'members' => $this->curTpl['members'] - ); - } - - return array_values($data); - } - - public function renderTooltip() {} - public function getJSGlobals($addMask = 0) {} -} - - -class ArenaTeamListFilter extends Filter -{ - public $extraOpts = []; - protected $genericFilter = []; - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'ex' => [FILTER_V_EQUAL, 'on', false], // only match exact - 'si' => [FILTER_V_LIST, [1, 2], false], // side - 'sz' => [FILTER_V_LIST, [2, 3, 5], false], // tema size - 'rg' => [FILTER_V_CALLBACK, 'cbRegionCheck', false], // region - 'sv' => [FILTER_V_CALLBACK, 'cbServerCheck', false], // server - ); - - protected function createSQLForCriterium(&$cr) { } - - protected function createSQLForValues() - { - $parts = []; - $_v = $this->fiData['v']; - - // region (rg), battlegroup (bg) and server (sv) are passed to ArenaTeamList as miscData and handled there - - // name [str] - if (!empty($_v['na'])) - if ($_ = $this->modularizeString(['at.name'], $_v['na'], !empty($_v['ex']) && $_v['ex'] == 'on')) - $parts[] = $_; - - // side [list] - if (!empty($_v['si'])) - { - if ($_v['si'] == 1) - $parts[] = ['c.race', [1, 3, 4, 7, 11]]; - else if ($_v['si'] == 2) - $parts[] = ['c.race', [2, 5, 6, 8, 10]]; - } - - // size [int] - if (!empty($_v['sz'])) - $parts[] = ['at.type', $_v['sz']]; - - return $parts; - } - - protected function cbRegionCheck(&$v) - { - if (in_array($v, Util::$regions)) - { - $this->parentCats[0] = $v; // directly redirect onto this region - $v = ''; // remove from filter - - return true; - } - - return false; - } - - protected function cbServerCheck(&$v) - { - foreach (Profiler::getRealms() as $realm) - if ($realm['name'] == $v) - { - $this->parentCats[1] = Profiler::urlize($v);// directly redirect onto this server - $v = ''; // remove from filter - - return true; - } - - return false; - } -} - - -class RemoteArenaTeamList extends ArenaTeamList -{ - protected $queryBase = 'SELECT `at`.*, `at`.`arenaTeamId` AS ARRAY_KEY FROM arena_team at'; - protected $queryOpts = array( - 'at' => [['atm', 'c'], 'g' => 'ARRAY_KEY', 'o' => 'rating DESC'], - 'atm' => ['j' => 'arena_team_member atm ON atm.arenaTeamId = at.arenaTeamId'], - 'c' => ['j' => 'characters c ON c.guid = atm.guid AND c.deleteInfos_Account IS NULL AND c.level <= 80 AND (c.extra_flags & '.Profiler::CHAR_GMFLAGS.') = 0', 's' => ', BIT_OR(IF(c.race IN (1, 3, 4, 7, 11), 1, 2)) - 1 AS faction'] - ); - - private $members = []; - - public function __construct($conditions = [], $miscData = null) - { - // select DB by realm - if (!$this->selectRealms($miscData)) - { - trigger_error('no access to auth-db or table realmlist is empty', E_USER_WARNING); - return; - } - - parent::__construct($conditions, $miscData); - - if ($this->error) - return; - - // ranks in DB are inaccurate. recalculate from rating (fetched as DESC from DB) - foreach ($this->dbNames as $rId => $__) - foreach ([2, 3, 5] as $type) - $this->rankOrder[$rId][$type] = DB::Characters($rId)->selectCol('SELECT arenaTeamId FROM arena_team WHERE `type` = ?d ORDER BY rating DESC', $type); - - reset($this->dbNames); // only use when querying single realm - $realmId = key($this->dbNames); - $realms = Profiler::getRealms(); - $distrib = []; - - // post processing - foreach ($this->iterate() as $guid => &$curTpl) - { - // battlegroup - $curTpl['battlegroup'] = CFG_BATTLEGROUP; - - // realm, rank - $r = explode(':', $guid); - if (!empty($realms[$r[0]])) - { - $curTpl['realm'] = $r[0]; - $curTpl['realmName'] = $realms[$r[0]]['name']; - $curTpl['region'] = $realms[$r[0]]['region']; - $curTpl['rank'] = array_search($curTpl['arenaTeamId'], $this->rankOrder[$r[0]][$curTpl['type']]) + 1; - } - else - { - trigger_error('arena team "'.$curTpl['name'].'" belongs to nonexistant realm #'.$r, E_USER_WARNING); - unset($this->templates[$guid]); - continue; - } - - // team members - $this->members[$r[0]][$r[1]] = $r[1]; - - // equalize distribution - if (empty($distrib[$curTpl['realm']])) - $distrib[$curTpl['realm']] = 1; - else - $distrib[$curTpl['realm']]++; - } - - // get team members - foreach ($this->members as $realmId => &$teams) - $teams = DB::Characters($realmId)->select(' - SELECT - at.arenaTeamId AS ARRAY_KEY, c.guid AS ARRAY_KEY2, c.name AS "0", c.class AS "1", IF(at.captainguid = c.guid, 1, 0) AS "2" - FROM - arena_team at - JOIN - arena_team_member atm ON atm.arenaTeamId = at.arenaTeamId JOIN characters c ON c.guid = atm.guid - WHERE - at.arenaTeamId IN (?a) AND - c.deleteInfos_Account IS NULL AND - c.level <= ?d AND - (c.extra_flags & ?d) = 0', - $teams, - MAX_LEVEL, - Profiler::CHAR_GMFLAGS - ); - - // equalize subject distribution across realms - $limit = CFG_SQL_LIMIT_DEFAULT; - foreach ($conditions as $c) - if (is_int($c)) - $limit = $c; - - $total = array_sum($distrib); - foreach ($distrib as &$d) - $d = ceil($limit * $d / $total); - - foreach ($this->iterate() as $guid => &$curTpl) - { - if ($limit <= 0 || $distrib[$curTpl['realm']] <= 0) - { - unset($this->templates[$guid]); - continue; - } - - $r = explode(':', $guid); - if (isset($this->members[$r[0]][$r[1]])) - $curTpl['members'] = array_values($this->members[$r[0]][$r[1]]); // [name, classId, isCaptain] - - $distrib[$curTpl['realm']]--; - $limit--; - } - } - - public function initializeLocalEntries() - { - $profiles = []; - // init members for tooltips - foreach ($this->members as $realmId => $teams) - { - $gladiators = []; - foreach ($teams as $team) - $gladiators = array_merge($gladiators, array_keys($team)); - - $profiles[$realmId] = new RemoteProfileList(array(['c.guid', $gladiators], CFG_SQL_LIMIT_NONE), ['sv' => $realmId]); - - if (!$profiles[$realmId]->error) - $profiles[$realmId]->initializeLocalEntries(); - } - - $data = []; - foreach ($this->iterate() as $guid => $__) - { - $data[$guid] = array( - 'realm' => $this->getField('realm'), - 'realmGUID' => $this->getField('arenaTeamId'), - 'name' => $this->getField('name'), - 'nameUrl' => Profiler::urlize($this->getField('name')), - 'type' => $this->getField('type'), - 'rating' => $this->getField('rating'), - 'cuFlags' => PROFILER_CU_NEEDS_RESYNC - ); - } - - // basic arena team data - foreach (Util::createSqlBatchInsert($data) as $ins) - DB::Aowow()->query('INSERT IGNORE INTO ?_profiler_arena_team (?#) VALUES '.$ins, array_keys(reset($data))); - - // merge back local ids - $localIds = DB::Aowow()->selectCol( - 'SELECT CONCAT(realm, ":", realmGUID) AS ARRAY_KEY, id FROM ?_profiler_arena_team WHERE realm IN (?a) AND realmGUID IN (?a)', - array_column($data, 'realm'), - array_column($data, 'realmGUID') - ); - - foreach ($this->iterate() as $guid => &$_curTpl) - if (isset($localIds[$guid])) - $_curTpl['id'] = $localIds[$guid]; - - - // profiler_arena_team_member requires profiles and arena teams to be filled - foreach ($this->members as $realmId => $teams) - { - if (empty($profiles[$realmId])) - continue; - - $memberData = []; - foreach ($teams as $teamId => $team) - foreach ($team as $memberId => $member) - $memberData[] = array( - 'arenaTeamId' => $localIds[$realmId.':'.$teamId], - 'profileId' => $profiles[$realmId]->getEntry($realmId.':'.$memberId)['id'], - 'captain' => $member[2] - ); - - foreach (Util::createSqlBatchInsert($memberData) as $ins) - DB::Aowow()->query('INSERT IGNORE INTO ?_profiler_arena_team_member (?#) VALUES '.$ins, array_keys(reset($memberData))); - } - } -} - - -class LocalArenaTeamList extends ArenaTeamList -{ - protected $queryBase = 'SELECT at.*, at.id AS ARRAY_KEY FROM ?_profiler_arena_team at'; - - public function __construct($conditions = [], $miscData = null) - { - parent::__construct($conditions, $miscData); - - if ($this->error) - return; - - $realms = Profiler::getRealms(); - - // post processing - $members = DB::Aowow()->selectCol('SELECT *, arenaTeamId AS ARRAY_KEY, profileId AS ARRAY_KEY2 FROM ?_profiler_arena_team_member WHERE arenaTeamId IN (?a)', $this->getFoundIDs()); - - foreach ($this->iterate() as $id => &$curTpl) - { - if ($curTpl['realm'] && !isset($realms[$curTpl['realm']])) - continue; - - if (isset($realms[$curTpl['realm']])) - { - $curTpl['realmName'] = $realms[$curTpl['realm']]['name']; - $curTpl['region'] = $realms[$curTpl['realm']]['region']; - } - - // battlegroup - $curTpl['battlegroup'] = CFG_BATTLEGROUP; - - $curTpl['members'] = $members[$id]; - } - } - - public function getProfileUrl() - { - $url = '?arena-team='; - - return $url.implode('.', array( - Profiler::urlize($this->getField('region')), - Profiler::urlize($this->getField('realmName')), - Profiler::urlize($this->getField('name')) - )); - } -} - - -?> diff --git a/includes/types/creature.class.php b/includes/types/creature.class.php deleted file mode 100644 index 673ed8e1..00000000 --- a/includes/types/creature.class.php +++ /dev/null @@ -1,554 +0,0 @@ - [['ft', 'qse', 'dct1', 'dct2', 'dct3'], 's' => ', IFNULL(dct1.id, IFNULL(dct2.id, IFNULL(dct3.id, 0))) AS parentId, IFNULL(dct1.name_loc0, IFNULL(dct2.name_loc0, IFNULL(dct3.name_loc0, ""))) AS parent_loc0, IFNULL(dct1.name_loc2, IFNULL(dct2.name_loc2, IFNULL(dct3.name_loc2, ""))) AS parent_loc2, IFNULL(dct1.name_loc3, IFNULL(dct2.name_loc3, IFNULL(dct3.name_loc3, ""))) AS parent_loc3, IFNULL(dct1.name_loc4, IFNULL(dct2.name_loc4, IFNULL(dct3.name_loc4, ""))) AS parent_loc4, IFNULL(dct1.name_loc6, IFNULL(dct2.name_loc6, IFNULL(dct3.name_loc6, ""))) AS parent_loc6, IFNULL(dct1.name_loc8, IFNULL(dct2.name_loc8, IFNULL(dct3.name_loc8, ""))) AS parent_loc8, IF(dct1.difficultyEntry1 = ct.id, 1, IF(dct2.difficultyEntry2 = ct.id, 2, IF(dct3.difficultyEntry3 = ct.id, 3, 0))) AS difficultyMode'], - 'dct1' => ['j' => ['?_creature dct1 ON ct.cuFlags & 0x02 AND dct1.difficultyEntry1 = ct.id', true]], - 'dct2' => ['j' => ['?_creature dct2 ON ct.cuFlags & 0x02 AND dct2.difficultyEntry2 = ct.id', true]], - 'dct3' => ['j' => ['?_creature dct3 ON ct.cuFlags & 0x02 AND dct3.difficultyEntry3 = ct.id', true]], - 'ft' => ['j' => '?_factiontemplate ft ON ft.id = ct.faction', 's' => ', ft.A, ft.H, ft.factionId'], - 'qse' => ['j' => ['?_quests_startend qse ON qse.type = 1 AND qse.typeId = ct.id', true], 's' => ', IF(min(qse.method) = 1 OR max(qse.method) = 3, 1, 0) AS startsQuests, IF(min(qse.method) = 2 OR max(qse.method) = 3, 1, 0) AS endsQuests', 'g' => 'ct.id'], - 'qt' => ['j' => '?_quests qt ON qse.questId = qt.id'], - 's' => ['j' => ['?_spawns s ON s.type = 1 AND s.typeId = ct.id', true]] - ); - - public function __construct($conditions = [], $miscData = null) - { - parent::__construct($conditions, $miscData); - - if ($this->error) - return; - - // post processing - foreach ($this->iterate() as $_id => &$curTpl) - { - // check for attackspeeds - if (!$curTpl['atkSpeed']) - $curTpl['atkSpeed'] = 2.0; - else - $curTpl['atkSpeed'] /= 1000; - - if (!$curTpl['rngAtkSpeed']) - $curTpl['rngAtkSpeed'] = 2.0; - else - $curTpl['rngAtkSpeed'] /= 1000; - } - } - - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_creature WHERE id = ?d', $id); - return Util::localizedString($n, 'name'); - } - - public function renderTooltip() - { - if (!$this->curTpl) - return null; - - $level = '??'; - $type = $this->curTpl['type']; - $row3 = [Lang::game('level')]; - $fam = $this->curTpl['family']; - - if (!($this->curTpl['typeFlags'] & 0x4)) - { - $level = $this->curTpl['minLevel']; - if ($level != $this->curTpl['maxLevel']) - $level .= ' - '.$this->curTpl['maxLevel']; - } - else - $level = '??'; - - $row3[] = $level; - - if ($type) - $row3[] = Lang::game('ct', $type); - - if ($_ = Lang::npc('rank', $this->curTpl['rank'])) - $row3[] = '('.$_.')'; - - $x = ''; - $x .= ''; - - if ($sn = $this->getField('subname', true)) - $x .= ''; - - $x .= ''; - - if ($type == 1 && $fam) // 1: Beast - $x .= ''; - - $fac = new FactionList(array([['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0], ['id', (int)$this->getField('factionId')])); - if (!$fac->error) - $x .= ''; - - $x .= '
'.Util::htmlEscape($this->getField('name', true)).'
'.Util::htmlEscape($sn).'
'.implode(' ', $row3).'
'.Lang::game('fa', $fam).'
'.$fac->getField('name', true).'
'; - - return $x; - } - - public function getRandomModelId() - { - // dwarf?? [null, 30754, 30753, 30755, 30736] - // totems use hardcoded models, tauren model is base - $totems = [null, 4589, 4588, 4587, 4590]; // slot => modelId - $data = []; - - for ($i = 1; $i < 5; $i++) - if ($_ = $this->curTpl['displayId'.$i]) - $data[] = $_; - - if (count($data) == 1 && ($slotId = array_search($data[0], $totems))) - $data = DB::World()->selectCol('SELECT DisplayId FROM player_totem_model WHERE TotemSlot = ?d', $slotId); - - return !$data ? 0 : $data[array_rand($data)]; - } - - public function getBaseStats(string $type) : array - { - // i'm aware of the BaseVariance/RangedVariance fields ... i'm just totaly unsure about the whole damage calculation - switch ($type) - { - case 'health': - $hMin = $this->getField('healthMin'); - $hMax = $this->getField('healthMax'); - return [$hMin, $hMax]; - case 'power': - $mMin = $this->getField('manaMin'); - $mMax = $this->getField('manaMax'); - return [$mMin, $mMax]; - case 'armor': - $aMin = $this->getField('armorMin'); - $aMax = $this->getField('armorMax'); - return [$aMin, $aMax]; - case 'melee': - $mleMin = ($this->getField('dmgMin') + ($this->getField('mleAtkPwrMin') / 14)) * $this->getField('dmgMultiplier') * $this->getField('atkSpeed'); - $mleMax = ($this->getField('dmgMax') * 1.5 + ($this->getField('mleAtkPwrMax') / 14)) * $this->getField('dmgMultiplier') * $this->getField('atkSpeed'); - return [$mleMin, $mleMax]; - case 'ranged': - $rngMin = ($this->getField('dmgMin') + ($this->getField('rngAtkPwrMin') / 14)) * $this->getField('dmgMultiplier') * $this->getField('rngAtkSpeed'); - $rngMax = ($this->getField('dmgMax') * 1.5 + ($this->getField('rngAtkPwrMax') / 14)) * $this->getField('dmgMultiplier') * $this->getField('rngAtkSpeed'); - return [$rngMin, $rngMax]; - case 'resistance': - $r = []; - for ($i = SPELL_SCHOOL_HOLY; $i < SPELL_SCHOOL_ARCANE+1; $i++) - $r[$i] = $this->getField('resistance'.$i); - - return $r; - default: - return []; - } - } - - public function isBoss() - { - return ($this->curTpl['cuFlags'] & NPC_CU_INSTANCE_BOSS) || ($this->curTpl['typeFlags'] & 0x4 && $this->curTpl['rank']); - } - - public function getListviewData($addInfoMask = 0x0) - { - /* looks like this data differs per occasion - * - * NPCINFO_TAMEABLE (0x1): include texture & react - * NPCINFO_MODEL (0x2): - * NPCINFO_REP (0x4): include repreward - */ - - $data = []; - $rewRep = []; - - if ($addInfoMask & NPCINFO_REP && $this->getFoundIDs()) - { - $rewRep = DB::World()->selectCol(' - SELECT creature_id AS ARRAY_KEY, RewOnKillRepFaction1 AS ARRAY_KEY2, RewOnKillRepValue1 FROM creature_onkill_reputation WHERE creature_id IN (?a) AND RewOnKillRepFaction1 > 0 UNION - SELECT creature_id AS ARRAY_KEY, RewOnKillRepFaction2 AS ARRAY_KEY2, RewOnKillRepValue2 FROM creature_onkill_reputation WHERE creature_id IN (?a) AND RewOnKillRepFaction2 > 0', - $this->getFoundIDs(), - $this->getFoundIDs() - ); - } - - - foreach ($this->iterate() as $__) - { - if ($addInfoMask & NPCINFO_MODEL) - { - $texStr = strtolower($this->curTpl['textureString']); - - if (isset($data[$texStr])) - { - if ($data[$texStr]['minLevel'] > $this->curTpl['minLevel']) - $data[$texStr]['minLevel'] = $this->curTpl['minLevel']; - - if ($data[$texStr]['maxLevel'] < $this->curTpl['maxLevel']) - $data[$texStr]['maxLevel'] = $this->curTpl['maxLevel']; - - $data[$texStr]['count']++; - } - else - $data[$texStr] = array( - 'family' => $this->curTpl['family'], - 'minLevel' => $this->curTpl['minLevel'], - 'maxLevel' => $this->curTpl['maxLevel'], - 'modelId' => $this->curTpl['modelId'], - 'displayId' => $this->curTpl['displayId1'], - 'skin' => $texStr, - 'count' => 1 - ); - } - else - { - $data[$this->id] = array( - 'family' => $this->curTpl['family'], - 'minlevel' => $this->curTpl['minLevel'], - 'maxlevel' => $this->curTpl['maxLevel'], - 'id' => $this->id, - 'boss' => $this->isBoss() ? 1 : 0, - 'classification' => $this->curTpl['rank'], - 'location' => $this->getSpawns(SPAWNINFO_ZONES), - 'name' => $this->getField('name', true), - 'type' => $this->curTpl['type'], - 'react' => [$this->curTpl['A'], $this->curTpl['H']], - ); - - - if ($this->getField('startsQuests')) - $data[$this->id]['hasQuests'] = 1; - - if ($_ = $this->getField('subname', true)) - $data[$this->id]['tag'] = $_; - - if ($addInfoMask & NPCINFO_TAMEABLE) // only first skin of first model ... we're omitting potentially 11 skins here .. but the lv accepts only one .. w/e - $data[$this->id]['skin'] = $this->curTpl['textureString']; - - if ($addInfoMask & NPCINFO_REP) - { - $data[$this->id]['reprewards'] = []; - if ($rewRep[$this->id]) - foreach ($rewRep[$this->id] as $fac => $val) - $data[$this->id]['reprewards'][] = [$fac, $val]; - } - } - } - - ksort($data); - return $data; - } - - public function getJSGlobals($addMask = 0) - { - $data = []; - - foreach ($this->iterate() as $__) - $data[Type::NPC][$this->id] = ['name' => $this->getField('name', true)]; - - return $data; - } - - public function getSourceData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'n' => $this->getField('parentId') ? $this->getField('parent', true) : $this->getField('name', true), - 't' => Type::NPC, - 'ti' => $this->getField('parentId') ?: $this->id, - // 'bd' => (int)($this->curTpl['cuFlags'] & NPC_CU_INSTANCE_BOSS || ($this->curTpl['typeFlags'] & 0x4 && $this->curTpl['rank'])) - // 'z' where am i spawned - // 'dd' DungeonDifficulty requires 'z' - ); - } - - return $data; - } - - public function addRewardsToJScript(&$refs) { } - - -} - - -class CreatureListFilter extends Filter -{ - public $extraOpts = null; - protected $enums = array( - 3 => array( 469, 1037, 1106, 529, 1012, 87, 21, 910, 609, 942, 909, 530, 69, 577, 930, 1068, 1104, 729, 369, 92, 54, 946, 67, 1052, 749, - 47, 989, 1090, 1098, 978, 1011, 93, 1015, 1038, 76, 470, 349, 1031, 1077, 809, 911, 890, 970, 169, 730, 72, 70, 932, 1156, 933, - 510, 1126, 1067, 1073, 509, 941, 1105, 990, 934, 935, 1094, 1119, 1124, 1064, 967, 1091, 59, 947, 81, 576, 922, 68, 1050, 1085, 889, - 589, 270) - ); - - // cr => [type, field, misc, extraCol] - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 1 => [FILTER_CR_CALLBACK, 'cbHealthMana', 'healthMax', 'healthMin'], // health [num] - 2 => [FILTER_CR_CALLBACK, 'cbHealthMana', 'manaMin', 'manaMax' ], // mana [num] - 3 => [FILTER_CR_CALLBACK, 'cbFaction', null, null ], // faction [enum] - 5 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_REPAIRER ], // canrepair - 6 => [FILTER_CR_ENUM, 's.areaId', null ], // foundin - 7 => [FILTER_CR_CALLBACK, 'cbQuestRelation', 'startsQuests', 0x1 ], // startsquest [enum] - 8 => [FILTER_CR_CALLBACK, 'cbQuestRelation', 'endsQuests', 0x2 ], // endsquest [enum] - 9 => [FILTER_CR_BOOLEAN, 'lootId', ], // lootable - 10 => [FILTER_CR_CALLBACK, 'cbRegularSkinLoot', NPC_TYPEFLAG_SPECIALLOOT ], // skinnable [yn] - 11 => [FILTER_CR_BOOLEAN, 'pickpocketLootId', ], // pickpocketable - 12 => [FILTER_CR_CALLBACK, 'cbMoneyDrop', null, null ], // averagemoneydropped [op] [int] - 15 => [FILTER_CR_CALLBACK, 'cbSpecialSkinLoot', NPC_TYPEFLAG_HERBLOOT, null ], // gatherable [yn] - 16 => [FILTER_CR_CALLBACK, 'cbSpecialSkinLoot', NPC_TYPEFLAG_MININGLOOT, null ], // minable [yn] - 18 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_AUCTIONEER ], // auctioneer - 19 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_BANKER ], // banker - 20 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_BATTLEMASTER ], // battlemaster - 21 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_FLIGHT_MASTER ], // flightmaster - 22 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_GUILD_MASTER ], // guildmaster - 23 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_INNKEEPER ], // innkeeper - 24 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_CLASS_TRAINER ], // talentunlearner - 25 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_GUILD_MASTER ], // tabardvendor - 27 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_STABLE_MASTER ], // stablemaster - 28 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_TRAINER ], // trainer - 29 => [FILTER_CR_FLAG, 'npcflag', NPC_FLAG_VENDOR ], // vendor - 31 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 32 => [FILTER_CR_FLAG, 'cuFlags', NPC_CU_INSTANCE_BOSS ], // instanceboss - 33 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 34 => [FILTER_CR_NYI_PH, 1, null ], // usemodel [str] - displayId -> id:creatureDisplayInfo.dbc/model -> id:cratureModelData.dbc/modelPath - 35 => [FILTER_CR_STRING, 'textureString' ], // useskin - 37 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true ], // id - 38 => [FILTER_CR_CALLBACK, 'cbRelEvent', null, null ], // relatedevent [enum] - 40 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 41 => [FILTER_CR_NYI_PH, 1, null ], // haslocation [yn] [staff] - 42 => [FILTER_CR_CALLBACK, 'cbReputation', '>', null ], // increasesrepwith [enum] - 43 => [FILTER_CR_CALLBACK, 'cbReputation', '<', null ], // decreasesrepwith [enum] - 44 => [FILTER_CR_CALLBACK, 'cbSpecialSkinLoot', NPC_TYPEFLAG_ENGINEERLOOT, null ] // salvageable [yn] - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_LIST, [[1, 3],[5, 12], 15, 16, [18, 25], [27, 29], [31, 35], 37, 38, [40, 44]], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 9999]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/[\p{C}:;%\\\\]/ui', true ], // criteria values - only printable chars, no delimiter - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name / subname - only printable chars, no delimiter - 'ex' => [FILTER_V_EQUAL, 'on', false], // also match subname - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'fa' => [FILTER_V_CALLBACK, 'cbPetFamily', true ], // pet family [list] - cat[0] == 1 - 'minle' => [FILTER_V_RANGE, [1, 99], false], // min level [int] - 'maxle' => [FILTER_V_RANGE, [1, 99], false], // max level [int] - 'cl' => [FILTER_V_RANGE, [0, 4], true ], // classification [list] - 'ra' => [FILTER_V_LIST, [-1, 0, 1], false], // react alliance [int] - 'rh' => [FILTER_V_LIST, [-1, 0, 1], false] // react horde [int] - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = &$this->fiData['v']; - - // name [str] - if (isset($_v['na'])) - { - $_ = []; - if (isset($_v['ex']) && $_v['ex'] == 'on') - $_ = $this->modularizeString(['name_loc'.User::$localeId, 'subname_loc'.User::$localeId]); - else - $_ = $this->modularizeString(['name_loc'.User::$localeId]); - - if ($_) - $parts[] = $_; - } - - // pet family [list] - if (isset($_v['fa'])) - $parts[] = ['family', $_v['fa']]; - - // creatureLevel min [int] - if (isset($_v['minle'])) - $parts[] = ['minLevel', $_v['minle'], '>=']; - - // creatureLevel max [int] - if (isset($_v['maxle'])) - $parts[] = ['maxLevel', $_v['maxle'], '<=']; - - // classification [list] - if (isset($_v['cl'])) - $parts[] = ['rank', $_v['cl']]; - - // react Alliance [int] - if (isset($_v['ra'])) - $parts[] = ['ft.A', $_v['ra']]; - - // react Horde [int] - if (isset($_v['rh'])) - $parts[] = ['ft.H', $_v['rh']]; - - return $parts; - } - - protected function cbPetFamily(&$val) - { - if (!$this->parentCats || $this->parentCats[0] != 1) - return false; - - if (!Util::checkNumeric($val, NUM_REQ_INT)) - return false; - - $type = FILTER_V_LIST; - $valid = [[1, 9], 11, 12, 20, 21, [24, 27], [30, 35], [37, 39], [41, 46]]; - - return $this->checkInput($type, $valid, $val); - } - - protected function cbRelEvent($cr) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT)) - return false; - - if ($cr[1] == FILTER_ENUM_ANY) - { - $eventIds = DB::Aowow()->selectCol('SELECT id FROM ?_events WHERE holidayId <> 0'); - $cGuids = DB::World()->selectCol('SELECT DISTINCT guid FROM game_event_creature WHERE eventEntry IN (?a)', $eventIds); - return ['s.guid', $cGuids]; - } - else if ($cr[1] == FILTER_ENUM_NONE) - { - $eventIds = DB::Aowow()->selectCol('SELECT id FROM ?_events WHERE holidayId <> 0'); - $cGuids = DB::World()->selectCol('SELECT DISTINCT guid FROM game_event_creature WHERE eventEntry IN (?a)', $eventIds); - return ['s.guid', $cGuids, '!']; - } - else if ($cr[1]) - { - $eventIds = DB::Aowow()->selectCol('SELECT id FROM ?_events WHERE holidayId = ?d', $cr[1]); - $cGuids = DB::World()->selectCol('SELECT DISTINCT guid FROM game_event_creature WHERE eventEntry IN (?a)', $eventIds); - return ['s.guid', $cGuids]; - } - - return false; - } - - protected function cbMoneyDrop($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - return ['AND', ['((minGold + maxGold) / 2)', $cr[2], $cr[1]]]; - } - - protected function cbQuestRelation($cr, $field, $val) - { - switch ($cr[1]) - { - case 1: // any - return ['AND', ['qse.method', $val, '&'], ['qse.questId', null, '!']]; - case 2: // alliance - return ['AND', ['qse.method', $val, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', RACE_MASK_HORDE, '&'], 0], ['qt.reqRaceMask', RACE_MASK_ALLIANCE, '&']]; - case 3: // horde - return ['AND', ['qse.method', $val, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', RACE_MASK_ALLIANCE, '&'], 0], ['qt.reqRaceMask', RACE_MASK_HORDE, '&']]; - case 4: // both - return ['AND', ['qse.method', $val, '&'], ['qse.questId', null, '!'], ['OR', ['AND', ['qt.reqRaceMask', RACE_MASK_ALLIANCE, '&'], ['qt.reqRaceMask', RACE_MASK_HORDE, '&']], ['qt.reqRaceMask', 0]]]; - case 5: // none - $this->extraOpts['ct']['h'][] = $field.' = 0'; - return [1]; - } - - return false; - } - - protected function cbHealthMana($cr, $minField, $maxField) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - // remap OP for this special case - switch ($cr[1]) - { - case '=': // min > max is totally possible - $this->extraOpts['ct']['h'][] = $minField.' = '.$maxField.' AND '.$minField.' = '.$cr[2]; - break; - case '>': - case '>=': - case '<': - case '<=': - $this->extraOpts['ct']['h'][] = 'IF('.$minField.' > '.$maxField.', '.$maxField.', '.$minField.') '.$cr[1].' '.$cr[2]; - break; - } - - - return [1]; // always true, use post-filter - } - - protected function cbSpecialSkinLoot($cr, $typeFlag) - { - if (!$this->int2Bool($cr[1])) - return false; - - - if ($cr[1]) - return ['AND', ['skinLootId', 0, '>'], ['typeFlags', $typeFlag, '&']]; - else - return ['OR', ['skinLootId', 0], [['typeFlags', $typeFlag, '&'], 0]]; - } - - protected function cbRegularSkinLoot($cr, $typeFlag) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return ['AND', ['skinLootId', 0, '>'], [['typeFlags', $typeFlag, '&'], 0]]; - else - return ['OR', ['skinLootId', 0], ['typeFlags', $typeFlag, '&']]; - } - - protected function cbReputation($cr, $op) - { - if (!in_array($cr[1], $this->enums[3])) // reuse - return false; - - if ($_ = DB::Aowow()->selectRow('SELECT * FROM ?_factions WHERE id = ?d', $cr[1])) - $this->formData['reputationCols'][] = [$cr[1], Util::localizedString($_, 'name')]; - - if ($cIds = DB::World()->selectCol('SELECT creature_id FROM creature_onkill_reputation WHERE (RewOnKillRepFaction1 = ?d AND RewOnKillRepValue1 '.$op.' 0) OR (RewOnKillRepFaction2 = ?d AND RewOnKillRepValue2 '.$op.' 0)', $cr[1], $cr[1])) - return ['id', $cIds]; - else - return [0]; - } - - protected function cbFaction($cr) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT)) - return false; - - if (!in_array($cr[1], $this->enums[$cr[0]])) - return false; - - - $facTpls = []; - $facs = new FactionList(array('OR', ['parentFactionId', $cr[1]], ['id', $cr[1]])); - foreach ($facs->iterate() as $__) - $facTpls = array_merge($facTpls, $facs->getField('templateIds')); - - return $facTpls ? ['faction', $facTpls] : [0]; - } -} - -?> diff --git a/includes/types/currency.class.php b/includes/types/currency.class.php deleted file mode 100644 index e1a3ba8b..00000000 --- a/includes/types/currency.class.php +++ /dev/null @@ -1,87 +0,0 @@ - [['ic']], - 'ic' => ['j' => ['?_icons ic ON ic.id = c.iconId', true], 's' => ', ic.name AS iconString'] - ); - - public function __construct($conditions = []) - { - parent::__construct($conditions); - - foreach ($this->iterate() as &$_curTpl) - if (!$_curTpl['iconString']) - $_curTpl['iconString'] = 'inv_misc_questionmark'; - } - - - public function getListviewData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'category' => $this->curTpl['category'], - 'name' => $this->getField('name', true), - 'icon' => $this->curTpl['iconString'] - ); - } - - return $data; - } - - public function getJSGlobals($addMask = 0) - { - $data = []; - - foreach ($this->iterate() as $__) - { - // todo (low): find out, why i did this in the first place - if ($this->id == 104) // in case of honor commit sebbuku - $icon = ['inv_bannerpvp_02', 'inv_bannerpvp_01']; // ['alliance', 'horde']; - else if ($this->id == 103) // also arena-icon diffs from item-icon - $icon = ['money_arena', 'money_arena']; - else - $icon = [$this->curTpl['iconString'], $this->curTpl['iconString']]; - - $data[Type::CURRENCY][$this->id] = ['name' => $this->getField('name', true), 'icon' => $icon]; - } - - return $data; - } - - public function renderTooltip() - { - if (!$this->curTpl) - return array(); - - $x = '
'; - $x .= ''.$this->getField('name', true).'
'; - - // cata+ (or go fill it by hand) - if ($_ = $this->getField('description', true)) - $x .= '
'.$_.'
'; - - if ($_ = $this->getField('cap')) - $x .= '
'.Lang::currency('cap').Lang::main('colon').''.Lang::nf($_).'
'; - - $x .= '
'; - - return $x; - } -} - -?> diff --git a/includes/types/emote.class.php b/includes/types/emote.class.php deleted file mode 100644 index 4e1b4a57..00000000 --- a/includes/types/emote.class.php +++ /dev/null @@ -1,58 +0,0 @@ -iterate() as &$curTpl) - { - // remap for generic access - $curTpl['name'] = $curTpl['cmd']; - } - } - - public function getListviewData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->curTpl['id'], - 'name' => $this->curTpl['cmd'], - 'preview' => $this->getField('self', true) ?: ($this->getField('noTarget', true) ?: $this->getField('target', true)) - ); - - // [nyi] sounds - } - - return $data; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) - { - $data = []; - - foreach ($this->iterate() as $__) - $data[Type::EMOTE][$this->id] = ['name' => $this->getField('cmd')]; - - return $data; - } - - public function renderTooltip() { } -} - -?> diff --git a/includes/types/enchantment.class.php b/includes/types/enchantment.class.php deleted file mode 100644 index 31c77ac6..00000000 --- a/includes/types/enchantment.class.php +++ /dev/null @@ -1,347 +0,0 @@ - Type::ENCHANTMENT - 'ie' => [['is']], - 'is' => ['j' => ['?_item_stats `is` ON `is`.`type` = 502 AND `is`.`typeId` = `ie`.`id`', true], 's' => ', `is`.*'], - ); - - public function __construct($conditions = []) - { - parent::__construct($conditions); - - // post processing - foreach ($this->iterate() as &$curTpl) - { - $curTpl['spells'] = []; // [spellId, triggerType, charges, chanceOrPpm] - for ($i = 1; $i <=3; $i++) - { - if ($curTpl['object'.$i] <= 0) - continue; - - switch ($curTpl['type'.$i]) - { - case 1: - $proc = -$this->getField('ppmRate') ?: ($this->getField('procChance') ?: $this->getField('amount'.$i)); - $curTpl['spells'][$i] = [$curTpl['object'.$i], 2, $curTpl['charges'], $proc]; - $this->relSpells[] = $curTpl['object'.$i]; - break; - case 3: - $curTpl['spells'][$i] = [$curTpl['object'.$i], 1, $curTpl['charges'], 0]; - $this->relSpells[] = $curTpl['object'.$i]; - break; - case 7: - $curTpl['spells'][$i] = [$curTpl['object'.$i], 0, $curTpl['charges'], 0]; - $this->relSpells[] = $curTpl['object'.$i]; - break; - } - } - - // floats are fetched as string from db :< - $curTpl['dmg'] = floatVal($curTpl['dmg']); - $curTpl['dps'] = floatVal($curTpl['dps']); - - // remove zero-stats - foreach (Game::$itemMods as $str) - if ($curTpl[$str] == 0) // empty(0.0f) => true .. yeah, sure - unset($curTpl[$str]); - - if ($curTpl['dps'] == 0) - unset($curTpl['dps']); - } - - if ($this->relSpells) - $this->relSpells = new SpellList(array(['id', $this->relSpells])); - } - - // use if you JUST need the name - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_itemenchantment WHERE id = ?d', $id ); - return Util::localizedString($n, 'name'); - } - // end static use - - public function getListviewData($addInfoMask = 0x0) - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'name' => $this->getField('name', true), - 'spells' => [] - ); - - if ($this->curTpl['skillLine'] > 0) - $data[$this->id]['reqskill'] = $this->curTpl['skillLine']; - - if ($this->curTpl['skillLevel'] > 0) - $data[$this->id]['reqskillrank'] = $this->curTpl['skillLevel']; - - if ($this->curTpl['requiredLevel'] > 0) - $data[$this->id]['reqlevel'] = $this->curTpl['requiredLevel']; - - foreach ($this->curTpl['spells'] as $s) - { - // enchant is procing or onUse - if ($s[1] == 2 || $s[1] == 0) - $data[$this->id]['spells'][$s[0]] = $s[2]; - // spell is procing - else if ($this->relSpells && $this->relSpells->getEntry($s[0]) && ($_ = $this->relSpells->canTriggerSpell())) - { - foreach ($_ as $idx) - { - $this->triggerIds[] = $this->relSpells->getField('effect'.$idx.'TriggerSpell'); - $data[$this->id]['spells'][$this->relSpells->getField('effect'.$idx.'TriggerSpell')] = $s[2]; - } - } - } - - if (!$data[$this->id]['spells']) - unset($data[$this->id]['spells']); - - Util::arraySumByKey($data[$this->id], $this->getStatGain()); - } - - return $data; - } - - public function getStatGain($addScalingKeys = false) - { - $data = []; - - foreach (Game::$itemMods as $str) - if (isset($this->curTpl[$str])) - $data[$str] = $this->curTpl[$str]; - - if (isset($this->curTpl['dps'])) - $data['dps'] = $this->curTpl['dps']; - - // scaling enchantments are saved as 0 to item_stats, thus return empty - if ($addScalingKeys) - { - $spellStats = []; - if ($this->relSpells) - $spellStats = $this->relSpells->getStatGain(); - - for ($h = 1; $h <= 3; $h++) - { - $obj = (int)$this->curTpl['object'.$h]; - - switch ($this->curTpl['type'.$h]) - { - case 3: // TYPE_EQUIP_SPELL Spells from ObjectX (use of amountX?) - if (!empty($spellStats[$obj])) - foreach ($spellStats[$obj] as $mod => $_) - if ($str = Game::$itemMods[$mod]) - Util::arraySumByKey($data, [$str => 0]); - - $obj = null; - break; - case 4: // TYPE_RESISTANCE +AmountX resistance for ObjectX School - switch ($obj) - { - case 0: // Physical - $obj = ITEM_MOD_ARMOR; - break; - case 1: // Holy - $obj = ITEM_MOD_HOLY_RESISTANCE; - break; - case 2: // Fire - $obj = ITEM_MOD_FIRE_RESISTANCE; - break; - case 3: // Nature - $obj = ITEM_MOD_NATURE_RESISTANCE; - break; - case 4: // Frost - $obj = ITEM_MOD_FROST_RESISTANCE; - break; - case 5: // Shadow - $obj = ITEM_MOD_SHADOW_RESISTANCE; - break; - case 6: // Arcane - $obj = ITEM_MOD_ARCANE_RESISTANCE; - break; - default: - $obj = null; - } - break; - case 5: // TYPE_STAT +AmountX for Statistic by type of ObjectX - if ($obj < 2) // [mana, health] are on [0, 1] respectively and are expected on [1, 2] .. - $obj++; // 0 is weaponDmg .. ehh .. i messed up somewhere - - break; // stats are directly assigned below - default: // TYPE_NONE dnd stuff; skip assignment below - $obj = null; - } - - if ($obj !== null) - if ($str = Game::$itemMods[$obj]) // check if we use these mods - Util::arraySumByKey($data, [$str => 0]); - } - } - - return $data; - } - - public function getRelSpell($id) - { - if ($this->relSpells) - return $this->relSpells->getEntry($id); - - return null; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) - { - $data = []; - - if ($addMask & GLOBALINFO_SELF) - foreach ($this->iterate() as $__) - $data[Type::ENCHANTMENT][$this->id] = ['name' => $this->getField('name', true)]; - - if ($addMask & GLOBALINFO_RELATED) - { - if ($this->relSpells) - $data = $this->relSpells->getJSGlobals(GLOBALINFO_SELF); - - foreach ($this->triggerIds as $tId) - if (empty($data[Type::SPELL][$tId])) - $data[Type::SPELL][$tId] = $tId; - } - - return $data; - } - - public function renderTooltip() { } -} - - -class EnchantmentListFilter extends Filter -{ - protected $enums = array( - 3 => array( // requiresprof - null, 171, 164, 185, 333, 202, 129, 755, 165, 186, 197, true, false, 356, 182, 773 - ) - ); - - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 2 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true], // id - 3 => [FILTER_CR_ENUM, 'skillLine' ], // requiresprof - 4 => [FILTER_CR_NUMERIC, 'skillLevel', NUM_CAST_INT ], // reqskillrank - 5 => [FILTER_CR_BOOLEAN, 'conditionId' ], // hascondition - 10 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 11 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 12 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 20 => [FILTER_CR_NUMERIC, 'is.str', NUM_CAST_INT, true], // str - 21 => [FILTER_CR_NUMERIC, 'is.agi', NUM_CAST_INT, true], // agi - 22 => [FILTER_CR_NUMERIC, 'is.sta', NUM_CAST_INT, true], // sta - 23 => [FILTER_CR_NUMERIC, 'is.int', NUM_CAST_INT, true], // int - 24 => [FILTER_CR_NUMERIC, 'is.spi', NUM_CAST_INT, true], // spi - 25 => [FILTER_CR_NUMERIC, 'is.arcres', NUM_CAST_INT, true], // arcres - 26 => [FILTER_CR_NUMERIC, 'is.firres', NUM_CAST_INT, true], // firres - 27 => [FILTER_CR_NUMERIC, 'is.natres', NUM_CAST_INT, true], // natres - 28 => [FILTER_CR_NUMERIC, 'is.frores', NUM_CAST_INT, true], // frores - 29 => [FILTER_CR_NUMERIC, 'is.shares', NUM_CAST_INT, true], // shares - 30 => [FILTER_CR_NUMERIC, 'is.holres', NUM_CAST_INT, true], // holres - 32 => [FILTER_CR_NUMERIC, 'is.dps', NUM_CAST_FLOAT, true], // dps - 34 => [FILTER_CR_NUMERIC, 'is.dmg', NUM_CAST_FLOAT, true], // dmg - 37 => [FILTER_CR_NUMERIC, 'is.mleatkpwr', NUM_CAST_INT, true], // mleatkpwr - 38 => [FILTER_CR_NUMERIC, 'is.rgdatkpwr', NUM_CAST_INT, true], // rgdatkpwr - 39 => [FILTER_CR_NUMERIC, 'is.rgdhitrtng', NUM_CAST_INT, true], // rgdhitrtng - 40 => [FILTER_CR_NUMERIC, 'is.rgdcritstrkrtng', NUM_CAST_INT, true], // rgdcritstrkrtng - 41 => [FILTER_CR_NUMERIC, 'is.armor' , NUM_CAST_INT, true], // armor - 42 => [FILTER_CR_NUMERIC, 'is.defrtng', NUM_CAST_INT, true], // defrtng - 43 => [FILTER_CR_NUMERIC, 'is.block', NUM_CAST_INT, true], // block - 44 => [FILTER_CR_NUMERIC, 'is.blockrtng', NUM_CAST_INT, true], // blockrtng - 45 => [FILTER_CR_NUMERIC, 'is.dodgertng', NUM_CAST_INT, true], // dodgertng - 46 => [FILTER_CR_NUMERIC, 'is.parryrtng', NUM_CAST_INT, true], // parryrtng - 48 => [FILTER_CR_NUMERIC, 'is.splhitrtng', NUM_CAST_INT, true], // splhitrtng - 49 => [FILTER_CR_NUMERIC, 'is.splcritstrkrtng', NUM_CAST_INT, true], // splcritstrkrtng - 50 => [FILTER_CR_NUMERIC, 'is.splheal', NUM_CAST_INT, true], // splheal - 51 => [FILTER_CR_NUMERIC, 'is.spldmg', NUM_CAST_INT, true], // spldmg - 52 => [FILTER_CR_NUMERIC, 'is.arcsplpwr', NUM_CAST_INT, true], // arcsplpwr - 53 => [FILTER_CR_NUMERIC, 'is.firsplpwr', NUM_CAST_INT, true], // firsplpwr - 54 => [FILTER_CR_NUMERIC, 'is.frosplpwr', NUM_CAST_INT, true], // frosplpwr - 55 => [FILTER_CR_NUMERIC, 'is.holsplpwr', NUM_CAST_INT, true], // holsplpwr - 56 => [FILTER_CR_NUMERIC, 'is.natsplpwr', NUM_CAST_INT, true], // natsplpwr - 57 => [FILTER_CR_NUMERIC, 'is.shasplpwr', NUM_CAST_INT, true], // shasplpwr - 60 => [FILTER_CR_NUMERIC, 'is.healthrgn', NUM_CAST_INT, true], // healthrgn - 61 => [FILTER_CR_NUMERIC, 'is.manargn', NUM_CAST_INT, true], // manargn - 77 => [FILTER_CR_NUMERIC, 'is.atkpwr', NUM_CAST_INT, true], // atkpwr - 78 => [FILTER_CR_NUMERIC, 'is.mlehastertng', NUM_CAST_INT, true], // mlehastertng - 79 => [FILTER_CR_NUMERIC, 'is.resirtng', NUM_CAST_INT, true], // resirtng - 84 => [FILTER_CR_NUMERIC, 'is.mlecritstrkrtng', NUM_CAST_INT, true], // mlecritstrkrtng - 94 => [FILTER_CR_NUMERIC, 'is.splpen', NUM_CAST_INT, true], // splpen - 95 => [FILTER_CR_NUMERIC, 'is.mlehitrtng', NUM_CAST_INT, true], // mlehitrtng - 96 => [FILTER_CR_NUMERIC, 'is.critstrkrtng', NUM_CAST_INT, true], // critstrkrtng - 97 => [FILTER_CR_NUMERIC, 'is.feratkpwr', NUM_CAST_INT, true], // feratkpwr - 101 => [FILTER_CR_NUMERIC, 'is.rgdhastertng', NUM_CAST_INT, true], // rgdhastertng - 102 => [FILTER_CR_NUMERIC, 'is.splhastertng', NUM_CAST_INT, true], // splhastertng - 103 => [FILTER_CR_NUMERIC, 'is.hastertng', NUM_CAST_INT, true], // hastertng - 114 => [FILTER_CR_NUMERIC, 'is.armorpenrtng', NUM_CAST_INT, true], // armorpenrtng - 115 => [FILTER_CR_NUMERIC, 'is.health', NUM_CAST_INT, true], // health - 116 => [FILTER_CR_NUMERIC, 'is.mana', NUM_CAST_INT, true], // mana - 117 => [FILTER_CR_NUMERIC, 'is.exprtng', NUM_CAST_INT, true], // exprtng - 119 => [FILTER_CR_NUMERIC, 'is.hitrtng', NUM_CAST_INT, true], // hitrtng - 123 => [FILTER_CR_NUMERIC, 'is.splpwr', NUM_CAST_INT, true] // splpwr - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_RANGE, [2, 123], true ], // criteria ids - 'crs' => [FILTER_V_RANGE, [1, 15], true ], // criteria operators - 'crv' => [FILTER_V_RANGE, [0, 99999], true ], // criteria values - only numerals - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'ty' => [FILTER_V_RANGE, [1, 8], true ] // types - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = &$this->fiData['v']; - - //string - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name_loc'.User::$localeId])) - $parts[] = $_; - - // type - if (isset($_v['ty'])) - $parts[] = ['OR', ['type1', $_v['ty']], ['type2', $_v['ty']], ['type3', $_v['ty']]]; - - return $parts; - } -} - -?> diff --git a/includes/types/gameobject.class.php b/includes/types/gameobject.class.php deleted file mode 100644 index f835b723..00000000 --- a/includes/types/gameobject.class.php +++ /dev/null @@ -1,260 +0,0 @@ - [['ft', 'qse']], - 'ft' => ['j' => ['?_factiontemplate ft ON ft.id = o.faction', true], 's' => ', ft.factionId, ft.A, ft.H'], - 'qse' => ['j' => ['?_quests_startend qse ON qse.type = 2 AND qse.typeId = o.id', true], 's' => ', IF(min(qse.method) = 1 OR max(qse.method) = 3, 1, 0) AS startsQuests, IF(min(qse.method) = 2 OR max(qse.method) = 3, 1, 0) AS endsQuests', 'g' => 'o.id'], - 'qt' => ['j' => '?_quests qt ON qse.questId = qt.id'], - 's' => ['j' => '?_spawns s ON s.type = 2 AND s.typeId = o.id'] - ); - - public function __construct($conditions = [], $miscData = null) - { - parent::__construct($conditions, $miscData); - - if ($this->error) - return; - - // post processing - foreach ($this->iterate() as $_id => &$curTpl) - { - if (!$curTpl['name_loc0']) - $curTpl['name_loc0'] = 'Unnamed Object #' . $_id; - - // unpack miscInfo - $curTpl['lootStack'] = []; - $curTpl['spells'] = []; - - if (in_array($curTpl['type'], [OBJECT_GOOBER, OBJECT_RITUAL, OBJECT_SPELLCASTER, OBJECT_FLAGSTAND, OBJECT_FLAGDROP, OBJECT_AURA_GENERATOR, OBJECT_TRAP])) - $curTpl['spells'] = array_combine(['onUse', 'onSuccess', 'aura', 'triggered'], [$curTpl['onUseSpell'], $curTpl['onSuccessSpell'], $curTpl['auraSpell'], $curTpl['triggeredSpell']]); - - if (!$curTpl['miscInfo']) - continue; - - switch ($curTpl['type']) - { - case OBJECT_CHEST: - case OBJECT_FISHINGHOLE: - $curTpl['lootStack'] = explode(' ', $curTpl['miscInfo']); - break; - case OBJECT_CAPTURE_POINT: - $curTpl['capture'] = explode(' ', $curTpl['miscInfo']); - break; - case OBJECT_MEETINGSTONE: - $curTpl['mStone'] = explode(' ', $curTpl['miscInfo']); - break; - } - } - } - - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_objects WHERE id = ?d', $id); - return Util::localizedString($n, 'name'); - } - - public function getListviewData() - { - $data = []; - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'name' => $this->getField('name', true), - 'type' => $this->curTpl['typeCat'], - 'location' => $this->getSpawns(SPAWNINFO_ZONES) - ); - - if (!empty($this->curTpl['reqSkill'])) - $data[$this->id]['skill'] = $this->curTpl['reqSkill']; - - if ($this->curTpl['startsQuests']) - $data[$this->id]['hasQuests'] = 1; - - } - - return $data; - } - - public function renderTooltip($interactive = false) - { - if (!$this->curTpl) - return array(); - - $x = ''; - $x .= ''; - if ($this->curTpl['typeCat']) - if ($_ = Lang::gameObject('type', $this->curTpl['typeCat'])) - $x .= ''; - - if (isset($this->curTpl['lockId'])) - if ($locks = Lang::getLocks($this->curTpl['lockId'])) - foreach ($locks as $l) - $x .= ''; - - $x .= '
'.$this->getField('name', true).'
'.$_.'
'.sprintf(Lang::game('requires'), $l).'
'; - - return $x; - } - - public function getJSGlobals($addMask = 0) - { - $data = []; - - foreach ($this->iterate() as $__) - $data[Type::OBJECT][$this->id] = ['name' => $this->getField('name', true)]; - - return $data; - } - - public function getSourceData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'n' => $this->getField('name', true), - 't' => Type::OBJECT, - 'ti' => $this->id - // 'bd' => bossdrop - // 'dd' => dungeondifficulty - ); - } - - return $data; - } -} - - -class GameObjectListFilter extends Filter -{ - public $extraOpts = []; - protected $enums = array( - 50 => array( - null, 1, 2, 3, 4, - 663 => 663, - 883 => 883, - FILTER_ENUM_ANY => true, - FILTER_ENUM_NONE => false - ) - ); - - protected $genericFilter = array( - 1 => [FILTER_CR_ENUM, 's.areaId', null ], // foundin - 2 => [FILTER_CR_CALLBACK, 'cbQuestRelation', 'startsQuests', 0x1 ], // startsquest [side] - 3 => [FILTER_CR_CALLBACK, 'cbQuestRelation', 'endsQuests', 0x2 ], // endsquest [side] - 4 => [FILTER_CR_CALLBACK, 'cbOpenable', null, null], // openable [yn] - 5 => [FILTER_CR_NYI_PH, null, null ], // averagemoneycontained [op] [int] - GOs don't contain money, match against 0 - 7 => [FILTER_CR_NUMERIC, 'reqSkill', NUM_CAST_INT ], // requiredskilllevel - 11 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 13 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 15 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT ], // id - 16 => [FILTER_CR_CALLBACK, 'cbRelEvent', null, null], // relatedevent (ignore removed by event) - 18 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 50 => [FILTER_CR_ENUM, 'spellFocusId', null, ], // SpellFocus - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_LIST, [[1, 5], 7, 11, 13, 15, 16, 18, 50], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 5000]], true ], // criteria operators - 'crv' => [FILTER_V_RANGE, [0, 99999], true ], // criteria values - only numeric input values expected - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false] // match any / all filter - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCR = $this->genericCriterion($cr)) - return $genCR; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = $this->fiData['v']; - - // name - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name_loc'.User::$localeId])) - $parts[] = $_; - - return $parts; - } - - protected function cbOpenable($cr) - { - if ($this->int2Bool($cr[1])) - return $cr[1] ? ['OR', ['flags', 0x2, '&'], ['type', 3]] : ['AND', [['flags', 0x2, '&'], 0], ['type', 3, '!']]; - - return false; - } - - protected function cbQuestRelation($cr, $field, $value) - { - switch ($cr[1]) - { - case 1: // any - return ['AND', ['qse.method', $value, '&'], ['qse.questId', null, '!']]; - case 2: // alliance only - return ['AND', ['qse.method', $value, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', RACE_MASK_HORDE, '&'], 0], ['qt.reqRaceMask', RACE_MASK_ALLIANCE, '&']]; - case 3: // horde only - return ['AND', ['qse.method', $value, '&'], ['qse.questId', null, '!'], [['qt.reqRaceMask', RACE_MASK_ALLIANCE, '&'], 0], ['qt.reqRaceMask', RACE_MASK_HORDE, '&']]; - case 4: // both - return ['AND', ['qse.method', $value, '&'], ['qse.questId', null, '!'], ['OR', ['AND', ['qt.reqRaceMask', RACE_MASK_ALLIANCE, '&'], ['qt.reqRaceMask', RACE_MASK_HORDE, '&']], ['qt.reqRaceMask', 0]]]; - case 5: // none todo (low): broken, if entry starts and ends quests... - $this->extraOpts['o']['h'][] = $field.' = 0'; - return [1]; - } - - return false; - } - - protected function cbRelEvent($cr) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT)) - return false;; - - if ($cr[1] == FILTER_ENUM_ANY) - { - $eventIds = DB::Aowow()->selectCol('SELECT id FROM ?_events WHERE holidayId <> 0'); - $goGuids = DB::World()->selectCol('SELECT DISTINCT guid FROM game_event_gameobject WHERE eventEntry IN (?a)', $eventIds); - return ['s.guid', $goGuids]; - } - else if ($cr[1] == FILTER_ENUM_NONE) - { - $eventIds = DB::Aowow()->selectCol('SELECT id FROM ?_events WHERE holidayId <> 0'); - $goGuids = DB::World()->selectCol('SELECT DISTINCT guid FROM game_event_gameobject WHERE eventEntry IN (?a)', $eventIds); - return ['s.guid', $goGuids, '!']; - } - else if ($cr[1]) - { - $eventIds = DB::Aowow()->selectCol('SELECT id FROM ?_events WHERE holidayId = ?d', $cr[1]); - $goGuids = DB::World()->selectCol('SELECT DISTINCT guid FROM game_event_gameobject WHERE eventEntry IN (?a)', $eventIds); - return ['s.guid', $goGuids]; - } - - return false; - } -} - -?> diff --git a/includes/types/guide.class.php b/includes/types/guide.class.php deleted file mode 100644 index 02359abe..00000000 --- a/includes/types/guide.class.php +++ /dev/null @@ -1,167 +0,0 @@ - '#71D5FF', - GUIDE_STATUS_REVIEW => '#FFFF00', - GUIDE_STATUS_APPROVED => '#1EFF00', - GUIDE_STATUS_REJECTED => '#FF4040', - GUIDE_STATUS_ARCHIVED => '#FFD100' - ); - - public static $type = Type::GUIDE; - public static $brickFile = 'guide'; - public static $dataTable = '?_guides'; - - private $article = []; - private $jsGlobals = []; - - protected $queryBase = 'SELECT g.*, g.id AS ARRAY_KEY FROM ?_guides g'; - protected $queryOpts = array( - 'g' => [['a', 'c'], 'g' => 'g.`id`'], - 'a' => ['j' => ['?_account a ON a.id = g.userId', true], 's' => ', IFNULL(a.displayName, "") AS author'], - 'c' => ['j' => ['?_comments c ON c.`type` = '.Type::GUIDE.' AND c.`typeId` = g.`id` AND (c.`flags` & '.CC_FLAG_DELETED.') = 0', true], 's' => ', COUNT(c.`id`) AS `comments`'] - ); - - public function __construct($conditions = []) - { - parent::__construct($conditions); - - if ($this->error) - return; - - $ratings = DB::Aowow()->select('SELECT `entry` AS ARRAY_KEY, IFNULL(SUM(`value`), 0) AS `t`, IFNULL(COUNT(*), 0) AS `n`, IFNULL(MAX(IF(`userId` = ?d, `value`, 0)), 0) AS `s` FROM ?_user_ratings WHERE `type` = ?d AND `entry` IN (?a)', User::$id, RATING_GUIDE, $this->getFoundIDs()); - - // post processing - foreach ($this->iterate() as $id => &$_curTpl) - { - if (isset($ratings[$id])) - { - $_curTpl['nvotes'] = $ratings[$id]['n']; - $_curTpl['rating'] = $ratings[$id]['n'] < 5 ? -1 : $ratings[$id]['t'] / $ratings[$id]['n']; - $_curTpl['_self'] = $ratings[$id]['s']; - } - else - { - $_curTpl['nvotes'] = 0; - $_curTpl['rating'] = -1; - } - } - } - - public function getArticle(int $rev = -1) : string - { - if ($rev < -1) - $rev = -1; - - if (empty($this->article[$rev])) - { - $a = DB::Aowow()->selectRow('SELECT `article`, `rev` FROM ?_articles WHERE ((`type` = ?d AND `typeId` = ?d){ OR `url` = ?}){ AND `rev`= ?d} ORDER BY `rev` DESC LIMIT 1', - Type::GUIDE, $this->id, $this->getField('url') ?: DBSIMPLE_SKIP, $rev < 0 ? DBSIMPLE_SKIP : $rev); - - $this->article[$a['rev']] = $a['article']; - if ($this->article[$a['rev']]) - { - (new Markup($this->article[$a['rev']]))->parseGlobalsFromText($this->jsGlobals); - return $this->article[$a['rev']]; - } - else - trigger_error('GuideList::getArticle - linked article is missing'); - } - - return $this->article[$rev] ?? ''; - } - - public function getListviewData(bool $addDescription = false) : array - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'category' => $this->getField('category'), - 'title' => $this->getField('title'), - 'description' => $this->getField('description'), - 'sticky' => !!($this->getField('cuFlags') & CC_FLAG_STICKY), - 'nvotes' => $this->getField('nvotes'), - 'url' => '/?guide=' . ($this->getField('url') ?: $this->id), - 'status' => $this->getField('status'), - 'author' => $this->getField('author'), - 'authorroles' => $this->getField('roles'), - 'rating' => $this->getField('rating'), - 'views' => $this->getField('views'), - 'comments' => $this->getField('comments'), - // 'patch' => $this->getField(''), // 30305 - patch is pointless, use date instead - 'date' => $this->getField('date'), // ok - 'when' => date(Util::$dateFormatInternal, $this->getField('date')) - ); - - - } - - return $data; - } - - public function userCanView() : bool - { - // is owner || is staff - return $this->getField('userId') == User::$id || User::isInGroup(U_GROUP_STAFF); - } - - public function canBeViewed() : bool - { - // currently approved || has prev. approved version - return $this->getField('status') == GUIDE_STATUS_APPROVED || $this->getField('rev') > 0; - } - - public function canBeReported() : bool - { - // not own guide && is not archived - return $this->getField('userId') != User::$id && $this->getField('status') != GUIDE_STATUS_ARCHIVED; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) : array - { - return $this->jsGlobals; - } - - public function renderTooltip() : string - { - $specStr = ''; - - if ($this->getField('classId') && $this->getField('category') == 1) - { - $c = $this->getField('classId'); - if (($s = $this->getField('specId')) > -1) - { - $i = Game::$specIconStrings[$c][$s]; - $n = Lang::game('classSpecs', $c, $s); - } - else - { - $i = 'class_'.Game::$classFileStrings[$c]; - $n = Lang::game('cl', $c); - } - - $specStr = '  –  '.$n.''; - } - - $tt = '
'.$this->getField('title').'
'; - $tt .= '
'.Lang::guide('guide').''.Lang::guide('byAuthor', [$this->getField('author')]).'
'; - $tt .= '
'.Lang::guide('category', $this->getField('category')).$specStr.''.Lang::guide('patch').' 3.3.5
'; - $tt .= '
'.$this->getField('description').'
'; - $tt .= '
'; - - return $tt; - } -} - -?> diff --git a/includes/types/icon.class.php b/includes/types/icon.class.php deleted file mode 100644 index f74f69e7..00000000 --- a/includes/types/icon.class.php +++ /dev/null @@ -1,237 +0,0 @@ - '?_items', - 'nSpells' => '?_spell', - 'nAchievements' => '?_achievement', - 'nCurrencies' => '?_currencies', - 'nPets' => '?_pet' - ); - - protected $queryBase = 'SELECT ic.*, ic.id AS ARRAY_KEY FROM ?_icons ic'; - /* this works, but takes ~100x more time than i'm comfortable with .. kept as reference - protected $queryOpts = array( // 29 => Type::ICON - 'ic' => [['s', 'i', 'a', 'c', 'p'], 'g' => 'ic.id'], - 'i' => ['j' => ['?_items `i` ON `i`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `i`.`id`) AS nItems'], - 's' => ['j' => ['?_spell `s` ON `s`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `s`.`id`) AS nSpells'], - 'a' => ['j' => ['?_achievement `a` ON `a`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `a`.`id`) AS nAchievements'], - 'c' => ['j' => ['?_currencies `c` ON `c`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `c`.`id`) AS nCurrencies'], - 'p' => ['j' => ['?_pet `p` ON `p`.`iconId` = `ic`.`id`', true], 's' => ', COUNT(DISTINCT `p`.`id`) AS nPets'] - ); - */ - - public function __construct($conditions) - { - parent::__construct($conditions); - - if (!$this->getFoundIDs()) - return; - - foreach ($this->pseudoJoin as $var => $tbl) - { - $res = DB::Aowow()->selectCol($this->pseudoQry, $tbl, $this->getFoundIDs()); - foreach ($res as $icon => $qty) - $this->templates[$icon][$var] = $qty; - } - } - - - // use if you JUST need the name - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name FROM ?_icons WHERE id = ?d', $id ); - return Util::localizedString($n, 'name'); - } - // end static use - - public function getListviewData($addInfoMask = 0x0) - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'name' => $this->getField('name', true, true), - 'icon' => $this->getField('name', true, true), - 'itemcount' => (int)$this->getField('nItems'), - 'spellcount' => (int)$this->getField('nSpells'), - 'achievementcount' => (int)$this->getField('nAchievements'), - 'npccount' => 0, // UNUSED - 'petabilitycount' => 0, // UNUSED - 'currencycount' => (int)$this->getField('nCurrencies'), - 'missionabilitycount' => 0, // UNUSED - 'buildingcount' => 0, // UNUSED - 'petcount' => (int)$this->getField('nPets'), - 'threatcount' => 0, // UNUSED - 'classcount' => 0 // class icons are hardcoded and not referenced in dbc - ); - } - - return $data; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) - { - $data = []; - - foreach ($this->iterate() as $__) - $data[Type::ICON][$this->id] = ['name' => $this->getField('name', true, true), 'icon' => $this->getField('name', true, true)]; - - return $data; - } - - public function renderTooltip() { } -} - - -class IconListFilter extends Filter -{ - public $extraOpts = null; - - // cr => [type, field, misc, extraCol] - private $criterion2field = array( - 1 => '?_items', // items [num] - 2 => '?_spell', // spells [num] - 3 => '?_achievement', // achievements [num] - // 4 => '', // battlepets [num] - // 5 => '', // battlepetabilities [num] - 6 => '?_currencies', // currencies [num] - // 7 => '', // garrisonabilities [num] - // 8 => '', // garrisonbuildings [num] - 9 => '?_pet', // hunterpets [num] - // 10 => '', // garrisonmissionthreats [num] - 11 => '', // classes [num] - 13 => '' // used [num] - ); - private $totalUses = []; - - protected $genericFilter = array( - 1 => [FILTER_CR_CALLBACK, 'cbUseAny' ], // items [num] - 2 => [FILTER_CR_CALLBACK, 'cbUseAny' ], // spells [num] - 3 => [FILTER_CR_CALLBACK, 'cbUseAny' ], // achievements [num] - 6 => [FILTER_CR_CALLBACK, 'cbUseAny' ], // currencies [num] - 9 => [FILTER_CR_CALLBACK, 'cbUseAny' ], // hunterpets [num] - 11 => [FILTER_CR_NYI_PH, null, null], // classes [num] - 13 => [FILTER_CR_CALLBACK, 'cbUseAll' ] // used [num] - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_LIST, [1, 2, 3, 6, 9, 11, 13], true ], // criteria ids - 'crs' => [FILTER_V_RANGE, [1, 6], true ], // criteria operators - 'crv' => [FILTER_V_RANGE, [0, 99999], true ], // criteria values - all criteria are numeric here - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false] // match any / all filter - ); - - private function _getCnd($op, $val, $tbl) - { - switch ($op) - { - case '>': - case '>=': - case '=': - $ids = DB::Aowow()->selectCol('SELECT iconId AS ARRAY_KEY, COUNT(*) AS n FROM ?# GROUP BY iconId HAVING n '.$op.' '.$val, $tbl); - return $ids ? ['id', array_keys($ids)] : [1]; - case '<=': - if ($val) - $op = '>'; - break; - case '<': - if ($val) - $op = '>='; - break; - case '!=': - if ($val) - $op = '='; - break; - } - - $ids = DB::Aowow()->selectCol('SELECT iconId AS ARRAY_KEY, COUNT(*) AS n FROM ?# GROUP BY iconId HAVING n '.$op.' '.$val, $tbl); - return $ids ? ['id', array_keys($ids), '!'] : [1]; - } - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = &$this->fiData['v']; - - //string - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name'])) - $parts[] = $_; - - return $parts; - } - - protected function cbUseAny($cr, $value) - { - if (Util::checkNumeric($cr[2], NUM_CAST_INT) && $this->int2Op($cr[1])) - return $this->_getCnd($cr[1], $cr[2], $this->criterion2field[$cr[0]]); - - return false; - } - - protected function cbUseAll($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - if (!$this->totalUses) - { - foreach ($this->criterion2field as $tbl) - { - if (!$tbl) - continue; - - $res = DB::Aowow()->selectCol('SELECT iconId AS ARRAY_KEY, COUNT(*) AS n FROM ?# GROUP BY iconId', $tbl); - Util::arraySumByKey($this->totalUses, $res); - } - } - - if ($cr[1] == '=') - $cr[1] = '=='; - - $op = $cr[1]; - if ($cr[1] == '<=' && $cr[2]) - $op = '>'; - else if ($cr[1] == '<' && $cr[2]) - $op = '>='; - else if ($cr[1] == '!=' && $cr[2]) - $op = '=='; - $ids = array_filter($this->totalUses, function ($x) use ($op, $cr) { return eval('return '.$x.' '.$op.' '.$cr[2].';'); }); - - if ($cr[1] != $op) - return $ids ? ['id', array_keys($ids), '!'] : [1]; - else - return $ids ? ['id', array_keys($ids)] : ['id', array_keys($this->totalUses), '!']; - } -} - -?> diff --git a/includes/types/item.class.php b/includes/types/item.class.php deleted file mode 100644 index d0927833..00000000 --- a/includes/types/item.class.php +++ /dev/null @@ -1,2678 +0,0 @@ - Type::ITEM - 'i' => [['is', 'src', 'ic'], 'o' => 'i.quality DESC, i.itemLevel DESC'], - 'ic' => ['j' => ['?_icons `ic` ON `ic`.`id` = `i`.`iconId`', true], 's' => ', ic.name AS iconString'], - 'is' => ['j' => ['?_item_stats `is` ON `is`.`type` = 3 AND `is`.`typeId` = `i`.`id`', true], 's' => ', `is`.*'], - 's' => ['j' => ['?_spell `s` ON `s`.`effect1CreateItemId` = `i`.`id`', true], 'g' => 'i.id'], - 'e' => ['j' => ['?_events `e` ON `e`.`id` = `i`.`eventId`', true], 's' => ', e.holidayId'], - 'src' => ['j' => ['?_source `src` ON `src`.`type` = 3 AND `src`.`typeId` = `i`.`id`', true], 's' => ', moreType, moreTypeId, src1, src2, src3, src4, src5, src6, src7, src8, src9, src10, src11, src12, src13, src14, src15, src16, src17, src18, src19, src20, src21, src22, src23, src24'] - ); - - public function __construct($conditions = [], $miscData = null) - { - parent::__construct($conditions, $miscData); - - foreach ($this->iterate() as &$_curTpl) - { - // item is scaling; overwrite other values - if ($_curTpl['scalingStatDistribution'] > 0 && $_curTpl['scalingStatValue'] > 0) - $this->initScalingStats(); - - $this->initJsonStats(); - - if ($miscData) - { - // readdress itemset .. is wrong for virtual sets - if (isset($miscData['pcsToSet']) && isset($miscData['pcsToSet'][$this->id])) - $this->json[$this->id]['itemset'] = $miscData['pcsToSet'][$this->id]; - - // additional rel attribute for listview rows - if (isset($miscData['extraOpts']['relEnchant'])) - $this->relEnchant = $miscData['extraOpts']['relEnchant']; - } - - // unify those pesky masks - $_ = &$_curTpl['requiredClass']; - $_ &= CLASS_MASK_ALL; - if ($_ < 0 || $_ == CLASS_MASK_ALL) - $_ = 0; - unset($_); - - $_ = &$_curTpl['requiredRace']; - $_ &= RACE_MASK_ALL; - if ($_ < 0 || $_ == RACE_MASK_ALL) - $_ = 0; - unset($_); - - // sources - for ($i = 1; $i < 25; $i++) - { - if ($_ = $_curTpl['src'.$i]) - $this->sources[$this->id][$i][] = $_; - - unset($_curTpl['src'.$i]); - } - } - } - - // use if you JUST need the name - public static function getName($id) - { - $n = DB::Aowow()->selectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_items WHERE id = ?d', $id); - return Util::localizedString($n, 'name'); - } - - // todo (med): information will get lost if one vendor sells one item multiple times with different costs (e.g. for item 54637) - // wowhead seems to have had the same issues - public function getExtendedCost($filter = [], &$reqRating = []) - { - if ($this->error) - return []; - - $idx = $this->id; - - if (empty($this->vendors)) - { - $itemz = []; - $xCostData = []; - $rawEntries = DB::World()->select(' - SELECT nv.item, nv.entry, 0 AS eventId, nv.maxcount, nv.extendedCost FROM npc_vendor nv WHERE {nv.entry IN (?a) AND} nv.item IN (?a) - UNION - SELECT genv.item, c.id AS `entry`, ge.eventEntry AS eventId, genv.maxcount, genv.extendedCost FROM game_event_npc_vendor genv LEFT JOIN game_event ge ON genv.eventEntry = ge.eventEntry JOIN creature c ON c.guid = genv.guid WHERE {c.id IN (?a) AND} genv.item IN (?a)', - empty($filter[Type::NPC]) || !is_array($filter[Type::NPC]) ? DBSIMPLE_SKIP : $filter[Type::NPC], - array_keys($this->templates), - empty($filter[Type::NPC]) || !is_array($filter[Type::NPC]) ? DBSIMPLE_SKIP : $filter[Type::NPC], - array_keys($this->templates) - ); - - foreach ($rawEntries as $costEntry) - { - if ($costEntry['extendedCost']) - $xCostData[] = $costEntry['extendedCost']; - - if (!isset($itemz[$costEntry['item']][$costEntry['entry']])) - $itemz[$costEntry['item']][$costEntry['entry']] = [$costEntry]; - else - $itemz[$costEntry['item']][$costEntry['entry']][] = $costEntry; - } - - if ($xCostData) - $xCostData = DB::Aowow()->select('SELECT *, id AS ARRAY_KEY FROM ?_itemextendedcost WHERE id IN (?a)', $xCostData); - - $cItems = []; - foreach ($itemz as $k => $vendors) - { - foreach ($vendors as $l => $vendor) - { - foreach ($vendor as $m => $vInfo) - { - $costs = []; - if (!empty($xCostData[$vInfo['extendedCost']])) - $costs = $xCostData[$vInfo['extendedCost']]; - - $data = array( - 'stock' => $vInfo['maxcount'] ?: -1, - 'event' => $vInfo['eventId'], - 'reqRating' => $costs ? $costs['reqPersonalRating'] : 0, - 'reqBracket' => $costs ? $costs['reqArenaSlot'] : 0 - ); - - // hardcode arena(103) & honor(104) - if (!empty($costs['reqArenaPoints'])) - { - $data[-103] = $costs['reqArenaPoints']; - $this->jsGlobals[Type::CURRENCY][103] = 103; - } - - if (!empty($costs['reqHonorPoints'])) - { - $data[-104] = $costs['reqHonorPoints']; - $this->jsGlobals[Type::CURRENCY][104] = 104; - } - - for ($i = 1; $i < 6; $i++) - { - if (!empty($costs['reqItemId'.$i]) && $costs['itemCount'.$i] > 0) - { - $data[$costs['reqItemId'.$i]] = $costs['itemCount'.$i]; - $cItems[] = $costs['reqItemId'.$i]; - } - } - - // no extended cost or additional gold required - if (!$costs || $this->getField('flagsExtra') & 0x04) - { - $this->getEntry($k); - if ($_ = $this->getField('buyPrice')) - $data[0] = $_; - } - - $vendor[$m] = $data; - } - $vendors[$l] = $vendor; - } - - $itemz[$k] = $vendors; - } - - // convert items to currency if possible - if ($cItems) - { - $moneyItems = new CurrencyList(array(['itemId', $cItems])); - foreach ($moneyItems->getJSGlobals() as $type => $jsData) - foreach ($jsData as $k => $v) - $this->jsGlobals[$type][$k] = $v; - - foreach ($itemz as $itemId => $vendors) - { - foreach ($vendors as $npcId => $costData) - { - foreach ($costData as $itr => $cost) - { - foreach ($cost as $k => $v) - { - if (in_array($k, $cItems)) - { - $found = false; - foreach ($moneyItems->iterate() as $__) - { - if ($moneyItems->getField('itemId') == $k) - { - unset($cost[$k]); - $cost[-$moneyItems->id] = $v; - $found = true; - break; - } - } - - if (!$found) - $this->jsGlobals[Type::ITEM][$k] = $k; - } - } - $costData[$itr] = $cost; - } - $vendors[$npcId] = $costData; - } - $itemz[$itemId] = $vendors; - } - } - - $this->vendors = $itemz; - } - - $result = $this->vendors; - - // apply filter if given - $tok = !empty($filter[Type::ITEM]) ? $filter[Type::ITEM] : null; - $cur = !empty($filter[Type::CURRENCY]) ? $filter[Type::CURRENCY] : null; - - foreach ($result as $itemId => &$data) - { - $reqRating = []; - foreach ($data as $npcId => $entries) - { - foreach ($entries as $costs) - { - if ($tok || $cur) // bought with specific token or currency - { - $valid = false; - foreach ($costs as $k => $qty) - { - if ((!$tok || $k == $tok) && (!$cur || $k == -$cur)) - { - $valid = true; - break; - } - } - - if (!$valid) - unset($data[$npcId]); - } - - // reqRating ins't really a cost .. so pass it by ref instead of return - // use highest total value - if (isset($data[$npcId]) && $costs['reqRating'] && (!$reqRating || $reqRating[0] < $costs['reqRating'])) - $reqRating = [$costs['reqRating'], $costs['reqBracket']]; - } - } - - if (empty($data)) - unset($result[$itemId]); - } - - // restore internal index; - $this->getEntry($idx); - - return $result; - } - - public function getListviewData($addInfoMask = 0x0, $miscData = null) - { - /* - * ITEMINFO_JSON (0x01): itemMods (including spells) and subitems parsed - * ITEMINFO_SUBITEMS (0x02): searched by comparison - * ITEMINFO_VENDOR (0x04): costs-obj, when displayed as vendor - * ITEMINFO_GEM (0x10): gem infos and score - * ITEMINFO_MODEL (0x20): sameModelAs-Tab - */ - - $data = []; - - // random item is random - if ($addInfoMask & ITEMINFO_SUBITEMS) - $this->initSubItems(); - - if ($addInfoMask & ITEMINFO_JSON) - $this->extendJsonStats(); - - $extCosts = []; - if ($addInfoMask & ITEMINFO_VENDOR) - $extCosts = $this->getExtendedCost($miscData); - - $extCostOther = []; - foreach ($this->iterate() as $__) - { - foreach ($this->json[$this->id] as $k => $v) - $data[$this->id][$k] = $v; - - // json vs listview quirk - $data[$this->id]['name'] = $data[$this->id]['quality'].$data[$this->id]['name']; - unset($data[$this->id]['quality']); - - if (!empty($this->relEnchant) && $this->curTpl['randomEnchant']) - { - if (($x = array_search($this->curTpl['randomEnchant'], array_column($this->relEnchant, 'entry'))) !== false) - { - $data[$this->id]['rel'] = 'rand='.$this->relEnchant[$x]['ench']; - $data[$this->id]['name'] .= ' '.$this->relEnchant[$x]['name']; - } - } - - if ($addInfoMask & ITEMINFO_JSON) - { - foreach ($this->itemMods[$this->id] as $k => $v) - $data[$this->id][$k] = $v; - - if ($_ = intVal(($this->curTpl['minMoneyLoot'] + $this->curTpl['maxMoneyLoot']) / 2)) - $data[$this->id]['avgmoney'] = $_; - - if ($_ = $this->curTpl['repairPrice']) - $data[$this->id]['repaircost'] = $_; - } - - if ($addInfoMask & (ITEMINFO_JSON | ITEMINFO_GEM)) - if (isset($this->curTpl['score'])) - $data[$this->id]['score'] = $this->curTpl['score']; - - if ($addInfoMask & ITEMINFO_GEM) - { - $data[$this->id]['uniqEquip'] = ($this->curTpl['flags'] & ITEM_FLAG_UNIQUEEQUIPPED) ? 1 : 0; - $data[$this->id]['socketLevel'] = 0; // not used with wotlk - } - - if ($addInfoMask & ITEMINFO_VENDOR) - { - // just use the first results - // todo (med): dont use first vendor; search for the right one - if (!empty($extCosts[$this->id])) - { - $cost = reset($extCosts[$this->id]); - foreach ($cost as $itr => $entries) - { - $currency = []; - $tokens = []; - $costArr = []; - - foreach ($entries as $k => $qty) - { - if (is_string($k)) - continue; - - if ($k > 0) - $tokens[] = [$k, $qty]; - else if ($k < 0) - $currency[] = [-$k, $qty]; - } - - $costArr['stock'] = $entries['stock'];// display as column in lv - $costArr['avail'] = $entries['stock'];// display as number on icon - $costArr['cost'] = [empty($entries[0]) ? 0 : $entries[0]]; - - if ($entries['event']) - { - $this->jsGlobals[Type::WORLDEVENT][$entries['event']] = $entries['event']; - $costArr['condition'][0][$this->id][] = [[CND_ACTIVE_EVENT, $entries['event']]]; - } - - if ($currency || $tokens) // fill idx:3 if required - $costArr['cost'][] = $currency; - - if ($tokens) - $costArr['cost'][] = $tokens; - - if (!empty($entries['reqRating'])) - $costArr['reqarenartng'] = $entries['reqRating']; - - if ($itr > 0) - $extCostOther[$this->id][] = $costArr; - else - $data[$this->id] = array_merge($data[$this->id], $costArr); - } - } - - if ($x = $this->curTpl['buyPrice']) - $data[$this->id]['buyprice'] = $x; - - if ($x = $this->curTpl['sellPrice']) - $data[$this->id]['sellprice'] = $x; - - if ($x = $this->curTpl['buyCount']) - $data[$this->id]['stack'] = $x; - } - - if ($this->curTpl['class'] == ITEM_CLASS_GLYPH) - $data[$this->id]['glyph'] = $this->curTpl['subSubClass']; - - if ($x = $this->curTpl['requiredSkill']) - $data[$this->id]['reqskill'] = $x; - - if ($x = $this->curTpl['requiredSkillRank']) - $data[$this->id]['reqskillrank'] = $x; - - if ($x = $this->curTpl['requiredSpell']) - $data[$this->id]['reqspell'] = $x; - - if ($x = $this->curTpl['requiredFaction']) - $data[$this->id]['reqfaction'] = $x; - - if ($x = $this->curTpl['requiredFactionRank']) - { - $data[$this->id]['reqrep'] = $x; - $data[$this->id]['standing'] = $x; // used in /faction item-listing - } - - if ($x = $this->curTpl['slots']) - $data[$this->id]['nslots'] = $x; - - $_ = $this->curTpl['requiredRace']; - if ($_ && $_ & RACE_MASK_ALLIANCE != RACE_MASK_ALLIANCE && $_ & RACE_MASK_HORDE != RACE_MASK_HORDE) - $data[$this->id]['reqrace'] = $_; - - if ($_ = $this->curTpl['requiredClass']) - $data[$this->id]['reqclass'] = $_; // $data[$this->id]['classes'] ?? - - if ($this->curTpl['flags'] & ITEM_FLAG_HEROIC) - $data[$this->id]['heroic'] = true; - - if ($addInfoMask & ITEMINFO_MODEL) - if ($_ = $this->getField('displayId')) - $data[$this->id]['displayid'] = $_; - - if ($this->getSources($s, $sm) && !($addInfoMask & ITEMINFO_MODEL)) - { - $data[$this->id]['source'] = $s; - if ($sm) - $data[$this->id]['sourcemore'] = $sm; - } - - if (!empty($this->curTpl['cooldown'])) - $data[$this->id]['cooldown'] = $this->curTpl['cooldown'] / 1000; - } - - foreach ($extCostOther as $itemId => $duplicates) - foreach ($duplicates as $d) - $data[] = array_merge($data[$itemId], $d); // we dont really use keys on data, but this may cause errors in future - - /* even more complicated crap - modelviewer {type:X, displayid:Y, slot:z} .. not sure, when to set - */ - - return $data; - } - - public function getJSGlobals($addMask = GLOBALINFO_SELF, &$extra = []) - { - $data = $addMask & GLOBALINFO_RELATED ? $this->jsGlobals : []; - - foreach ($this->iterate() as $id => $__) - { - if ($addMask & GLOBALINFO_SELF) - { - $data[Type::ITEM][$id] = array( - 'name' => $this->getField('name', true), - 'quality' => $this->curTpl['quality'], - 'icon' => $this->curTpl['iconString'] - ); - } - - if ($addMask & GLOBALINFO_EXTRA) - { - $extra[$id] = array( - 'id' => $id, - 'tooltip' => $this->renderTooltip(true), - 'spells' => new StdClass // placeholder for knownSpells - ); - } - } - - return $data; - } - - /* - enhance (set by comparison tool or formated external links) - ench: enchantmentId - sock: bool (extraScoket (gloves, belt)) - gems: array (:-separated itemIds) - rand: >0: randomPropId; <0: randomSuffixId - interactive (set to place javascript/anchors to manipulate level and ratings or link to filters (static tooltips vs popup tooltip)) - subOf (tabled layout doesn't work if used as sub-tooltip in other item or spell tooltips; use line-break instead) - */ - public function getField($field, $localized = false, $silent = false, $enhance = []) - { - $res = parent::getField($field, $localized, $silent); - - if ($field == 'name' && !empty($enhance['r'])) - if ($this->getRandEnchantForItem($enhance['r'])) - $res .= ' '.Util::localizedString($this->enhanceR, 'name'); - - return $res; - } - - public function renderTooltip($interactive = false, $subOf = 0, $enhance = []) - { - if ($this->error) - return; - - $_name = $this->getField('name', true); - $_reqLvl = $this->curTpl['requiredLevel']; - $_quality = $this->curTpl['quality']; - $_flags = $this->curTpl['flags']; - $_class = $this->curTpl['class']; - $_subClass = $this->curTpl['subClass']; - $_slot = $this->curTpl['slot']; - $causesScaling = false; - - if (!empty($enhance['r'])) - { - if ($this->getRandEnchantForItem($enhance['r'])) - { - $_name .= ' '.Util::localizedString($this->enhanceR, 'name'); - $randEnchant = ''; - - for ($i = 1; $i < 6; $i++) - { - if ($this->enhanceR['enchantId'.$i] <= 0) - continue; - - $enchant = DB::Aowow()->selectRow('SELECT * FROM ?_itemenchantment WHERE id = ?d', $this->enhanceR['enchantId'.$i]); - if ($this->enhanceR['allocationPct'.$i] > 0) - { - $amount = intVal($this->enhanceR['allocationPct'.$i] * $this->generateEnchSuffixFactor()); - $randEnchant .= ''.str_replace('$i', $amount, Util::localizedString($enchant, 'name')).'
'; - } - else - $randEnchant .= ''.Util::localizedString($enchant, 'name').'
'; - } - } - else - unset($enhance['r']); - } - - if (isset($enhance['s']) && !in_array($_slot, [INVTYPE_WRISTS, INVTYPE_WAIST, INVTYPE_HANDS])) - unset($enhance['s']); - - // IMPORTAT: DO NOT REMOVE THE HTML-COMMENTS! THEY ARE REQUIRED TO UPDATE THE TOOLTIP CLIENTSIDE - $x = ''; - - // upper table: stats - if (!$subOf) - $x .= '
'; - - // name; quality - if ($subOf) - $x .= ''.$_name.''; - else - $x .= ''.$_name.''; - - // heroic tag - if (($_flags & ITEM_FLAG_HEROIC) && $_quality == ITEM_QUALITY_EPIC) - $x .= '
'.Lang::item('heroic').''; - - // requires map (todo: reparse ?_zones for non-conflicting data; generate Link to zone) - if ($_ = $this->curTpl['map']) - { - $map = DB::Aowow()->selectRow('SELECT * FROM ?_zones WHERE mapId = ?d LIMIT 1', $_); - $x .= '
'.Util::localizedString($map, 'name').''; - } - - // requires area - if ($this->curTpl['area']) - { - $area = DB::Aowow()->selectRow('SELECT * FROM ?_zones WHERE Id=?d LIMIT 1', $this->curTpl['area']); - $x .= '
'.Util::localizedString($area, 'name'); - } - - // conjured - if ($_flags & ITEM_FLAG_CONJURED) - $x .= '
'.Lang::item('conjured'); - - // bonding - if ($_flags & ITEM_FLAG_ACCOUNTBOUND) - $x .= '
'.Lang::item('bonding', 0); - else if ($this->curTpl['bonding']) - $x .= '
'.Lang::item('bonding', $this->curTpl['bonding']); - - // unique || unique-equipped || unique-limited - if ($this->curTpl['maxCount'] == 1) - $x .= '
'.Lang::item('unique', 0); - // not for currency tokens - else if ($this->curTpl['maxCount'] && $this->curTpl['bagFamily'] != 8192) - $x .= '
'.sprintf(Lang::item('unique', 1), $this->curTpl['maxCount']); - else if ($_flags & ITEM_FLAG_UNIQUEEQUIPPED) - $x .= '
'.Lang::item('uniqueEquipped', 0); - else if ($this->curTpl['itemLimitCategory']) - { - $limit = DB::Aowow()->selectRow("SELECT * FROM ?_itemlimitcategory WHERE id = ?", $this->curTpl['itemLimitCategory']); - $x .= '
'.sprintf(Lang::item($limit['isGem'] ? 'uniqueEquipped' : 'unique', 2), Util::localizedString($limit, 'name'), $limit['count']); - } - - // max duration - if ($dur = $this->curTpl['duration']) - { - $rt = ''; - if ($this->curTpl['flagsCustom'] & 0x1) - $rt = $interactive ? ' ('.sprintf(Util::$dfnString, 'LANG.tooltip_realduration', Lang::item('realTime')).')' : ' ('.Lang::item('realTime').')'; - - $x .= "
".Lang::game('duration').Lang::main('colon').Util::formatTime(abs($dur) * 1000).$rt; - } - - // required holiday - if ($eId = $this->curTpl['eventId']) - if ($hName = DB::Aowow()->selectRow('SELECT h.* FROM ?_holidays h JOIN ?_events e ON e.holidayId = h.id WHERE e.id = ?d', $eId)) - $x .= '
'.sprintf(Lang::game('requires'), ''.Util::localizedString($hName, 'name').''); - - // item begins a quest - if ($this->curTpl['startQuest']) - $x .= '
'.Lang::item('startQuest').''; - - // containerType (slotCount) - if ($this->curTpl['slots'] > 0) - { - $fam = $this->curTpl['bagFamily'] ? log($this->curTpl['bagFamily'], 2) + 1 : 0; - $x .= '
'.Lang::item('bagSlotString', [$this->curTpl['slots'], Lang::item('bagFamily', $fam)]); - } - - if (in_array($_class, [ITEM_CLASS_ARMOR, ITEM_CLASS_WEAPON, ITEM_CLASS_AMMUNITION])) - { - $x .= ''; - - // Class - if ($_slot) - $x .= ''; - - // Subclass - if ($_class == ITEM_CLASS_ARMOR && $_subClass > 0) - $x .= ''; - else if ($_class == ITEM_CLASS_WEAPON) - $x .= ''; - else if ($_class == ITEM_CLASS_AMMUNITION) - $x .= ''; - - $x .= '
'.Lang::item('inventoryType', $_slot).''.Lang::item('armorSubClass', $_subClass).''.Lang::item('weaponSubClass', $_subClass).''.Lang::item('projectileSubClass', $_subClass).'
'; - } - else if ($_slot && $_class != ITEM_CLASS_CONTAINER) // yes, slot can occur on random items and is then also displayed <_< .. excluding Bags >_> - $x .= '
'.Lang::item('inventoryType', $_slot).'
'; - else - $x .= '
'; - - // Weapon/Ammunition Stats (not limited to weapons (see item:1700)) - $speed = $this->curTpl['delay'] / 1000; - $sc1 = $this->curTpl['dmgType1']; - $sc2 = $this->curTpl['dmgType2']; - $dmgmin = $this->curTpl['dmgMin1'] + $this->curTpl['dmgMin2']; - $dmgmax = $this->curTpl['dmgMax1'] + $this->curTpl['dmgMax2']; - $dps = $speed ? ($dmgmin + $dmgmax) / (2 * $speed) : 0; - - if ($_class == ITEM_CLASS_AMMUNITION && $dmgmin && $dmgmax) - { - if ($sc1) - $x .= sprintf(Lang::item('damage', 'ammo', 1), ($dmgmin + $dmgmax) / 2, Lang::game('sc', $sc1)).'
'; - else - $x .= sprintf(Lang::item('damage', 'ammo', 0), ($dmgmin + $dmgmax) / 2).'
'; - } - else if ($dps) - { - if ($this->curTpl['dmgMin1'] == $this->curTpl['dmgMax1']) - $dmg = sprintf(Lang::item('damage', 'single', $sc1 ? 1 : 0), $this->curTpl['dmgMin1'], $sc1 ? Lang::game('sc', $sc1) : null); - else - $dmg = sprintf(Lang::item('damage', 'range', $sc1 ? 1 : 0), $this->curTpl['dmgMin1'], $this->curTpl['dmgMax1'], $sc1 ? Lang::game('sc', $sc1) : null); - - if ($_class == ITEM_CLASS_WEAPON) // do not use localized format here! - $x .= '
'.$dmg.''.Lang::item('speed').' '.number_format($speed, 2).'
'; - else - $x .= ''.$dmg.'
'; - - // secondary damage is set - if (($this->curTpl['dmgMin2'] || $this->curTpl['dmgMax2']) && $this->curTpl['dmgMin2'] != $this->curTpl['dmgMax2']) - $x .= sprintf(Lang::item('damage', 'range', $sc2 ? 3 : 2), $this->curTpl['dmgMin2'], $this->curTpl['dmgMax2'], $sc2 ? Lang::game('sc', $sc2) : null).'
'; - else if ($this->curTpl['dmgMin2']) - $x .= sprintf(Lang::item('damage', 'single', $sc2 ? 3 : 2), $this->curTpl['dmgMin2'], $sc2 ? Lang::game('sc', $sc2) : null).'
'; - - if ($_class == ITEM_CLASS_WEAPON) - $x .= ''.sprintf(Lang::item('dps'), $dps).'
'; // do not use localized format here! - - // display FeralAttackPower if set - if ($fap = $this->getFeralAP()) - $x .= '('.$fap.' '.Lang::item('fap').')
'; - } - - // Armor - if ($_class == ITEM_CLASS_ARMOR && $this->curTpl['armorDamageModifier'] > 0) - { - $spanI = 'class="q2"'; - if ($interactive) - $spanI = 'class="q2 tip" onmouseover="$WH.Tooltip.showAtCursor(event, $WH.sprintf(LANG.tooltip_armorbonus, '.$this->curTpl['armorDamageModifier'].'), 0, 0, \'q\')" onmousemove="$WH.Tooltip.cursorUpdate(event)" onmouseout="$WH.Tooltip.hide()"'; - - $x .= ''.Lang::item('armor', [$this->curTpl['armor']]).'
'; - } - else if ($this->curTpl['armor']) - $x .= ''.Lang::item('armor', [$this->curTpl['armor']]).'
'; - - // Block (note: block value from field block and from field stats or parsed from itemSpells are displayed independently) - if ($this->curTpl['tplBlock']) - $x .= ''.sprintf(Lang::item('block'), $this->curTpl['tplBlock']).'
'; - - // Item is a gem (don't mix with sockets) - if ($geId = $this->curTpl['gemEnchantmentId']) - { - $gemEnch = DB::Aowow()->selectRow('SELECT * FROM ?_itemenchantment WHERE id = ?d', $geId); - $x .= ''.Util::localizedString($gemEnch, 'name').'
'; - - // activation conditions for meta gems - if (!empty($gemEnch['conditionId'])) - { - if ($gemCnd = DB::Aowow()->selectRow('SELECT * FROM ?_itemenchantmentcondition WHERE id = ?d', $gemEnch['conditionId'])) - { - for ($i = 1; $i < 6; $i++) - { - if (!$gemCnd['color'.$i]) - continue; - - $vspfArgs = []; - switch ($gemCnd['comparator'.$i]) - { - case 2: // requires less than ( || ) gems - case 5: // requires at least than ( || ) gems - $vspfArgs = [$gemCnd['value'.$i], Lang::item('gemColors', $gemCnd['color'.$i] - 1)]; - break; - case 3: // requires more than ( || ) gems - $vspfArgs = [Lang::item('gemColors', $gemCnd['color'.$i] - 1), Lang::item('gemColors', $gemCnd['cmpColor'.$i] - 1)]; - break; - default: - continue 2; - } - - $x .= ''.Lang::achievement('reqNumCrt').' '.Lang::item('gemConditions', $gemCnd['comparator'.$i], $vspfArgs).'
'; - } - } - } - } - - // Random Enchantment - if random enchantment is set, prepend stats from it - if ($this->curTpl['randomEnchant'] && empty($enhance['r'])) - $x .= ''.Lang::item('randEnchant').'
'; - else if (!empty($enhance['r'])) - $x .= $randEnchant; - - // itemMods (display stats and save ratings for later use) - for ($j = 1; $j <= 10; $j++) - { - $type = $this->curTpl['statType'.$j]; - $qty = $this->curTpl['statValue'.$j]; - - if (!$qty || $type <= 0) - continue; - - // base stat - switch ($type) - { - case ITEM_MOD_MANA: - case ITEM_MOD_HEALTH: - // $type += 1; // i think i fucked up somewhere mapping item_mods: offsets may be required somewhere - case ITEM_MOD_AGILITY: - case ITEM_MOD_STRENGTH: - case ITEM_MOD_INTELLECT: - case ITEM_MOD_SPIRIT: - case ITEM_MOD_STAMINA: - $x .= ''.($qty > 0 ? '+' : '-').abs($qty).' '.Lang::item('statType', $type).'
'; - break; - default: // rating with % for reqLevel - $green[] = $this->parseRating($type, $qty, $interactive, $causesScaling); - - } - } - - // magic resistances - foreach (Game::$resistanceFields as $j => $rowName) - if ($rowName && $this->curTpl[$rowName] != 0) - $x .= '+'.$this->curTpl[$rowName].' '.Lang::game('resistances', $j).'
'; - - // Enchantment - if (isset($enhance['e'])) - { - if ($enchText = DB::Aowow()->selectRow('SELECT * FROM ?_itemenchantment WHERE id = ?', $enhance['e'])) - $x .= ''.Util::localizedString($enchText, 'name').'
'; - else - { - unset($enhance['e']); - $x .= ''; - } - } - else // enchantment placeholder - $x .= ''; - - // Sockets w/ Gems - if (!empty($enhance['g'])) - { - $gems = DB::Aowow()->select(' - SELECT it.id AS ARRAY_KEY, ic.name AS iconString, ae.*, it.gemColorMask AS colorMask - FROM ?_items it - JOIN ?_itemenchantment ae ON ae.id = it.gemEnchantmentId - JOIN ?_icons ic ON ic.id = it.iconId - WHERE it.id IN (?a)', - $enhance['g']); - foreach ($enhance['g'] as $k => $v) - if ($v && !in_array($v, array_keys($gems))) // 0 is valid - unset($enhance['g'][$k]); - } - else - $enhance['g'] = []; - - // zero fill empty sockets - $sockCount = isset($enhance['s']) ? 1 : 0; - if (!empty($this->json[$this->id]['nsockets'])) - $sockCount += $this->json[$this->id]['nsockets']; - - while ($sockCount > count($enhance['g'])) - $enhance['g'][] = 0; - - $enhance['g'] = array_reverse($enhance['g']); - - $hasMatch = 1; - // fill native sockets - for ($j = 1; $j <= 3; $j++) - { - if (!$this->curTpl['socketColor'.$j]) - continue; - - for ($i = 0; $i < 4; $i++) - if (($this->curTpl['socketColor'.$j] & (1 << $i))) - $colorId = $i; - - $pop = array_pop($enhance['g']); - $col = $pop ? 1 : 0; - $hasMatch &= $pop ? (($gems[$pop]['colorMask'] & (1 << $colorId)) ? 1 : 0) : 0; - $icon = $pop ? sprintf(Util::$bgImagePath['tiny'], STATIC_URL, strtolower($gems[$pop]['iconString'])) : null; - $text = $pop ? Util::localizedString($gems[$pop], 'name') : Lang::item('socket', $colorId); - - if ($interactive) - $x .= ''.$text.'
'; - else - $x .= ''.$text.'
'; - } - - // fill extra socket - if (isset($enhance['s'])) - { - $pop = array_pop($enhance['g']); - $col = $pop ? 1 : 0; - $icon = $pop ? sprintf(Util::$bgImagePath['tiny'], STATIC_URL, strtolower($gems[$pop]['iconString'])) : null; - $text = $pop ? Util::localizedString($gems[$pop], 'name') : Lang::item('socket', -1); - - if ($interactive) - $x .= ''.$text.'
'; - else - $x .= ''.$text.'
'; - } - else // prismatic socket placeholder - $x .= ''; - - if ($_ = $this->curTpl['socketBonus']) - { - $sbonus = DB::Aowow()->selectRow('SELECT * FROM ?_itemenchantment WHERE id = ?d', $_); - $x .= ''.Lang::item('socketBonus', [''.Util::localizedString($sbonus, 'name').'']).'
'; - } - - // durability - if ($dur = $this->curTpl['durability']) - $x .= sprintf(Lang::item('durability'), $dur, $dur).'
'; - - $jsg = []; - // required classes - if ($classes = Lang::getClassString($this->curTpl['requiredClass'], $jsg)) - { - foreach ($jsg as $js) - if (empty($this->jsGlobals[Type::CHR_CLASS][$js])) - $this->jsGlobals[Type::CHR_CLASS][$js] = $js; - - $x .= Lang::game('classes').Lang::main('colon').$classes.'
'; - } - - // required races - if ($races = Lang::getRaceString($this->curTpl['requiredRace'], $jsg)) - { - foreach ($jsg as $js) - if (empty($this->jsGlobals[Type::CHR_RACE][$js])) - $this->jsGlobals[Type::CHR_ACE][$js] = $js; - - $x .= Lang::game('races').Lang::main('colon').$races.'
'; - } - - // required honorRank (not used anymore) - if ($rhr = $this->curTpl['requiredHonorRank']) - $x .= sprintf(Lang::game('requires'), Lang::game('pvpRank', $rhr)).'
'; - - // required CityRank..? - // what the f.. - - // required level - if (($_flags & ITEM_FLAG_ACCOUNTBOUND) && $_quality == ITEM_QUALITY_HEIRLOOM) - $x .= sprintf(Lang::item('reqLevelRange'), 1, MAX_LEVEL, ($interactive ? sprintf(Util::$changeLevelString, MAX_LEVEL) : ''.MAX_LEVEL)).'
'; - else if ($_reqLvl > 1) - $x .= sprintf(Lang::item('reqMinLevel'), $_reqLvl).'
'; - - // required arena team rating / personal rating / todo (low): sort out what kind of rating - if (!empty($this->getExtendedCost([], $reqRating)[$this->id]) && $reqRating) - $x .= sprintf(Lang::item('reqRating', $reqRating[1]), $reqRating[0]).'
'; - - // item level - if (in_array($_class, [ITEM_CLASS_ARMOR, ITEM_CLASS_WEAPON])) - $x .= sprintf(Lang::item('itemLevel'), $this->curTpl['itemLevel']).'
'; - - // required skill - if ($reqSkill = $this->curTpl['requiredSkill']) - { - $_ = ''.SkillList::getName($reqSkill).''; - if ($this->curTpl['requiredSkillRank'] > 0) - $_ .= ' ('.$this->curTpl['requiredSkillRank'].')'; - - $x .= sprintf(Lang::game('requires'), $_).'
'; - } - - // required spell - if ($reqSpell = $this->curTpl['requiredSpell']) - $x .= Lang::game('requires2').' '.SpellList::getName($reqSpell).'
'; - - // required reputation w/ faction - if ($reqFac = $this->curTpl['requiredFaction']) - $x .= sprintf(Lang::game('requires'), ''.FactionList::getName($reqFac).' - '.Lang::game('rep', $this->curTpl['requiredFactionRank'])).'
'; - - // locked or openable - if ($locks = Lang::getLocks($this->curTpl['lockId'], $arr, true, true)) - $x .= ''.Lang::item('locked').'
'.implode('
', array_map(function($x) { return sprintf(Lang::game('requires'), $x); }, $locks)).'

'; - else if ($this->curTpl['flags'] & ITEM_FLAG_OPENABLE) - $x .= ''.Lang::item('openClick').'
'; - - // upper table: done - if (!$subOf) - $x .= '
'; - - // spells on item - if (!$this->canTeachSpell()) - { - $itemSpellsAndTrigger = []; - for ($j = 1; $j <= 5; $j++) - { - if ($this->curTpl['spellId'.$j] > 0) - { - $cd = $this->curTpl['spellCooldown'.$j]; - if ($cd < $this->curTpl['spellCategoryCooldown'.$j]) - $cd = $this->curTpl['spellCategoryCooldown'.$j]; - - $extra = []; - if ($cd >= 5000) - $extra[] = Lang::game('cooldown', [Util::formatTime($cd, true)]); - if ($this->curTpl['spellTrigger'.$j] == 2) - if ($ppm = $this->curTpl['spellppmRate'.$j]) - $extra[] = Lang::spell('ppm', [$ppm]); - - $itemSpellsAndTrigger[$this->curTpl['spellId'.$j]] = [$this->curTpl['spellTrigger'.$j], $extra ? ' ('.implode(', ', $extra).')' : '']; - } - } - - if ($itemSpellsAndTrigger) - { - $cooldown = ''; - - $itemSpells = new SpellList(array(['s.id', array_keys($itemSpellsAndTrigger)])); - foreach ($itemSpells->iterate() as $__) - if ($parsed = $itemSpells->parseText('description', $_reqLvl > 1 ? $_reqLvl : MAX_LEVEL, false, $causesScaling)[0]) - { - if ($interactive) - { - $link = '%s'; - $parsed = preg_replace_callback('/([^;]*)( .*?<\/small>)([^&]*)/i', function($m) use($link) { - $m[1] = $m[1] ? sprintf($link, $m[1]) : ''; - $m[3] = $m[3] ? sprintf($link, $m[3]) : ''; - return $m[1].$m[2].$m[3]; - }, $parsed, -1, $nMatches - ); - - if (!$nMatches) - $parsed = sprintf($link, $parsed); - } - - $green[] = Lang::item('trigger', $itemSpellsAndTrigger[$itemSpells->id][0]).$parsed.$itemSpellsAndTrigger[$itemSpells->id][1]; - } - } - } - - // lower table (ratings, spells, ect) - if (!$subOf) - $x .= '
'; - - if (isset($green)) - foreach ($green as $j => $bonus) - if ($bonus) - $x .= ''.$bonus.'
'; - - // Item Set - $pieces = []; - if ($setId = $this->getField('itemset')) - { - $condition = [ - ['refSetId', $setId], - // ['quality', $this->curTpl['quality']], - ['minLevel', $this->curTpl['itemLevel'], '<='], - ['maxLevel', $this->curTpl['itemLevel'], '>='] - ]; - - $itemset = new ItemsetList($condition); - if (!$itemset->error && $itemset->pieceToSet) - { - // handle special cases where: - // > itemset has items of different qualities (handled by not limiting for this in the initial query) - // > itemset is virtual and multiple instances have the same itemLevel but not quality (filter below) - if ($itemset->getMatches() > 1) - { - foreach ($itemset->iterate() as $id => $__) - { - if ($itemset->getField('quality') == $this->curTpl['quality']) - { - $itemset->pieceToSet = array_filter($itemset->pieceToSet, function($x) use ($id) { return $id == $x; }); - break; - } - } - } - - $pieces = DB::Aowow()->select(' - SELECT b.id AS ARRAY_KEY, b.name_loc0, b.name_loc2, b.name_loc3, b.name_loc4, b.name_loc6, b.name_loc8, GROUP_CONCAT(a.id SEPARATOR \':\') AS equiv - FROM ?_items a, ?_items b - WHERE a.slotBak = b.slotBak AND a.itemset = b.itemset AND b.id IN (?a) - GROUP BY b.id;', - array_keys($itemset->pieceToSet) - ); - - foreach ($pieces as $k => &$p) - $p = ''.Util::localizedString($p, 'name').''; - - $xSet = '
'.Lang::item('setName', [''.$itemset->getField('name', true).'', 0, count($pieces)]).''; - - if ($skId = $itemset->getField('skillId')) // bonus requires skill to activate - { - $xSet .= '
'.sprintf(Lang::game('requires'), ''.SkillList::getName($skId).''); - - if ($_ = $itemset->getField('skillLevel')) - $xSet .= ' ('.$_.')'; - - $xSet .= '
'; - } - - // list pieces - $xSet .= '
'.implode('
', $pieces).'

'; - - // get bonuses - $setSpellsAndIdx = []; - for ($j = 1; $j <= 8; $j++) - if ($_ = $itemset->getField('spell'.$j)) - $setSpellsAndIdx[$_] = $j; - - $setSpells = []; - if ($setSpellsAndIdx) - { - $boni = new SpellList(array(['s.id', array_keys($setSpellsAndIdx)])); - foreach ($boni->iterate() as $__) - { - $setSpells[] = array( - 'tooltip' => $boni->parseText('description', $_reqLvl > 1 ? $_reqLvl : MAX_LEVEL, false, $causesScaling)[0], - 'entry' => $itemset->getField('spell'.$setSpellsAndIdx[$boni->id]), - 'bonus' => $itemset->getField('bonus'.$setSpellsAndIdx[$boni->id]) - ); - } - } - - // sort and list bonuses - $xSet .= ''; - for ($i = 0; $i < count($setSpells); $i++) - { - for ($j = $i; $j < count($setSpells); $j++) - { - if ($setSpells[$j]['bonus'] >= $setSpells[$i]['bonus']) - continue; - - $tmp = $setSpells[$i]; - $setSpells[$i] = $setSpells[$j]; - $setSpells[$j] = $tmp; - } - $xSet .= ''.Lang::item('setBonus', [$setSpells[$i]['bonus'], ''.$setSpells[$i]['tooltip'].'']).''; - if ($i < count($setSpells) - 1) - $xSet .= '
'; - } - $xSet .= '
'; - } - } - - // recipes, vanity pets, mounts - if ($this->canTeachSpell()) - { - $craftSpell = new SpellList(array(['s.id', intVal($this->curTpl['spellId2'])])); - if (!$craftSpell->error) - { - $xCraft = ''; - if ($desc = $this->getField('description', true)) - $x .= ''.Lang::item('trigger', 0).' '.$desc.'
'; - - // recipe handling (some stray Techniques have subclass == 0), place at bottom of tooltipp - if ($_class == ITEM_CLASS_RECIPE || $this->curTpl['bagFamily'] == 16) - { - $craftItem = new ItemList(array(['i.id', (int)$craftSpell->curTpl['effect1CreateItemId']])); - if (!$craftItem->error) - { - if ($itemTT = $craftItem->renderTooltip($interactive, $this->id)) - $xCraft .= '

'.$itemTT.'
'; - - $reagentItems = []; - for ($i = 1; $i <= 8; $i++) - if ($rId = $craftSpell->getField('reagent'.$i)) - $reagentItems[$rId] = $craftSpell->getField('reagentCount'.$i); - - if (isset($xCraft) && $reagentItems) - { - $reagents = new ItemList(array(['i.id', array_keys($reagentItems)])); - $reqReag = []; - - foreach ($reagents->iterate() as $__) - $reqReag[] = ''.$reagents->getField('name', true).' ('.$reagentItems[$reagents->id].')'; - - $xCraft .= '

'.Lang::game('requires2').' '.implode(', ', $reqReag).'
'; - } - } - } - } - } - - // misc (no idea, how to organize the
better) - $xMisc = []; - - // itemset: pieces and boni - if (isset($xSet)) - $xMisc[] = $xSet; - - // funny, yellow text at the bottom, omit if we have a recipe - if ($this->curTpl['description_loc0'] && !$this->canTeachSpell()) - $xMisc[] = '"'.$this->getField('description', true).'"'; - - // readable - if ($this->curTpl['pageTextId']) - $xMisc[] = ''.Lang::item('readClick').''; - - // charges (i guess checking first spell is enough) - if ($this->curTpl['spellCharges1']) - $xMisc[] = ''.Lang::item('charges', [abs($this->curTpl['spellCharges1'])]).''; - - // list required reagents - if (isset($xCraft)) - $xMisc[] = $xCraft; - - if ($xMisc) - $x .= implode('
', $xMisc); - - if ($sp = $this->curTpl['sellPrice']) - $x .= '
'.Lang::item('sellPrice').Lang::main('colon').Util::formatMoney($sp).'
'; - - if (!$subOf) - $x .= '
'; - - // tooltip scaling - if (!isset($xCraft)) - { - $link = [$subOf ? $subOf : $this->id, 1]; // itemId, scaleMinLevel - if (isset($this->ssd[$this->id])) // is heirloom - { - array_push($link, - $this->ssd[$this->id]['maxLevel'], // scaleMaxLevel - $this->ssd[$this->id]['maxLevel'], // scaleCurLevel - $this->curTpl['scalingStatDistribution'], // scaleDist - $this->curTpl['scalingStatValue'] // scaleFlags - ); - } - else // may still use level dependant ratings - { - array_push($link, - $causesScaling ? MAX_LEVEL : 1, // scaleMaxLevel - $_reqLvl > 1 ? $_reqLvl : MAX_LEVEL // scaleCurLevel - ); - } - $x .= ''; - } - - return $x; - } - - public function getRandEnchantForItem($randId) - { - // is it available for this item? .. does it even exist?! - if (empty($this->enhanceR)) - if (DB::World()->selectCell('SELECT 1 FROM item_enchantment_template WHERE entry = ?d AND ench = ?d', abs($this->getField('randomEnchant')), abs($randId))) - if ($_ = DB::Aowow()->selectRow('SELECT * FROM ?_itemrandomenchant WHERE id = ?d', $randId)) - $this->enhanceR = $_; - - return !empty($this->enhanceR); - } - - // from Trinity - public function generateEnchSuffixFactor() - { - $rpp = DB::Aowow()->selectRow('SELECT * FROM ?_itemrandomproppoints WHERE id = ?', $this->curTpl['itemLevel']); - if (!$rpp) - return 0; - - switch ($this->curTpl['slot']) - { - // Items of that type don`t have points - case INVTYPE_NON_EQUIP: - case INVTYPE_BAG: - case INVTYPE_TABARD: - case INVTYPE_AMMO: - case INVTYPE_QUIVER: - case INVTYPE_RELIC: - return 0; - // Select point coefficient - case INVTYPE_HEAD: - case INVTYPE_BODY: - case INVTYPE_CHEST: - case INVTYPE_LEGS: - case INVTYPE_2HWEAPON: - case INVTYPE_ROBE: - $suffixFactor = 1; - break; - case INVTYPE_SHOULDERS: - case INVTYPE_WAIST: - case INVTYPE_FEET: - case INVTYPE_HANDS: - case INVTYPE_TRINKET: - $suffixFactor = 2; - break; - case INVTYPE_NECK: - case INVTYPE_WRISTS: - case INVTYPE_FINGER: - case INVTYPE_SHIELD: - case INVTYPE_CLOAK: - case INVTYPE_HOLDABLE: - $suffixFactor = 3; - break; - case INVTYPE_WEAPON: - case INVTYPE_WEAPONMAINHAND: - case INVTYPE_WEAPONOFFHAND: - $suffixFactor = 4; - break; - case INVTYPE_RANGED: - case INVTYPE_THROWN: - case INVTYPE_RANGEDRIGHT: - $suffixFactor = 5; - break; - default: - return 0; - } - - // Select rare/epic modifier - switch ($this->curTpl['quality']) - { - case ITEM_QUALITY_UNCOMMON: - return $rpp['uncommon'.$suffixFactor] / 10000; - case ITEM_QUALITY_RARE: - return $rpp['rare'.$suffixFactor] / 10000; - case ITEM_QUALITY_EPIC: - return $rpp['epic'.$suffixFactor] / 10000; - case ITEM_QUALITY_LEGENDARY: - case ITEM_QUALITY_ARTIFACT: - return 0; // not have random properties - default: - break; - } - return 0; - } - - public function extendJsonStats() - { - $enchantments = []; // buffer Ids for lookup id => src; src>0: socketBonus; src<0: gemEnchant - - foreach ($this->iterate() as $__) - { - $this->itemMods[$this->id] = []; - - foreach (Game::$itemMods as $mod) - if ($_ = floatVal($this->curTpl[$mod])) - Util::arraySumByKey($this->itemMods[$this->id], [$mod => $_]); - - // fetch and add socketbonusstats - if (!empty($this->json[$this->id]['socketbonus'])) - $enchantments[$this->json[$this->id]['socketbonus']][] = $this->id; - - // Item is a gem (don't mix with sockets) - if ($geId = $this->curTpl['gemEnchantmentId']) - $enchantments[$geId][] = -$this->id; - } - - if ($enchantments) - { - $eStats = DB::Aowow()->select('SELECT *, typeId AS ARRAY_KEY FROM ?_item_stats WHERE `type` = ?d AND typeId IN (?a)', Type::ENCHANTMENT, array_keys($enchantments)); - Util::checkNumeric($eStats); - - // and merge enchantments back - foreach ($enchantments as $eId => $items) - { - if (empty($eStats[$eId])) - continue; - - foreach ($items as $item) - { - if ($item > 0) // apply socketBonus - $this->json[$item]['socketbonusstat'] = array_filter($eStats[$eId]); - else /* if ($item < 0) */ // apply gemEnchantment - Util::arraySumByKey($this->json[-$item], array_filter($eStats[$eId])); - } - } - } - - foreach ($this->json as $item => $json) - foreach ($json as $k => $v) - if (!$v && !in_array($k, ['classs', 'subclass', 'quality', 'side', 'gearscore'])) - unset($this->json[$item][$k]); - } - - public function getOnUseStats() - { - $onUseStats = []; - - // convert Spells - $useSpells = []; - for ($h = 1; $h <= 5; $h++) - { - if ($this->curTpl['spellId'.$h] <= 0) - continue; - - if ($this->curTpl['class'] != ITEM_CLASS_CONSUMABLE || $this->curTpl['spellTrigger'.$h]) - continue; - - $useSpells[] = $this->curTpl['spellId'.$h]; - } - - if ($useSpells) - { - $eqpSplList = new SpellList(array(['s.id', $useSpells])); - foreach ($eqpSplList->getStatGain() as $stat) - Util::arraySumByKey($onUseStats, $stat); - } - - return $onUseStats; - } - - public function getSourceData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'n' => $this->getField('name', true), - 't' => Type::ITEM, - 'ti' => $this->id, - 'q' => $this->curTpl['quality'], - // 'p' => PvP [NYI] - 'icon' => $this->curTpl['iconString'] - ); - } - - return $data; - } - - private function canTeachSpell() - { - // 483: learn recipe; 55884: learn mount/pet - if (!in_array($this->curTpl['spellId1'], [483, 55884])) - return false; - - // needs learnable spell - if (!$this->curTpl['spellId2']) - return false; - - return true; - } - - private function getFeralAP() - { - // must be weapon - if ($this->curTpl['class'] != ITEM_CLASS_WEAPON) - return 0; - - // must be 2H weapon (2H-Mace, Polearm, Staff, ..Fishing Pole) - if (!in_array($this->curTpl['subClass'], [5, 6, 10, 20])) - return 0; - - // thats fucked up.. - if (!$this->curTpl['delay']) - return 0; - - // must have enough damage - $dps = ($this->curTpl['dmgMin1'] + $this->curTpl['dmgMin2'] + $this->curTpl['dmgMax1'] + $this->curTpl['dmgMax2']) / (2 * $this->curTpl['delay'] / 1000); - if ($dps < 54.8) - return 0; - - return round(($dps - 54.8) * 14, 0); - } - - public function getSources(&$s, &$sm) - { - $s = $sm = null; - if (empty($this->sources[$this->id])) - return false; - - if ($this->sourceMore === null) - { - $buff = []; - $this->sourceMore = []; - - foreach ($this->iterate() as $_curTpl) - if ($_curTpl['moreType'] && $_curTpl['moreTypeId']) - $buff[$_curTpl['moreType']][] = $_curTpl['moreTypeId']; - - foreach ($buff as $type => $ids) - $this->sourceMore[$type] = (Type::newList($type, [['id', $ids]]))?->getSourceData(); - } - - $s = array_keys($this->sources[$this->id]); - if ($this->curTpl['moreType'] && $this->curTpl['moreTypeId'] && !empty($this->sourceMore[$this->curTpl['moreType']][$this->curTpl['moreTypeId']])) - $sm = [$this->sourceMore[$this->curTpl['moreType']][$this->curTpl['moreTypeId']]]; - else if (!empty($this->sources[$this->id][3])) - $sm = [['p' => $this->sources[$this->id][3][0]]]; - - return true; - } - - private function parseRating($type, $value, $interactive = false, &$scaling = false) - { - // clamp level range - $ssdLvl = isset($this->ssd[$this->id]) ? $this->ssd[$this->id]['maxLevel'] : 1; - $reqLvl = $this->curTpl['requiredLevel'] > 1 ? $this->curTpl['requiredLevel'] : MAX_LEVEL; - $level = min(max($reqLvl, $ssdLvl), MAX_LEVEL); - - // unknown rating - if (in_array($type, [2, 8, 9, 10, 11]) || $type > ITEM_MOD_BLOCK_VALUE || $type < 0) - { - if (User::isInGroup(U_GROUP_EMPLOYEE)) - return sprintf(Lang::item('statType', count(Lang::item('statType')) - 1), $type, $value); - else - return null; - } - // level independant Bonus - else if (in_array($type, Game::$lvlIndepRating)) - return Lang::item('trigger', 1).str_replace('%d', ''.$value, Lang::item('statType', $type)); - // rating-Bonuses - else - { - $scaling = true; - - if ($interactive) - $js = ' ('.sprintf(Util::$changeLevelString, Util::setRatingLevel($level, $type, $value)).')'; - else - $js = ' ('.Util::setRatingLevel($level, $type, $value).')'; - - return Lang::item('trigger', 1).str_replace('%d', ''.$value.$js, Lang::item('statType', $type)); - } - } - - private function getSSDMod($type) - { - $mask = $this->curTpl['scalingStatValue']; - - switch ($type) - { - case 'stats': $mask &= 0x04001F; break; - case 'armor': $mask &= 0xF001E0; break; - case 'dps' : $mask &= 0x007E00; break; - case 'spell': $mask &= 0x008000; break; - case 'fap' : $mask &= 0x010000; break; // unused - default: $mask &= 0x0; - } - - $field = null; - for ($i = 0; $i < count(Util::$ssdMaskFields); $i++) - if ($mask & (1 << $i)) - $field = Util::$ssdMaskFields[$i]; - - return $field ? DB::Aowow()->selectCell('SELECT ?# FROM ?_scalingstatvalues WHERE id = ?d', $field, $this->ssd[$this->id]['maxLevel']) : 0; - } - - private function initScalingStats() - { - $this->ssd[$this->id] = DB::Aowow()->selectRow('SELECT * FROM ?_scalingstatdistribution WHERE id = ?d', $this->curTpl['scalingStatDistribution']); - - if (!$this->ssd[$this->id]) - return; - - // stats and ratings - for ($i = 1; $i <= 10; $i++) - { - if ($this->ssd[$this->id]['statMod'.$i] <= 0) - { - $this->templates[$this->id]['statType'.$i] = 0; - $this->templates[$this->id]['statValue'.$i] = 0; - } - else - { - $this->templates[$this->id]['statType'.$i] = $this->ssd[$this->id]['statMod'.$i]; - $this->templates[$this->id]['statValue'.$i] = intVal(($this->getSSDMod('stats') * $this->ssd[$this->id]['modifier'.$i]) / 10000); - } - } - - // armor: only replace if set - if ($ssvArmor = $this->getSSDMod('armor')) - $this->templates[$this->id]['armor'] = $ssvArmor; - - // if set dpsMod in ScalingStatValue use it for min/max damage - // mle: 20% range / rgd: 30% range - if ($extraDPS = $this->getSSDMod('dps')) // dmg_x2 not used for heirlooms - { - $range = isset($this->json[$this->id]['rgddps']) ? 0.3 : 0.2; - $average = $extraDPS * $this->curTpl['delay'] / 1000; - - $this->templates[$this->id]['dmgMin1'] = floor((1 - $range) * $average); - $this->templates[$this->id]['dmgMax1'] = floor((1 + $range) * $average); - } - - // apply Spell Power from ScalingStatValue if set - if ($spellBonus = $this->getSSDMod('spell')) - { - $this->templates[$this->id]['statType10'] = ITEM_MOD_SPELL_POWER; - $this->templates[$this->id]['statValue10'] = $spellBonus; - } - } - - public function initSubItems() - { - if (!array_keys($this->templates)) - return; - - $subItemIds = []; - foreach ($this->iterate() as $__) - if ($_ = $this->getField('randomEnchant')) - $subItemIds[abs($_)] = $_; - - if (!$subItemIds) - return; - - // remember: id < 0: randomSuffix; id > 0: randomProperty - $subItemTpls = DB::World()->select(' - SELECT CAST( entry as SIGNED) AS ARRAY_KEY, CAST( ench as SIGNED) AS ARRAY_KEY2, chance FROM item_enchantment_template WHERE entry IN (?a) UNION - SELECT CAST(-entry as SIGNED) AS ARRAY_KEY, CAST(-ench as SIGNED) AS ARRAY_KEY2, chance FROM item_enchantment_template WHERE entry IN (?a)', - array_keys(array_filter($subItemIds, function ($v) { return $v > 0; })) ?: [0], - array_keys(array_filter($subItemIds, function ($v) { return $v < 0; })) ?: [0] - ); - - $randIds = []; - foreach ($subItemTpls as $tpl) - $randIds = array_merge($randIds, array_keys($tpl)); - - if (!$randIds) - return; - - $randEnchants = DB::Aowow()->select('SELECT *, id AS ARRAY_KEY FROM ?_itemrandomenchant WHERE id IN (?a)', $randIds); - $enchIds = array_unique(array_merge( - array_column($randEnchants, 'enchantId1'), - array_column($randEnchants, 'enchantId2'), - array_column($randEnchants, 'enchantId3'), - array_column($randEnchants, 'enchantId4'), - array_column($randEnchants, 'enchantId5') - )); - - $enchants = new EnchantmentList(array(['id', $enchIds], CFG_SQL_LIMIT_NONE)); - foreach ($enchants->iterate() as $eId => $_) - { - $this->rndEnchIds[$eId] = array( - 'text' => $enchants->getField('name', true), - 'stats' => $enchants->getStatGain(true) - ); - } - - foreach ($this->iterate() as $mstItem => $__) - { - if (!$this->getField('randomEnchant')) - continue; - - if (empty($subItemTpls[$this->getField('randomEnchant')])) - continue; - - foreach ($subItemTpls[$this->getField('randomEnchant')] as $subId => $data) - { - if (empty($randEnchants[$subId])) - continue; - - $data = array_merge($randEnchants[$subId], $data); - $jsonEquip = []; - $jsonText = []; - - for ($i = 1; $i < 6; $i++) - { - $enchId = $data['enchantId'.$i]; - if ($enchId <= 0 || empty($this->rndEnchIds[$enchId])) - continue; - - if ($data['allocationPct'.$i] > 0) // RandomSuffix: scaling Enchantment; enchId < 0 - { - $qty = intVal($data['allocationPct'.$i] * $this->generateEnchSuffixFactor()); - $stats = array_fill_keys(array_keys($this->rndEnchIds[$enchId]['stats']), $qty); - - $jsonText[$enchId] = str_replace('$i', $qty, $this->rndEnchIds[$enchId]['text']); - Util::arraySumByKey($jsonEquip, $stats); - } - else // RandomProperty: static Enchantment; enchId > 0 - { - $jsonText[$enchId] = $this->rndEnchIds[$enchId]['text']; - Util::arraySumByKey($jsonEquip, $this->rndEnchIds[$enchId]['stats']); - } - } - - $this->subItems[$mstItem][$subId] = array( - 'name' => Util::localizedString($data, 'name'), - 'enchantment' => $jsonText, - 'jsonequip' => $jsonEquip, - 'chance' => $data['chance'] // hmm, only needed for item detail page... - ); - } - - if (!empty($this->subItems[$mstItem])) - $this->json[$mstItem]['subitems'] = $this->subItems[$mstItem]; - } - } - - public function getScoreTotal($class = 0, $spec = [], $mhItem = 0, $ohItem = 0) - { - if (!$class || !$spec) - return array_sum(array_column($this->json, 'gearscore')); - - $score = 0.0; - $mh = $oh = []; - - foreach ($this->json as $j) - { - if ($j['id'] == $mhItem) - $mh = $j; - else if ($j['id'] == $ohItem) - $oh = $j; - else if ($j['gearscore']) - { - if ($j['slot'] == INVTYPE_RELIC) - $score += 20; - - $score += round($j['gearscore']); - } - } - - $score += array_sum(Util::fixWeaponScores($class, $spec, $mh, $oh)); - - return $score; - } - - private function initJsonStats() - { - $json = array( - 'id' => $this->id, - 'name' => $this->getField('name', true), - 'quality' => ITEM_QUALITY_HEIRLOOM - $this->curTpl['quality'], - 'icon' => $this->curTpl['iconString'], - 'classs' => $this->curTpl['class'], - 'subclass' => $this->curTpl['subClass'], - 'subsubclass' => $this->curTpl['subSubClass'], - 'heroic' => ($this->curTpl['flags'] & 0x8) >> 3, - 'side' => $this->curTpl['flagsExtra'] & 0x3 ? 3 - ($this->curTpl['flagsExtra'] & 0x3) : Game::sideByRaceMask($this->curTpl['requiredRace']), - 'slot' => $this->curTpl['slot'], - 'slotbak' => $this->curTpl['slotBak'], - 'level' => $this->curTpl['itemLevel'], - 'reqlevel' => $this->curTpl['requiredLevel'], - 'displayid' => $this->curTpl['displayId'], - // 'commondrop' => 'true' / null // set if the item is a loot-filler-item .. check common ref-templates..? - 'holres' => $this->curTpl['resHoly'], - 'firres' => $this->curTpl['resFire'], - 'natres' => $this->curTpl['resNature'], - 'frores' => $this->curTpl['resFrost'], - 'shares' => $this->curTpl['resShadow'], - 'arcres' => $this->curTpl['resArcane'], - 'armorbonus' => max(0, intVal($this->curTpl['armorDamageModifier'])), - 'armor' => $this->curTpl['armor'], - 'dura' => $this->curTpl['durability'], - 'itemset' => $this->curTpl['itemset'], - 'socket1' => $this->curTpl['socketColor1'], - 'socket2' => $this->curTpl['socketColor2'], - 'socket3' => $this->curTpl['socketColor3'], - 'nsockets' => ($this->curTpl['socketColor1'] > 0 ? 1 : 0) + ($this->curTpl['socketColor2'] > 0 ? 1 : 0) + ($this->curTpl['socketColor3'] > 0 ? 1 : 0), - 'socketbonus' => $this->curTpl['socketBonus'], - 'scadist' => $this->curTpl['scalingStatDistribution'], - 'scaflags' => $this->curTpl['scalingStatValue'] - ); - - if ($this->curTpl['class'] == ITEM_CLASS_WEAPON || $this->curTpl['class'] == ITEM_CLASS_AMMUNITION) - { - - $json['dmgtype1'] = $this->curTpl['dmgType1']; - $json['dmgmin1'] = $this->curTpl['dmgMin1'] + $this->curTpl['dmgMin2']; - $json['dmgmax1'] = $this->curTpl['dmgMax1'] + $this->curTpl['dmgMax2']; - $json['speed'] = number_format($this->curTpl['delay'] / 1000, 2); - $json['dps'] = !floatVal($json['speed']) ? 0 : number_format(($json['dmgmin1'] + $json['dmgmax1']) / (2 * $json['speed']), 1); - - if (in_array($json['subclass'], [2, 3, 18, 19])) - { - $json['rgddmgmin'] = $json['dmgmin1']; - $json['rgddmgmax'] = $json['dmgmax1']; - $json['rgdspeed'] = $json['speed']; - $json['rgddps'] = $json['dps']; - } - else if ($json['classs'] != ITEM_CLASS_AMMUNITION) - { - $json['mledmgmin'] = $json['dmgmin1']; - $json['mledmgmax'] = $json['dmgmax1']; - $json['mlespeed'] = $json['speed']; - $json['mledps'] = $json['dps']; - } - - if ($fap = $this->getFeralAP()) - $json['feratkpwr'] = $fap; - } - - if ($this->curTpl['class'] == ITEM_CLASS_ARMOR || $this->curTpl['class'] == ITEM_CLASS_WEAPON) - $json['gearscore'] = Util::getEquipmentScore($json['level'], $this->getField('quality'), $json['slot'], $json['nsockets']); - else if ($this->curTpl['class'] == ITEM_CLASS_GEM) - $json['gearscore'] = Util::getGemScore($json['level'], $this->getField('quality'), $this->getField('requiredSkill') == 755, $this->id); - - // clear zero-values afterwards - foreach ($json as $k => $v) - if (!$v && !in_array($k, ['classs', 'subclass', 'quality', 'side', 'gearscore'])) - unset($json[$k]); - - Util::checkNumeric($json); - - $this->json[$json['id']] = $json; - } - - public function addRewardsToJScript(&$ref) { } -} - - -class ItemListFilter extends Filter -{ - private $ubFilter = []; // usable-by - limit weapon/armor selection per CharClass - itemClass => available itemsubclasses - private $extCostQuery = 'SELECT item FROM npc_vendor WHERE extendedCost IN (?a) UNION - SELECT item FROM game_event_npc_vendor WHERE extendedCost IN (?a)'; - private $otFields = [18 => 4, 68 => 15, 69 => 16, 70 => 17, 72 => 2, 73 => 19, 75 => 21, 76 => 23, 88 => 20, 92 => 5, 93 => 3, 143 => 18, 171 => 8, 172 => 12]; - - public $extraOpts = []; // score for statWeights - public $wtCnd = []; - protected $enums = array( - 99 => array( // profession | recycled for 86, 87 - null, 171, 164, 185, 333, 202, 129, 755, 165, 186, 197, true, false, 356, 182, 773 - ), - 66 => array( // profession specialization - 1 => -1, - 2 => [ 9788, 9787, 17041, 17040, 17039 ], - 3 => -1, - 4 => -1, - 5 => [20219, 20222 ], - 6 => -1, - 7 => -1, - 8 => [10656, 10658, 10660 ], - 9 => -1, - 10 => [26798, 26801, 26797 ], - 11 => [ 9788, 9787, 17041, 17040, 17039, 20219, 20222, 10656, 10658, 10660, 26798, 26801, 26797], // i know, i know .. lazy as fuck - 12 => false, - 13 => -1, - 14 => -1, - 15 => -1 - ), - 152 => array( // class-specific - null, 1, 2, 3, 4, 5, 6, 7, 8, 9, null, 11, true, false - ), - 153 => array( // race-specific - null, 1, 2, 3, 4, 5, 6, 7, 8, null, 10, 11, true, false - ), - 158 => array( // currency - 32572, 32569, 29736, 44128, 20560, 20559, 29434, 37829, 23247, 44990, 24368, 52027, 52030, 43016, 41596, 34052, 45624, 49426, 40752, 47241, 40753, 29024, - 24245, 26045, 26044, 38425, 29735, 24579, 24581, 32897, 22484, 52026, 52029, 4291, 28558, 43228, 34664, 47242, 52025, 52028, 37836, 20558, 34597, 43589 - ), - 118 => array( // tokens - 34853, 34854, 34855, 34856, 34857, 34858, 34848, 34851, 34852, 40625, 40626, 40627, 45632, 45633, 45634, 34169, 34186, 29754, 29753, 29755, 31089, 31091, 31090, - 40610, 40611, 40612, 30236, 30237, 30238, 45635, 45636, 45637, 34245, 34332, 34339, 34345, 40631, 40632, 40633, 45638, 45639, 45640, 34244, 34208, 34180, 34229, - 34350, 40628, 40629, 40630, 45641, 45642, 45643, 29757, 29758, 29756, 31092, 31094, 31093, 40613, 40614, 40615, 30239, 30240, 30241, 45644, 45645, 45646, 34342, - 34211, 34243, 29760, 29761, 29759, 31097, 31095, 31096, 40616, 40617, 40618, 30242, 30243, 30244, 45647, 45648, 45649, 34216, 29766, 29767, 29765, 31098, 31100, - 31099, 40619, 40620, 40621, 30245, 30246, 30247, 45650, 45651, 45652, 34167, 40634, 40635, 40636, 45653, 45654, 45655, 40637, 40638, 40639, 45656, 45657, 45658, - 34170, 34192, 29763, 29764, 29762, 31101, 31103, 31102, 30248, 30249, 30250, 47557, 47558, 47559, 34233, 34234, 34202, 34195, 34209, 40622, 40623, 40624, 34193, - 45659, 45660, 45661, 34212, 34351, 34215 - ), - 128 => array( // source - 1 => true, // Any - 2 => false, // None - 3 => 1, // Crafted - 4 => 2, // Drop - 5 => 3, // PvP - 6 => 4, // Quest - 7 => 5, // Vendor - 9 => 10, // Starter - 10 => 11, // Event - 11 => 12 // Achievement - ), - 126 => array( // Zones - 4494, 36, 2597, 3358, 45, 331, 3790, 4277, 16, 3524, 3, 3959, 719, 1584, 25, 1583, 2677, 3702, 3522, 4, 3525, 3537, 46, 1941, - 2918, 3905, 4024, 2817, 4395, 4378, 148, 393, 1657, 41, 2257, 405, 2557, 65, 4196, 1, 14, 10, 15, 139, 12, 3430, 3820, 361, - 357, 3433, 721, 394, 3923, 4416, 2917, 4272, 4820, 4264, 3483, 3562, 267, 495, 4742, 3606, 210, 4812, 1537, 4710, 4080, 3457, 38, 4131, - 3836, 3792, 2100, 2717, 493, 215, 3518, 3698, 3456, 3523, 2367, 2159, 1637, 4813, 4298, 2437, 722, 491, 44, 3429, 3968, 796, 2057, 51, - 3607, 3791, 3789, 209, 3520, 3703, 3711, 1377, 3487, 130, 3679, 406, 1519, 4384, 33, 2017, 1477, 4075, 8, 440, 141, 3428, 3519, 3848, - 17, 2366, 3840, 3713, 3847, 3775, 4100, 1581, 3557, 3845, 4500, 4809, 47, 3849, 4265, 4493, 4228, 3698, 4406, 3714, 3717, 3715, 717, 67, - 3716, 457, 4415, 400, 1638, 1216, 85, 4723, 4722, 1337, 4273, 490, 1497, 206, 1196, 4603, 718, 3277, 28, 40, 11, 4197, 618, 3521, - 3805, 66, 1176, 1977 - ), - 163 => array( // enchantment mats - 34057, 22445, 11176, 34052, 11082, 34055, 16203, 10939, 11135, 11175, 22446, 16204, 34054, 14344, 11084, 11139, 22449, 11178, - 10998, 34056, 16202, 10938, 11134, 11174, 22447, 20725, 14343, 34053, 10978, 11138, 22448, 11177, 11083, 10940, 11137, 22450 - ) - ); - - // cr => [type, field, misc, extraCol] - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 2 => [FILTER_CR_CALLBACK, 'cbFieldHasVal', 'bonding', 1 ], // bindonpickup [yn] - 3 => [FILTER_CR_CALLBACK, 'cbFieldHasVal', 'bonding', 2 ], // bindonequip [yn] - 4 => [FILTER_CR_CALLBACK, 'cbFieldHasVal', 'bonding', 3 ], // bindonuse [yn] - 5 => [FILTER_CR_CALLBACK, 'cbFieldHasVal', 'bonding', [4, 5] ], // questitem [yn] - 6 => [FILTER_CR_CALLBACK, 'cbQuestRelation', null, null ], // startsquest [side] - 7 => [FILTER_CR_BOOLEAN, 'description_loc0', true ], // hasflavortext - 8 => [FILTER_CR_BOOLEAN, 'requiredDisenchantSkill' ], // disenchantable - 9 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_CONJURED ], // conjureditem - 10 => [FILTER_CR_BOOLEAN, 'lockId' ], // locked - 11 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_OPENABLE ], // openable - 12 => [FILTER_CR_BOOLEAN, 'itemset' ], // partofset - 13 => [FILTER_CR_BOOLEAN, 'randomEnchant' ], // randomlyenchanted - 14 => [FILTER_CR_BOOLEAN, 'pageTextId' ], // readable - 15 => [FILTER_CR_CALLBACK, 'cbFieldHasVal', 'maxCount', 1 ], // unique [yn] - 16 => [FILTER_CR_NYI_PH, null, 1, ], // dropsin [zone] - 17 => [FILTER_CR_ENUM, 'requiredFaction' ], // requiresrepwith - 18 => [FILTER_CR_CALLBACK, 'cbFactionQuestReward', null, null ], // rewardedbyfactionquest [side] - 20 => [FILTER_CR_NUMERIC, 'is.str', NUM_CAST_INT, true ], // str - 21 => [FILTER_CR_NUMERIC, 'is.agi', NUM_CAST_INT, true ], // agi - 22 => [FILTER_CR_NUMERIC, 'is.sta', NUM_CAST_INT, true ], // sta - 23 => [FILTER_CR_NUMERIC, 'is.int', NUM_CAST_INT, true ], // int - 24 => [FILTER_CR_NUMERIC, 'is.spi', NUM_CAST_INT, true ], // spi - 25 => [FILTER_CR_NUMERIC, 'is.arcres', NUM_CAST_INT, true ], // arcres - 26 => [FILTER_CR_NUMERIC, 'is.firres', NUM_CAST_INT, true ], // firres - 27 => [FILTER_CR_NUMERIC, 'is.natres', NUM_CAST_INT, true ], // natres - 28 => [FILTER_CR_NUMERIC, 'is.frores', NUM_CAST_INT, true ], // frores - 29 => [FILTER_CR_NUMERIC, 'is.shares', NUM_CAST_INT, true ], // shares - 30 => [FILTER_CR_NUMERIC, 'is.holres', NUM_CAST_INT, true ], // holres - 32 => [FILTER_CR_NUMERIC, 'is.dps', NUM_CAST_FLOAT, true ], // dps - 33 => [FILTER_CR_NUMERIC, 'is.dmgmin1', NUM_CAST_INT, true ], // dmgmin1 - 34 => [FILTER_CR_NUMERIC, 'is.dmgmax1', NUM_CAST_INT, true ], // dmgmax1 - 35 => [FILTER_CR_CALLBACK, 'cbDamageType', null, null ], // damagetype [enum] - 36 => [FILTER_CR_NUMERIC, 'is.speed', NUM_CAST_FLOAT, true ], // speed - 37 => [FILTER_CR_NUMERIC, 'is.mleatkpwr', NUM_CAST_INT, true ], // mleatkpwr - 38 => [FILTER_CR_NUMERIC, 'is.rgdatkpwr', NUM_CAST_INT, true ], // rgdatkpwr - 39 => [FILTER_CR_NUMERIC, 'is.rgdhitrtng', NUM_CAST_INT, true ], // rgdhitrtng - 40 => [FILTER_CR_NUMERIC, 'is.rgdcritstrkrtng', NUM_CAST_INT, true ], // rgdcritstrkrtng - 41 => [FILTER_CR_NUMERIC, 'is.armor', NUM_CAST_INT, true ], // armor - 42 => [FILTER_CR_NUMERIC, 'is.defrtng', NUM_CAST_INT, true ], // defrtng - 43 => [FILTER_CR_NUMERIC, 'is.block', NUM_CAST_INT, true ], // block - 44 => [FILTER_CR_NUMERIC, 'is.blockrtng', NUM_CAST_INT, true ], // blockrtng - 45 => [FILTER_CR_NUMERIC, 'is.dodgertng', NUM_CAST_INT, true ], // dodgertng - 46 => [FILTER_CR_NUMERIC, 'is.parryrtng', NUM_CAST_INT, true ], // parryrtng - 48 => [FILTER_CR_NUMERIC, 'is.splhitrtng', NUM_CAST_INT, true ], // splhitrtng - 49 => [FILTER_CR_NUMERIC, 'is.splcritstrkrtng', NUM_CAST_INT, true ], // splcritstrkrtng - 50 => [FILTER_CR_NUMERIC, 'is.splheal', NUM_CAST_INT, true ], // splheal - 51 => [FILTER_CR_NUMERIC, 'is.spldmg', NUM_CAST_INT, true ], // spldmg - 52 => [FILTER_CR_NUMERIC, 'is.arcsplpwr', NUM_CAST_INT, true ], // arcsplpwr - 53 => [FILTER_CR_NUMERIC, 'is.firsplpwr', NUM_CAST_INT, true ], // firsplpwr - 54 => [FILTER_CR_NUMERIC, 'is.frosplpwr', NUM_CAST_INT, true ], // frosplpwr - 55 => [FILTER_CR_NUMERIC, 'is.holsplpwr', NUM_CAST_INT, true ], // holsplpwr - 56 => [FILTER_CR_NUMERIC, 'is.natsplpwr', NUM_CAST_INT, true ], // natsplpwr - 57 => [FILTER_CR_NUMERIC, 'is.shasplpwr', NUM_CAST_INT, true ], // shasplpwr - 59 => [FILTER_CR_NUMERIC, 'durability', NUM_CAST_INT, true ], // dura - 60 => [FILTER_CR_NUMERIC, 'is.healthrgn', NUM_CAST_INT, true ], // healthrgn - 61 => [FILTER_CR_NUMERIC, 'is.manargn', NUM_CAST_INT, true ], // manargn - 62 => [FILTER_CR_CALLBACK, 'cbCooldown', null, null ], // cooldown [op] [int] - 63 => [FILTER_CR_NUMERIC, 'buyPrice', NUM_CAST_INT, true ], // buyprice - 64 => [FILTER_CR_NUMERIC, 'sellPrice', NUM_CAST_INT, true ], // sellprice - 65 => [FILTER_CR_CALLBACK, 'cbAvgMoneyContent', null, null ], // avgmoney [op] [int] - 66 => [FILTER_CR_ENUM, 'requiredSpell' ], // requiresprofspec - 68 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otdisenchanting [yn] - 69 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otfishing [yn] - 70 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otherbgathering [yn] - 71 => [FILTER_CR_FLAG, 'cuFlags', ITEM_CU_OT_ITEMLOOT ], // otitemopening [yn] - 72 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otlooting [yn] - 73 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otmining [yn] - 74 => [FILTER_CR_FLAG, 'cuFlags', ITEM_CU_OT_OBJECTLOOT ], // otobjectopening [yn] - 75 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otpickpocketing [yn] - 76 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otskinning [yn] - 77 => [FILTER_CR_NUMERIC, 'is.atkpwr', NUM_CAST_INT, true ], // atkpwr - 78 => [FILTER_CR_NUMERIC, 'is.mlehastertng', NUM_CAST_INT, true ], // mlehastertng - 79 => [FILTER_CR_NUMERIC, 'is.resirtng', NUM_CAST_INT, true ], // resirtng - 80 => [FILTER_CR_CALLBACK, 'cbHasSockets', null, null ], // has sockets [enum] - 81 => [FILTER_CR_CALLBACK, 'cbFitsGemSlot', null, null ], // fits gem slot [enum] - 83 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_UNIQUEEQUIPPED ], // uniqueequipped - 84 => [FILTER_CR_NUMERIC, 'is.mlecritstrkrtng', NUM_CAST_INT, true ], // mlecritstrkrtng - 85 => [FILTER_CR_CALLBACK, 'cbObjectiveOfQuest', null, null ], // objectivequest [side] - 86 => [FILTER_CR_CALLBACK, 'cbCraftedByProf', null, null ], // craftedprof [enum] - 87 => [FILTER_CR_CALLBACK, 'cbReagentForAbility', null, null ], // reagentforability [enum] - 88 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otprospecting [yn] - 89 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_PROSPECTABLE ], // prospectable - 90 => [FILTER_CR_CALLBACK, 'cbAvgBuyout', null, null ], // avgbuyout [op] [int] - 91 => [FILTER_CR_ENUM, 'totemCategory' ], // tool - 92 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // soldbyvendor [yn] - 93 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otpvp [pvp] - 94 => [FILTER_CR_NUMERIC, 'is.splpen', NUM_CAST_INT, true ], // splpen - 95 => [FILTER_CR_NUMERIC, 'is.mlehitrtng', NUM_CAST_INT, true ], // mlehitrtng - 96 => [FILTER_CR_NUMERIC, 'is.critstrkrtng', NUM_CAST_INT, true ], // critstrkrtng - 97 => [FILTER_CR_NUMERIC, 'is.feratkpwr', NUM_CAST_INT, true ], // feratkpwr - 98 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_PARTYLOOT ], // partyloot - 99 => [FILTER_CR_ENUM, 'requiredSkill' ], // requiresprof - 100 => [FILTER_CR_NUMERIC, 'is.nsockets', NUM_CAST_INT ], // nsockets - 101 => [FILTER_CR_NUMERIC, 'is.rgdhastertng', NUM_CAST_INT, true ], // rgdhastertng - 102 => [FILTER_CR_NUMERIC, 'is.splhastertng', NUM_CAST_INT, true ], // splhastertng - 103 => [FILTER_CR_NUMERIC, 'is.hastertng', NUM_CAST_INT, true ], // hastertng - 104 => [FILTER_CR_STRING, 'description', STR_LOCALIZED ], // flavortext - 105 => [FILTER_CR_NYI_PH, null, 1, ], // dropsinnormal [heroicdungeon-any] - 106 => [FILTER_CR_NYI_PH, null, 1, ], // dropsinheroic [heroicdungeon-any] - 107 => [FILTER_CR_NYI_PH, null, 1, ], // effecttext [str] not yet parsed ['effectsParsed_loc'.User::$localeId, $cr[2]] - 109 => [FILTER_CR_CALLBACK, 'cbArmorBonus', null, null ], // armorbonus [op] [int] - 111 => [FILTER_CR_NUMERIC, 'requiredSkillRank', NUM_CAST_INT, true ], // reqskillrank - 113 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 114 => [FILTER_CR_NUMERIC, 'is.armorpenrtng', NUM_CAST_INT, true ], // armorpenrtng - 115 => [FILTER_CR_NUMERIC, 'is.health', NUM_CAST_INT, true ], // health - 116 => [FILTER_CR_NUMERIC, 'is.mana', NUM_CAST_INT, true ], // mana - 117 => [FILTER_CR_NUMERIC, 'is.exprtng', NUM_CAST_INT, true ], // exprtng - 118 => [FILTER_CR_CALLBACK, 'cbPurchasableWith', null, null ], // purchasablewithitem [enum] - 119 => [FILTER_CR_NUMERIC, 'is.hitrtng', NUM_CAST_INT, true ], // hitrtng - 123 => [FILTER_CR_NUMERIC, 'is.splpwr', NUM_CAST_INT, true ], // splpwr - 124 => [FILTER_CR_CALLBACK, 'cbHasRandEnchant', null, null ], // randomenchants [str] - 125 => [FILTER_CR_CALLBACK, 'cbReqArenaRating', null, null ], // reqarenartng [op] [int] todo (low): 'find out, why "IN (W, X, Y) AND IN (X, Y, Z)" doesn't result in "(X, Y)" - 126 => [FILTER_CR_CALLBACK, 'cbQuestRewardIn', null, null ], // rewardedbyquestin [zone-any] - 128 => [FILTER_CR_CALLBACK, 'cbSource', null, null ], // source [enum] - 129 => [FILTER_CR_CALLBACK, 'cbSoldByNPC', null, null ], // soldbynpc [str-small] - 130 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 132 => [FILTER_CR_CALLBACK, 'cbGlyphType', null, null ], // glyphtype [enum] - 133 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_ACCOUNTBOUND ], // accountbound - 134 => [FILTER_CR_NUMERIC, 'is.mledps', NUM_CAST_FLOAT, true ], // mledps - 135 => [FILTER_CR_NUMERIC, 'is.mledmgmin', NUM_CAST_INT, true ], // mledmgmin - 136 => [FILTER_CR_NUMERIC, 'is.mledmgmax', NUM_CAST_INT, true ], // mledmgmax - 137 => [FILTER_CR_NUMERIC, 'is.mlespeed', NUM_CAST_FLOAT, true ], // mlespeed - 138 => [FILTER_CR_NUMERIC, 'is.rgddps', NUM_CAST_FLOAT, true ], // rgddps - 139 => [FILTER_CR_NUMERIC, 'is.rgddmgmin', NUM_CAST_INT, true ], // rgddmgmin - 140 => [FILTER_CR_NUMERIC, 'is.rgddmgmax', NUM_CAST_INT, true ], // rgddmgmax - 141 => [FILTER_CR_NUMERIC, 'is.rgdspeed', NUM_CAST_FLOAT, true ], // rgdspeed - 142 => [FILTER_CR_STRING, 'ic.name' ], // icon - 143 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otmilling [yn] - 144 => [FILTER_CR_CALLBACK, 'cbPvpPurchasable', 'reqHonorPoints', null ], // purchasablewithhonor [yn] - 145 => [FILTER_CR_CALLBACK, 'cbPvpPurchasable', 'reqHonorPoints', null ], // purchasablewitharena [yn] - 146 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_HEROIC ], // heroic - 147 => [FILTER_CR_NYI_PH, null, 1, ], // dropsinnormal10 [multimoderaid-any] - 148 => [FILTER_CR_NYI_PH, null, 1, ], // dropsinnormal25 [multimoderaid-any] - 149 => [FILTER_CR_NYI_PH, null, 1, ], // dropsinheroic10 [heroicraid-any] - 150 => [FILTER_CR_NYI_PH, null, 1, ], // dropsinheroic25 [heroicraid-any] - 151 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true ], // id - 152 => [FILTER_CR_CALLBACK, 'cbClassRaceSpec', 'requiredClass', CLASS_MASK_ALL], // classspecific [enum] - 153 => [FILTER_CR_CALLBACK, 'cbClassRaceSpec', 'requiredRace', RACE_MASK_ALL ], // racespecific [enum] - 154 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_REFUNDABLE ], // refundable - 155 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_USABLE_ARENA ], // usableinarenas - 156 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_USABLE_SHAPED ], // usablewhenshapeshifted - 157 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_SMARTLOOT ], // smartloot - 158 => [FILTER_CR_CALLBACK, 'cbPurchasableWith', null, null ], // purchasablewithcurrency [enum] - 159 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_MILLABLE ], // millable - 160 => [FILTER_CR_NYI_PH, null, 1, ], // relatedevent [enum] like 169 .. crawl though npc_vendor and loot_templates of event-related spawns - 161 => [FILTER_CR_CALLBACK, 'cbAvailable', null, null ], // availabletoplayers [yn] - 162 => [FILTER_CR_FLAG, 'flags', ITEM_FLAG_DEPRECATED ], // deprecated - 163 => [FILTER_CR_CALLBACK, 'cbDisenchantsInto', null, null ], // disenchantsinto [disenchanting] - 165 => [FILTER_CR_NUMERIC, 'repairPrice', NUM_CAST_INT, true ], // repaircost - 167 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 168 => [FILTER_CR_CALLBACK, 'cbFieldHasVal', 'spellId1', [483, 55884] ], // teachesspell [yn] - 483: learn recipe; 55884: learn mount/pet - 169 => [FILTER_CR_ENUM, 'e.holidayId' ], // requiresevent - 171 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // otredemption [yn] - 172 => [FILTER_CR_CALLBACK, 'cbObtainedBy', null, null ], // rewardedbyachievement [yn] - 176 => [FILTER_CR_STAFFFLAG, 'flags' ], // flags - 177 => [FILTER_CR_STAFFFLAG, 'flagsExtra' ], // flags2 - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'wt' => [FILTER_V_CALLBACK, 'cbWeightKeyCheck', true ], // weight keys - 'wtv' => [FILTER_V_RANGE, [1, 999], true ], // weight values - 'jc' => [FILTER_V_LIST, [1], false], // use jewelcrafter gems for weight calculation - 'gm' => [FILTER_V_LIST, [2, 3, 4], false], // gem rarity for weight calculation - 'cr' => [FILTER_V_RANGE, [1, 177], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 99999]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/[\p{C};:%\\\\]/ui', true ], // criteria values - only printable chars, no delimiters - 'upg' => [FILTER_V_RANGE, [1, 999999], true ], // upgrade item ids - 'gb' => [FILTER_V_LIST, [0, 1, 2, 3], false], // search result grouping - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'ub' => [FILTER_V_LIST, [[1, 9], 11], false], // usable by classId - 'qu' => [FILTER_V_RANGE, [0, 7], true ], // quality ids - 'ty' => [FILTER_V_CALLBACK, 'cbTypeCheck', true ], // item type - dynamic by current group - 'sl' => [FILTER_V_CALLBACK, 'cbSlotCheck', true ], // item slot - dynamic by current group - 'si' => [FILTER_V_LIST, [1, 2, 3, -1, -2], false], // side - 'minle' => [FILTER_V_RANGE, [1, 999], false], // item level min - 'maxle' => [FILTER_V_RANGE, [1, 999], false], // item level max - 'minrl' => [FILTER_V_RANGE, [1, MAX_LEVEL], false], // required level min - 'maxrl' => [FILTER_V_RANGE, [1, MAX_LEVEL], false] // required level max - ); - - public function __construct($fromPOST = false, $opts = []) - { - $classes = new CharClassList(); - foreach ($classes->iterate() as $cId => $_tpl) - { - // preselect misc subclasses - $this->ubFilter[$cId] = [ITEM_CLASS_WEAPON => [14], ITEM_CLASS_ARMOR => [0]]; - - for ($i = 0; $i < 21; $i++) - if ($_tpl['weaponTypeMask'] & (1 << $i)) - $this->ubFilter[$cId][ITEM_CLASS_WEAPON][] = $i; - - for ($i = 0; $i < 11; $i++) - if ($_tpl['armorTypeMask'] & (1 << $i)) - $this->ubFilter[$cId][ITEM_CLASS_ARMOR][] = $i; - } - - parent::__construct($fromPOST, $opts); - } - - public function createConditionsForWeights() - { - if (empty($this->fiData['v']['wt'])) - return null; - - $this->wtCnd = []; - $select = []; - $wtSum = 0; - - foreach ($this->fiData['v']['wt'] as $k => $v) - { - $str = Util::$itemFilter[$v]; - $qty = intVal($this->fiData['v']['wtv'][$k]); - - if ($str == 'rgdspeed') // dont need no duplicate column - $str = 'speed'; - - if ($str == 'mledps') // todo (med): unify rngdps and mledps to dps - $str = 'dps'; - - $select[] = '(`is`.`'.$str.'` * '.$qty.')'; - $this->wtCnd[] = ['is.'.$str, 0, '>']; - $wtSum += $qty; - } - - if (count($this->wtCnd) > 1) - array_unshift($this->wtCnd, 'OR'); - else if (count($this->wtCnd) == 1) - $this->wtCnd = $this->wtCnd[0]; - - if ($select) - { - $this->extraOpts['is']['s'][] = ', IF(is.typeId IS NULL, 0, ('.implode(' + ', $select).') / '.$wtSum.') AS score'; - $this->extraOpts['is']['o'][] = 'score DESC'; - $this->extraOpts['i']['o'][] = null; // remove default ordering - } - else - $this->extraOpts['is']['s'][] = ', 0 AS score'; // prevent errors - - return $this->wtCnd; - } - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = $this->fiData['v']; - - // weights - if (!empty($_v['wt']) && !empty($_v['wtv'])) - { - // gm - gem quality (qualityId) - // jc - jc-gems included (bool) - - $parts[] = $this->createConditionsForWeights(); - - foreach ($_v['wt'] as $_) - $this->formData['extraCols'][] = $_; - } - - // upgrade for [form only] - if (isset($_v['upg'])) - { - $_ = DB::Aowow()->selectCol('SELECT id as ARRAY_KEY, slot FROM ?_items WHERE class IN (2, 3, 4) AND id IN (?a)', (array)$_v['upg']); - if ($_ === null) - { - unset($_v['upg']); - unset($this->formData['form']['upg']); - } - else - { - $this->formData['form']['upg'] = $_; - if ($_) - $parts[] = ['slot', $_]; - } - } - - // group by [form only] - if (isset($_v['gb'])) - $this->formData['form']['gb'] = $_v['gb']; - - // name - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name_loc'.User::$localeId])) - $parts[] = $_; - - // usable-by (not excluded by requiredClass && armor or weapons match mask from ?_classes) - if (isset($_v['ub'])) - { - $parts[] = array( - 'AND', - ['OR', ['requiredClass', 0], ['requiredClass', $this->list2Mask((array)$_v['ub']), '&']], - [ - 'OR', - ['class', [2, 4], '!'], - ['AND', ['class', 2], ['subclassbak', $this->ubFilter[$_v['ub']][ITEM_CLASS_WEAPON]]], - ['AND', ['class', 4], ['subclassbak', $this->ubFilter[$_v['ub']][ITEM_CLASS_ARMOR]]] - ] - ); - } - - // quality [list] - if (isset($_v['qu'])) - $parts[] = ['quality', $_v['qu']]; - - // type - if (isset($_v['ty'])) - $parts[] = ['subclass', $_v['ty']]; - - // slot - if (isset($_v['sl'])) - $parts[] = ['slot', $_v['sl']]; - - // side - if (isset($_v['si'])) - { - $ex = [['requiredRace', RACE_MASK_ALL, '&'], RACE_MASK_ALL, '!']; - $notEx = ['OR', ['requiredRace', 0], [['requiredRace', RACE_MASK_ALL, '&'], RACE_MASK_ALL]]; - - switch ($_v['si']) - { - case 3: - $parts[] = $notEx; - break; - case 2: - $parts[] = ['AND', [['flagsExtra', 0x3, '&'], [0, 1]], ['OR', $notEx, ['requiredRace', RACE_MASK_HORDE, '&']]]; - break; - case -2: - $parts[] = ['OR', [['flagsExtra', 0x3, '&'], 1], ['AND', $ex, ['requiredRace', RACE_MASK_HORDE, '&']]]; - break; - case 1: - $parts[] = ['AND', [['flagsExtra', 0x3, '&'], [0, 2]], ['OR', $notEx, ['requiredRace', RACE_MASK_ALLIANCE, '&']]]; - break; - case -1: - $parts[] = ['OR', [['flagsExtra', 0x3, '&'], 2], ['AND', $ex, ['requiredRace', RACE_MASK_ALLIANCE, '&']]]; - break; - } - } - - // itemLevel min - if (isset($_v['minle'])) - $parts[] = ['itemLevel', $_v['minle'], '>=']; - - // itemLevel max - if (isset($_v['maxle'])) - $parts[] = ['itemLevel', $_v['maxle'], '<=']; - - // reqLevel min - if (isset($_v['minrl'])) - $parts[] = ['requiredLevel', $_v['minrl'], '>=']; - - // reqLevel max - if (isset($_v['maxrl'])) - $parts[] = ['requiredLevel', $_v['maxrl'], '<=']; - - return $parts; - } - - protected function cbFactionQuestReward($cr) - { - if (!isset($this->otFields[$cr[0]])) - return false; - - $field = 'src.src'.$this->otFields[$cr[0]]; - switch ($cr[1]) - { - case 1: // Yes - return [$field, null, '!']; - case 2: // Alliance - return [$field, 1]; - case 3: // Horde - return [$field, 2]; - case 4: // Both - return [$field, 3]; - case 5: // No - return [$field, null]; - } - - return false; - } - - protected function cbAvailable($cr) - { - if ($this->int2Bool($cr[1])) - return [['cuFlags', CUSTOM_UNAVAILABLE, '&'], 0, $cr[1] ? null : '!']; - - return false; - } - - protected function cbHasSockets($cr) - { - switch ($cr[1]) - { - case 5: // Yes - return ['is.nsockets', 0, '!']; - case 6: // No - return ['is.nsockets', 0]; - case 1: // Meta - case 2: // Red - case 3: // Yellow - case 4: // Blue - $mask = 1 << ($cr[1] - 1); - return ['OR', ['socketColor1', $mask], ['socketColor2', $mask], ['socketColor3', $mask]]; - } - - return false; - } - - protected function cbFitsGemSlot($cr) - { - switch ($cr[1]) - { - case 5: // Yes - return ['gemEnchantmentId', 0, '!']; - case 6: // No - return ['gemEnchantmentId', 0]; - case 1: // Meta - case 2: // Red - case 3: // Yellow - case 4: // Blue - $mask = 1 << ($cr[1] - 1); - return ['AND', ['gemEnchantmentId', 0, '!'], ['gemColorMask', $mask, '&']]; - } - } - - protected function cbGlyphType($cr) - { - switch ($cr[1]) - { - case 1: // Major - case 2: // Minor - return ['AND', ['class', 16], ['subSubClass', $cr[1]]]; - } - - return false; - } - - protected function cbHasRandEnchant($cr) - { - $randIds = DB::Aowow()->select('SELECT id AS ARRAY_KEY, ABS(id) AS `id`, name_loc?d, name_loc0 FROM ?_itemrandomenchant WHERE name_loc?d LIKE ?', User::$localeId, User::$localeId, '%'.$cr[2].'%'); - $tplIds = $randIds ? DB::World()->select('SELECT `entry`, `ench` FROM item_enchantment_template WHERE `ench` IN (?a)', array_column($randIds, 'id')) : []; - foreach ($tplIds as &$set) - { - $z = array_column($randIds, 'id'); - $x = array_search($set['ench'], $z); - if (isset($randIds[-$z[$x]])) - { - $set['entry'] *= -1; - $set['ench'] *= -1; - } - - $set['name'] = Util::localizedString($randIds[$set['ench']], 'name', true); - } - - // only enhance search results if enchantment by name is unique (implies only one enchantment per item is availabel) - if (count(array_unique(array_column($randIds, 'name_loc0'))) == 1) - $this->extraOpts['relEnchant'] = $tplIds; - - if ($tplIds) - return ['randomEnchant', array_column($tplIds, 'entry')]; - else - return [0]; // no results aren't really input errors - } - - protected function cbReqArenaRating($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - $this->formData['extraCols'][] = $cr[0]; - - $items = [0]; - if ($costs = DB::Aowow()->selectCol('SELECT id FROM ?_itemextendedcost WHERE reqPersonalrating '.$cr[1].' '.$cr[2])) - $items = DB::World()->selectCol($this->extCostQuery, $costs, $costs); - - return ['id', $items]; - } - - protected function cbClassRaceSpec($cr, $field, $mask) - { - if (!isset($this->enums[$cr[0]][$cr[1]])) - return false; - - $_ = $this->enums[$cr[0]][$cr[1]]; - if (is_bool($_)) - return $_ ? ['AND', [[$field, $mask, '&'], $mask, '!'], [$field, 0, '>']] : ['OR', [[$field, $mask, '&'], $mask], [$field, 0]]; - else if (is_int($_)) - return ['AND', [[$field, $mask, '&'], $mask, '!'], [$field, 1 << ($_ - 1), '&']]; - - return false; - } - - protected function cbDamageType($cr) - { - if (!$this->checkInput(FILTER_V_RANGE, [0, 6], $cr[1])) - return false; - - return ['OR', ['dmgType1', $cr[1]], ['dmgType2', $cr[1]]]; - } - - protected function cbArmorBonus($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_FLOAT) || !$this->int2Op($cr[1])) - return false; - - $this->formData['extraCols'][] = $cr[0]; - return ['AND', ['armordamagemodifier', $cr[2], $cr[1]], ['class', ITEM_CLASS_ARMOR]]; - } - - protected function cbCraftedByProf($cr) - { - if (!isset($this->enums[99][$cr[1]])) - return false; - - $_ = $this->enums[99][$cr[1]]; - if (is_bool($_)) - return ['src.src1', null, $_ ? '!' : null]; - else if (is_int($_)) - return ['s.skillLine1', $_]; - - return false; - } - - protected function cbQuestRewardIn($cr) - { - if (in_array($cr[1], $this->enums[$cr[0]])) - return ['AND', ['src.src4', null, '!'], ['src.moreZoneId', $cr[1]]]; - else if ($cr[1] == FILTER_ENUM_ANY) - return ['src.src4', null, '!']; // well, this seems a bit redundant.. - - return false; - } - - protected function cbPurchasableWith($cr) - { - if (in_array($cr[1], $this->enums[$cr[0]])) - $_ = (array)$cr[1]; - else if ($cr[1] == FILTER_ENUM_ANY) - $_ = $this->enums[$cr[0]]; - else - return false; - - $costs = DB::Aowow()->selectCol( - 'SELECT id FROM ?_itemextendedcost WHERE reqItemId1 IN (?a) OR reqItemId2 IN (?a) OR reqItemId3 IN (?a) OR reqItemId4 IN (?a) OR reqItemId5 IN (?a)', - $_, $_, $_, $_, $_ - ); - if ($items = DB::World()->selectCol($this->extCostQuery, $costs, $costs)) - return ['id', $items]; - } - - protected function cbSoldByNPC($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT)) - return false; - - if ($iIds = DB::World()->selectCol('SELECT item FROM npc_vendor WHERE entry = ?d UNION SELECT item FROM game_event_npc_vendor v JOIN creature c ON c.guid = v.guid WHERE c.id = ?d', $cr[2], $cr[2])) - return ['i.id', $iIds]; - else - return [0]; - } - - protected function cbAvgBuyout($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - foreach (Profiler::getRealms() as $rId => $__) - { - // todo: do something sensible.. - // // todo (med): get the avgbuyout into the listview - // if ($_ = DB::Characters()->select('SELECT ii.itemEntry AS ARRAY_KEY, AVG(ah.buyoutprice / ii.count) AS buyout FROM auctionhouse ah JOIN item_instance ii ON ah.itemguid = ii.guid GROUP BY ii.itemEntry HAVING buyout '.$cr[1].' ?f', $c[1])) - // return ['i.id', array_keys($_)]; - // else - // return [0]; - return [1]; - } - - return [0]; - } - - protected function cbAvgMoneyContent($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - $this->formData['extraCols'][] = $cr[0]; - return ['AND', ['flags', ITEM_FLAG_OPENABLE, '&'], ['((minMoneyLoot + maxMoneyLoot) / 2)', $cr[2], $cr[1]]]; - } - - protected function cbCooldown($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - $cr[2] *= 1000; // field supplied in milliseconds - - $this->formData['extraCols'][] = $cr[0]; - $this->extraOpts['is']['s'][] = ', IF(spellCooldown1 > 1, spellCooldown1, IF(spellCooldown2 > 1, spellCooldown2, IF(spellCooldown3 > 1, spellCooldown3, IF(spellCooldown4 > 1, spellCooldown4, IF(spellCooldown5 > 1, spellCooldown5,))))) AS cooldown'; - - return [ - 'OR', - ['AND', ['spellTrigger1', 0], ['spellId1', 0, '!'], ['spellCooldown1', 0, '>'], ['spellCooldown1', $cr[2], $cr[1]]], - ['AND', ['spellTrigger2', 0], ['spellId2', 0, '!'], ['spellCooldown2', 0, '>'], ['spellCooldown2', $cr[2], $cr[1]]], - ['AND', ['spellTrigger3', 0], ['spellId3', 0, '!'], ['spellCooldown3', 0, '>'], ['spellCooldown3', $cr[2], $cr[1]]], - ['AND', ['spellTrigger4', 0], ['spellId4', 0, '!'], ['spellCooldown4', 0, '>'], ['spellCooldown4', $cr[2], $cr[1]]], - ['AND', ['spellTrigger5', 0], ['spellId5', 0, '!'], ['spellCooldown5', 0, '>'], ['spellCooldown5', $cr[2], $cr[1]]], - ]; - } - - protected function cbQuestRelation($cr) - { - switch ($cr[1]) - { - case 1: // any - return ['startQuest', 0, '>']; - case 2: // exclude horde only - return ['AND', ['startQuest', 0, '>'], [['flagsExtra', 0x3, '&'], 2]]; - case 3: // exclude alliance only - return ['AND', ['startQuest', 0, '>'], [['flagsExtra', 0x3, '&'], 1]]; - case 4: // both - return ['AND', ['startQuest', 0, '>'], [['flagsExtra', 0x3, '&'], 0]]; - case 5: // none - return ['startQuest', 0]; - } - - return false; - } - - protected function cbFieldHasVal($cr, $field, $val) - { - if ($this->int2Bool($cr[1])) - return [$field, $val, $cr[1] ? null : '!']; - - return false; - } - - protected function cbObtainedBy($cr, $field) - { - if ($this->int2Bool($cr[1])) - return ['src.src'.$this->otFields[$cr[0]], null, $cr[1] ? '!' : null]; - - return false; - } - - protected function cbPvpPurchasable($cr, $field) - { - if (!$this->int2Bool($cr[1])) - return false; - - $costs = DB::Aowow()->selectCol('SELECT id FROM ?_itemextendedcost WHERE ?# > 0', $field); - if ($items = DB::World()->selectCol($this->extCostQuery, $costs, $costs)) - return ['id', $items, $cr[1] ? null : '!']; - - return false; - } - - protected function cbDisenchantsInto($cr) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT)) - return false; - - if (!in_array($cr[1], $this->enums[$cr[0]])) - return false; - - $refResults = []; - $newRefs = DB::World()->selectCol('SELECT entry FROM ?# WHERE item = ?d AND reference = 0', LOOT_REFERENCE, $cr[1]); - while ($newRefs) - { - $refResults += $newRefs; - $newRefs = DB::World()->selectCol('SELECT entry FROM ?# WHERE reference IN (?a)', LOOT_REFERENCE, $newRefs); - } - - $lootIds = DB::World()->selectCol('SELECT entry FROM ?# WHERE {reference IN (?a) OR }(reference = 0 AND item = ?d)', LOOT_DISENCHANT, $refResults ?: DBSIMPLE_SKIP, $cr[1]); - - return $lootIds ? ['disenchantId', $lootIds] : [0]; - } - - protected function cbObjectiveOfQuest($cr) - { - $w = ''; - switch ($cr[1]) - { - case 1: // Yes - case 5: // No - $w = 1; - break; - case 2: // Alliance - $w = 'reqRaceMask & '.RACE_MASK_ALLIANCE.' AND (reqRaceMask & '.RACE_MASK_HORDE.') = 0'; - break; - case 3: // Horde - $w = 'reqRaceMask & '.RACE_MASK_HORDE.' AND (reqRaceMask & '.RACE_MASK_ALLIANCE.') = 0'; - break; - case 4: // Both - $w = '(reqRaceMask & '.RACE_MASK_ALLIANCE.' AND reqRaceMask & '.RACE_MASK_HORDE.') OR reqRaceMask = 0'; - break; - default: - return false; - } - - $itemIds = DB::Aowow()->selectCol(sprintf(' - SELECT reqItemId1 FROM ?_quests WHERE %1$s UNION SELECT reqItemId2 FROM ?_quests WHERE %1$s UNION - SELECT reqItemId3 FROM ?_quests WHERE %1$s UNION SELECT reqItemId4 FROM ?_quests WHERE %1$s UNION - SELECT reqItemId5 FROM ?_quests WHERE %1$s UNION SELECT reqItemId6 FROM ?_quests WHERE %1$s', - $w - )); - - if ($itemIds) - return ['id', $itemIds, $cr[1] == 5 ? '!' : null]; - - return [0]; - } - - protected function cbReagentForAbility($cr) - { - if (!isset($this->enums[99][$cr[1]])) - return false; - - $_ = $this->enums[99][$cr[1]]; - if ($_ === null) - return false; - - $ids = []; - $spells = DB::Aowow()->select( // todo (med): hmm, selecting all using SpellList would exhaust 128MB of memory :x .. see, that we only select the fields that are really needed - 'SELECT reagent1, reagent2, reagent3, reagent4, reagent5, reagent6, reagent7, reagent8, - reagentCount1, reagentCount2, reagentCount3, reagentCount4, reagentCount5, reagentCount6, reagentCount7, reagentCount8 - FROM ?_spell - WHERE skillLine1 IN (?a)', - is_bool($_) ? array_filter($this->enums[99], "is_numeric") : $_ - ); - foreach ($spells as $spell) - for ($i = 1; $i < 9; $i++) - if ($spell['reagent'.$i] > 0 && $spell['reagentCount'.$i] > 0) - $ids[] = $spell['reagent'.$i]; - - if (empty($ids)) - return [0]; - else if ($_) - return ['id', $ids]; - else - return ['id', $ids, '!']; - } - - protected function cbSource($cr) - { - if (!isset($this->enums[$cr[0]][$cr[1]])) - return false; - - $_ = $this->enums[$cr[0]][$cr[1]]; - if (is_int($_)) // specific - return ['src.src'.$_, null, '!']; - else if ($_) // any - { - $foo = ['OR']; - foreach ($this->enums[$cr[0]] as $bar) - if (is_int($bar)) - $foo[] = ['src.src'.$bar, null, '!']; - - return $foo; - } - else // none - { - $foo = ['AND']; - foreach ($this->enums[$cr[0]] as $bar) - if (is_int($bar)) - $foo[] = ['src.src'.$bar, null]; - - return $foo; - } - } - - protected function cbTypeCheck(&$v) - { - if (!$this->parentCats) - return false; - - if (!Util::checkNumeric($v, NUM_REQ_INT)) - return false; - - $c = $this->parentCats; - - if (isset($c[2]) && is_array(Lang::item('cat', $c[0], 1, $c[1]))) - $catList = Lang::item('cat', $c[0], 1, $c[1], 1, $c[2]); - else if (isset($c[1]) && is_array(Lang::item('cat', $c[0]))) - $catList = Lang::item('cat', $c[0], 1, $c[1]); - else - $catList = Lang::item('cat', $c[0]); - - // consumables - always - if ($c[0] == 0) - return in_array($v, array_keys(Lang::item('cat', 0, 1))); - // weapons - only if parent - else if ($c[0] == 2 && !isset($c[1])) - return in_array($v, array_keys(Lang::spell('weaponSubClass'))); - // armor - only if parent - else if ($c[0] == 4 && !isset($c[1])) - return in_array($v, array_keys(Lang::item('cat', 4, 1))); - // uh ... other stuff... - else if (in_array($c[0], [1, 3, 7, 9, 15]) && !isset($c[1])) - return in_array($v, array_keys($catList[1])); - - return false; - } - - protected function cbSlotCheck(&$v) - { - if (!Util::checkNumeric($v, NUM_REQ_INT)) - return false; - - // todo (low): limit to concrete slots - $sl = array_keys(Lang::item('inventoryType')); - $c = $this->parentCats; - - // no selection - if (!isset($c[0])) - return in_array($v, $sl); - - // consumables - any; perm / temp item enhancements - else if ($c[0] == 0 && (!isset($c[1]) || in_array($c[1], [-3, 6]))) - return in_array($v, $sl); - - // weapons - always - else if ($c[0] == 2) - return in_array($v, $sl); - - // armor - any; any armor - else if ($c[0] == 4 && (!isset($c[1]) || in_array($c[1], [1, 2, 3, 4]))) - return in_array($v, $sl); - - return false; - } - - protected function cbWeightKeyCheck(&$v) - { - if (preg_match('/\W/i', $v)) - return false; - - return isset(Util::$itemFilter[$v]); - } -} - -?> diff --git a/includes/types/itemset.class.php b/includes/types/itemset.class.php deleted file mode 100644 index 12ed5638..00000000 --- a/includes/types/itemset.class.php +++ /dev/null @@ -1,261 +0,0 @@ - ['o' => 'maxlevel DESC'], - 'e' => ['j' => ['?_events e ON e.id = `set`.eventId', true], 's' => ', e.holidayId'] - ); - - public function __construct($conditions = []) - { - parent::__construct($conditions); - - // post processing - foreach ($this->iterate() as &$_curTpl) - { - $_curTpl['classes'] = []; - $_curTpl['pieces'] = []; - for ($i = 1; $i < 12; $i++) - { - if ($_curTpl['classMask'] & (1 << ($i - 1))) - { - $this->classes[] = $i; - $_curTpl['classes'][] = $i; - } - } - - for ($i = 1; $i < 10; $i++) - { - if ($piece = $_curTpl['item'.$i]) - { - $_curTpl['pieces'][] = $piece; - $this->pieceToSet[$piece] = $this->id; - } - } - } - $this->classes = array_unique($this->classes); - } - - public function getListviewData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'idbak' => $this->curTpl['refSetId'], - 'name' => (7 - $this->curTpl['quality']).$this->getField('name', true), - 'minlevel' => $this->curTpl['minLevel'], - 'maxlevel' => $this->curTpl['maxLevel'], - 'note' => $this->curTpl['contentGroup'], - 'type' => $this->curTpl['type'], - 'reqclass' => $this->curTpl['classMask'], - 'classes' => $this->curTpl['classes'], - 'pieces' => $this->curTpl['pieces'], - 'heroic' => $this->curTpl['heroic'] - ); - } - - return $data; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) - { - $data = []; - - if ($this->classes && ($addMask & GLOBALINFO_RELATED)) - $data[Type::CHR_CLASS] = array_combine($this->classes, $this->classes); - - if ($this->pieceToSet && ($addMask & GLOBALINFO_SELF)) - $data[Type::ITEM] = array_combine(array_keys($this->pieceToSet), array_keys($this->pieceToSet)); - - if ($addMask & GLOBALINFO_SELF) - foreach ($this->iterate() as $id => $__) - $data[Type::ITEMSET][$id] = ['name' => $this->getField('name', true)]; - - return $data; - } - - public function renderTooltip() - { - if (!$this->curTpl) - return array(); - - $x = '
'; - $x .= ''.$this->getField('name', true).'
'; - - $nCl = 0; - if ($_ = $this->getField('classMask')) - { - $jsg = []; - $cl = Lang::getClassString($_, $jsg); - $nCl = count($jsg); - $x .= Util::ucFirst($nCl > 1 ? Lang::game('classes') : Lang::game('class')).Lang::main('colon').$cl.'
'; - } - - if ($_ = $this->getField('contentGroup')) - $x .= Lang::itemset('notes', $_).($this->getField('heroic') ? ' ('.Lang::item('heroic').')' : '').'
'; - - if (!$nCl || !$this->getField('contentGroup')) - $x.= Lang::itemset('types', $this->getField('type')).'
'; - - if ($bonuses = $this->getBonuses()) - { - $x .= ''; - - foreach ($bonuses as $b) - $x .= '
'.$b['bonus'].' '.Lang::itemset('_pieces').Lang::main('colon').''.$b['desc']; - - $x .= '
'; - } - - $x .= '
'; - - return $x; - } - - public function getBonuses() - { - $spells = []; - for ($i = 1; $i < 9; $i++) - { - $spl = $this->getField('spell'.$i); - $qty = $this->getField('bonus'.$i); - - // cant use spell as index, would change order - if ($spl && $qty) - $spells[] = ['id' => $spl, 'bonus' => $qty]; - } - - // sort by required pieces ASC - usort($spells, function($a, $b) { - if ($a['bonus'] == $b['bonus']) - return 0; - - return ($a['bonus'] > $b['bonus']) ? 1 : -1; - }); - - $setSpells = new SpellList(array(['s.id', array_column($spells, 'id')])); - foreach ($setSpells->iterate() as $spellId => $__) - { - foreach ($spells as &$s) - { - if ($spellId != $s['id']) - continue; - - $s['desc'] = $setSpells->parseText('description', $this->getField('reqLevel') ?: MAX_LEVEL)[0]; - } - } - - return $spells; - } -} - - -// missing filter: "Available to Players" -class ItemsetListFilter extends Filter -{ - // cr => [type, field, misc, extraCol] - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 2 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true], // id - 3 => [FILTER_CR_NUMERIC, 'npieces', NUM_CAST_INT ], // pieces - 4 => [FILTER_CR_STRING, 'bonusText', STR_LOCALIZED ], // bonustext - 5 => [FILTER_CR_BOOLEAN, 'heroic', ], // heroic - 6 => [FILTER_CR_ENUM, 'e.holidayId', ], // relatedevent - 8 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 9 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 10 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 12 => [FILTER_CR_NYI_PH, null, 1 ] // available to players [yn] - ugh .. scan loot, quest and vendor templates and write to ?_itemset - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_RANGE, [2, 12], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 424]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/[\p{C};:%\\\\]/ui', true ], // criteria values - only printable chars, no delimiters - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name / description - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'qu' => [FILTER_V_RANGE, [0, 7], true ], // quality - 'ty' => [FILTER_V_RANGE, [1, 12], true ], // set type - 'minle' => [FILTER_V_RANGE, [1, 999], false], // min item level - 'maxle' => [FILTER_V_RANGE, [1, 999], false], // max itemlevel - 'minrl' => [FILTER_V_RANGE, [1, MAX_LEVEL], false], // min required level - 'maxrl' => [FILTER_V_RANGE, [1, MAX_LEVEL], false], // max required level - 'cl' => [FILTER_V_LIST, [[1, 9], 11], false], // class - 'ta' => [FILTER_V_RANGE, [1, 30], false] // tag / content group - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCR = $this->genericCriterion($cr)) - return $genCR; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = &$this->fiData['v']; - - // name [str] - if (isset($_v['na'])) - if ($_ = $this->modularizeString(['name_loc'.User::$localeId])) - $parts[] = $_; - - // quality [enum] - if (isset($_v['qu'])) - $parts[] = ['quality', $_v['qu']]; - - // type [enum] - if (isset($_v['ty'])) - $parts[] = ['type', $_v['ty']]; - - // itemLevel min [int] - if (isset($_v['minle'])) - $parts[] = ['minLevel', $_v['minle'], '>=']; - - // itemLevel max [int] - if (isset($_v['maxle'])) - $parts[] = ['maxLevel', $_v['maxle'], '<=']; - - // reqLevel min [int] - if (isset($_v['minrl'])) - $parts[] = ['reqLevel', $_v['minrl'], '>=']; - - // reqLevel max [int] - if (isset($_v['maxrl'])) - $parts[] = ['reqLevel', $_v['maxrl'], '<=']; - - // class [enum] - if (isset($_v['cl'])) - $parts[] = ['classMask', $this->list2Mask([$_v['cl']]), '&']; - - // tag [enum] - if (isset($_v['ta'])) - $parts[] = ['contentGroup', intVal($_v['ta'])]; - - return $parts; - } -} - -?> diff --git a/includes/types/profile.class.php b/includes/types/profile.class.php deleted file mode 100644 index ac7dce7b..00000000 --- a/includes/types/profile.class.php +++ /dev/null @@ -1,793 +0,0 @@ -iterate() as $__) - { - if (!$this->isVisibleToUser()) - continue; - - if (($addInfo & PROFILEINFO_PROFILE) && !$this->isCustom()) - continue; - - if (($addInfo & PROFILEINFO_CHARACTER) && $this->isCustom()) - continue; - - $data[$this->id] = array( - 'id' => $this->getField('id'), - 'name' => $this->getField('name'), - 'race' => $this->getField('race'), - 'classs' => $this->getField('class'), - 'gender' => $this->getField('gender'), - 'level' => $this->getField('level'), - 'faction' => (1 << ($this->getField('race') - 1)) & RACE_MASK_ALLIANCE ? 0 : 1, - 'talenttree1' => $this->getField('talenttree1'), - 'talenttree2' => $this->getField('talenttree2'), - 'talenttree3' => $this->getField('talenttree3'), - 'talentspec' => $this->getField('activespec') + 1, // 0 => 1; 1 => 2 - 'achievementpoints' => $this->getField('achievementpoints'), - 'guild' => '$"'.str_replace ('"', '', $this->curTpl['guildname']).'"',// force this to be a string - 'guildrank' => $this->getField('guildrank'), - 'realm' => Profiler::urlize($this->getField('realmName'), true), - 'realmname' => $this->getField('realmName'), - // 'battlegroup' => Profiler::urlize($this->getField('battlegroup')), // was renamed to subregion somewhere around cata release - // 'battlegroupname' => $this->getField('battlegroup'), - 'gearscore' => $this->getField('gearscore') - ); - - if ($addInfo & PROFILEINFO_USER) - $data[$this->id]['published'] = (int)!!($this->getField('cuFlags') & PROFILER_CU_PUBLISHED); - - // for the lv this determins if the link is profile= or profile=.. - if (!$this->isCustom()) - $data[$this->id]['region'] = Profiler::urlize($this->getField('region')); - - if ($addInfo & PROFILEINFO_ARENA) - { - $data[$this->id]['rating'] = $this->getField('rating'); - $data[$this->id]['captain'] = $this->getField('captain'); - $data[$this->id]['games'] = $this->getField('seasonGames'); - $data[$this->id]['wins'] = $this->getField('seasonWins'); - } - - // Filter asked for skills - add them - foreach ($reqCols as $col) - $data[$this->id][$col] = $this->getField($col); - - if ($addInfo & PROFILEINFO_PROFILE) - { - if ($_ = $this->getField('description')) - $data[$this->id]['description'] = $_; - - if ($_ = $this->getField('icon')) - $data[$this->id]['icon'] = $_; - } - - if ($addInfo & PROFILEINFO_CHARACTER) - if ($_ = $this->getField('renameItr')) - $data[$this->id]['renameItr'] = $_; - - if ($this->getField('cuFlags') & PROFILER_CU_PINNED) - $data[$this->id]['pinned'] = 1; - - if ($this->getField('cuFlags') & PROFILER_CU_DELETED) - $data[$this->id]['deleted'] = 1; - } - - return array_values($data); - } - - public function renderTooltip() - { - if (!$this->curTpl) - return []; - - $title = ''; - $name = $this->getField('name'); - if ($_ = $this->getField('title')) - $title = (new TitleList(array(['id', $_])))->getField($this->getField('gender') ? 'female' : 'male', true); - - if ($this->isCustom()) - $name .= Lang::profiler('customProfile'); - else if ($title) - $name = sprintf($title, $name); - - $x = ''; - $x .= ''; - if ($g = $this->getField('guildname')) - $x .= ''; - else if ($d = $this->getField('description')) - $x .= ''; - $x .= ''; - $x .= '
'.$name.'
<'.$g.'>
'.$d.'
'.Lang::game('level').' '.$this->getField('level').' '.Lang::game('ra', $this->getField('race')).' '.Lang::game('cl', $this->getField('class')).'
'; - - return $x; - } - - public function getJSGlobals($addMask = 0) - { - $data = []; - $realms = Profiler::getRealms(); - - foreach ($this->iterate() as $id => $__) - { - if (($addMask & PROFILEINFO_PROFILE) && $this->isCustom()) - { - $profile = array( - 'id' => $this->getField('id'), - 'name' => $this->getField('name'), - 'race' => $this->getField('race'), - 'classs' => $this->getField('class'), - 'level' => $this->getField('level'), - 'gender' => $this->getField('gender') - ); - - if ($_ = $this->getField('icon')) - $profile['icon'] = $_; - - $data[] = $profile; - - continue; - } - - if ($addMask & PROFILEINFO_CHARACTER && !$this->isCustom()) - { - if (!isset($realms[$this->getField('realm')])) - continue; - - $data[] = array( - 'id' => $this->getField('id'), - 'name' => $this->getField('name'), - 'realmname' => $realms[$this->getField('realm')]['name'], - 'region' => $realms[$this->getField('realm')]['region'], - 'realm' => Profiler::urlize($realms[$this->getField('realm')]['name']), - 'race' => $this->getField('race'), - 'classs' => $this->getField('class'), - 'level' => $this->getField('level'), - 'gender' => $this->getField('gender'), - 'pinned' => $this->getField('cuFlags') & PROFILER_CU_PINNED ? 1 : 0 - ); - } - } - - return $data; - } - - public function isCustom() - { - return $this->getField('cuFlags') & PROFILER_CU_PROFILE; - } - - public function isVisibleToUser() - { - if (!$this->isCustom() || User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) - return true; - - if ($this->getField('cuFlags') & PROFILER_CU_DELETED) - return false; - - if (User::$id == $this->getField('user')) - return true; - - return (bool)($this->getField('cuFlags') & PROFILER_CU_PUBLISHED); - } - - public function getIcon() - { - if ($_ = $this->getField('icon')) - return $_; - - $str = 'chr_'; - - switch ($this->getField('race')) - { - case 1: $str .= 'human_'; break; - case 2: $str .= 'orc_'; break; - case 3: $str .= 'dwarf_'; break; - case 4: $str .= 'nightelf_'; break; - case 5: $str .= 'scourge_'; break; - case 6: $str .= 'tauren_'; break; - case 7: $str .= 'gnome_'; break; - case 8: $str .= 'troll_'; break; - case 10: $str .= 'bloodelf_'; break; - case 11: $str .= 'draenei_'; break; - } - - switch ($this->getField('gender')) - { - case 0: $str .= 'male_'; break; - case 1: $str .= 'female_'; break; - } - - switch ($this->getField('class')) - { - case 1: $str .= 'warrior0'; break; - case 2: $str .= 'paladin0'; break; - case 3: $str .= 'hunter0'; break; - case 4: $str .= 'rogue0'; break; - case 5: $str .= 'priest0'; break; - case 6: $str .= 'deathknight0'; break; - case 7: $str .= 'shaman0'; break; - case 8: $str .= 'mage0'; break; - case 9: $str .= 'warlock0'; break; - case 11: $str .= 'druid0'; break; - } - - $level = $this->getField('level'); - if ($level > 59) - $str .= floor(($level - 60) / 10) + 2; - else - $str .= 1; - - return $str; - } -} - - -class ProfileListFilter extends Filter -{ - public $useLocalList = false; - public $extraOpts = []; - - private $realms = []; - - protected $enums = array( - -1 => array( // arena team sizes - // by name by rating by contrib - 12 => 2, 13 => 2, 14 => 2, - 15 => 3, 16 => 3, 17 => 3, - 18 => 5, 19 => 5, 20 => 5 - ) - ); - - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 2 => [FILTER_CR_NUMERIC, 'gearscore', NUM_CAST_INT ], // gearscore [num] - 3 => [FILTER_CR_CALLBACK, 'cbAchievs', null, null], // achievementpoints [num] - 5 => [FILTER_CR_NUMERIC, 'talenttree1', NUM_CAST_INT ], // talenttree1 [num] - 6 => [FILTER_CR_NUMERIC, 'talenttree2', NUM_CAST_INT ], // talenttree2 [num] - 7 => [FILTER_CR_NUMERIC, 'talenttree3', NUM_CAST_INT ], // talenttree3 [num] - 9 => [FILTER_CR_STRING, 'g.name', ], // guildname - 10 => [FILTER_CR_CALLBACK, 'cbHasGuildRank', null, null], // guildrank - 12 => [FILTER_CR_CALLBACK, 'cbTeamName', null, null], // teamname2v2 - 15 => [FILTER_CR_CALLBACK, 'cbTeamName', null, null], // teamname3v3 - 18 => [FILTER_CR_CALLBACK, 'cbTeamName', null, null], // teamname5v5 - 13 => [FILTER_CR_CALLBACK, 'cbTeamRating', null, null], // teamrtng2v2 - 16 => [FILTER_CR_CALLBACK, 'cbTeamRating', null, null], // teamrtng3v3 - 19 => [FILTER_CR_CALLBACK, 'cbTeamRating', null, null], // teamrtng5v5 - 14 => [FILTER_CR_NYI_PH, 0 ], // teamcontrib2v2 [num] - 17 => [FILTER_CR_NYI_PH, 0 ], // teamcontrib3v3 [num] - 20 => [FILTER_CR_NYI_PH, 0 ], // teamcontrib5v5 [num] - 21 => [FILTER_CR_CALLBACK, 'cbWearsItems', null, null], // wearingitem [str] - 23 => [FILTER_CR_CALLBACK, 'cbCompletedAcv', null, null], // completedachievement - 25 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_ALCHEMY, null], // alchemy [num] - 26 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_BLACKSMITHING, null], // blacksmithing [num] - 27 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_ENCHANTING, null], // enchanting [num] - 28 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_ENGINEERING, null], // engineering [num] - 29 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_HERBALISM, null], // herbalism [num] - 30 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_INSCRIPTION, null], // inscription [num] - 31 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_JEWELCRAFTING, null], // jewelcrafting [num] - 32 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_LEATHERWORKING, null], // leatherworking [num] - 33 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_MINING, null], // mining [num] - 34 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_SKINNING, null], // skinning [num] - 35 => [FILTER_CR_CALLBACK, 'cbProfession', SKILL_TAILORING, null], // tailoring [num] - 36 => [FILTER_CR_CALLBACK, 'cbHasGuild', null, null] // hasguild [yn] - ); - - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_RANGE, [1, 36], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 5000]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/[\p{C}:;%\\\\]/ui', true ], // criteria values - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name - only printable chars, no delimiter - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'ex' => [FILTER_V_EQUAL, 'on', false], // only match exact - 'si' => [FILTER_V_LIST, [1, 2], false], // side - 'ra' => [FILTER_V_LIST, [[1, 8], 10, 11], true ], // race - 'cl' => [FILTER_V_LIST, [[1, 9], 11], true ], // class - 'minle' => [FILTER_V_RANGE, [1, MAX_LEVEL], false], // min level - 'maxle' => [FILTER_V_RANGE, [1, MAX_LEVEL], false], // max level - 'rg' => [FILTER_V_CALLBACK, 'cbRegionCheck', false], // region - 'sv' => [FILTER_V_CALLBACK, 'cbServerCheck', false], // server - ); - - /* heads up! - a couple of filters are too complex to be run against the characters database - if they are selected, force useage of LocalProfileList - */ - - public function __construct($fromPOST = false, $opts = []) - { - if (!empty($opts['realms'])) - $this->realms = $opts['realms']; - else - $this->realms = array_keys(Profiler::getRealms()); - - parent::__construct($fromPOST, $opts); - - if (!empty($this->fiData['c']['cr'])) - if (array_intersect($this->fiData['c']['cr'], [2, 5, 6, 7, 21])) - $this->useLocalList = true; - } - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCR = $this->genericCriterion($cr)) - return $genCR; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = $this->fiData['v']; - - // region (rg), battlegroup (bg) and server (sv) are passed to ProflieList as miscData and handled there - - // table key differs between remote and local :< - $k = $this->useLocalList ? 'p' : 'c'; - - // name [str] - the table is case sensitive. Since i down't want to destroy indizes, lets alter the search terms - if (!empty($_v['na'])) - { - $lower = $this->modularizeString([$k.'.name'], Util::lower($_v['na']), !empty($_v['ex']) && $_v['ex'] == 'on', true); - $proper = $this->modularizeString([$k.'.name'], Util::ucWords($_v['na']), !empty($_v['ex']) && $_v['ex'] == 'on', true); - - $parts[] = ['OR', $lower, $proper]; - } - - // side [list] - if (!empty($_v['si'])) - { - if ($_v['si'] == 1) - $parts[] = [$k.'.race', [1, 3, 4, 7, 11]]; - else if ($_v['si'] == 2) - $parts[] = [$k.'.race', [2, 5, 6, 8, 10]]; - } - - // race [list] - if (!empty($_v['ra'])) - $parts[] = [$k.'.race', $_v['ra']]; - - // class [list] - if (!empty($_v['cl'])) - $parts[] = [$k.'.class', $_v['cl']]; - - // min level [int] - if (isset($_v['minle'])) - $parts[] = [$k.'.level', $_v['minle'], '>=']; - - // max level [int] - if (isset($_v['maxle'])) - $parts[] = [$k.'.level', $_v['maxle'], '<=']; - - return $parts; - } - - protected function cbRegionCheck(&$v) - { - if (in_array($v, Util::$regions)) - { - $this->parentCats[0] = $v; // directly redirect onto this region - $v = ''; // remove from filter - - return true; - } - - return false; - } - - protected function cbServerCheck(&$v) - { - foreach (Profiler::getRealms() as $realm) - if ($realm['name'] == $v) - { - $this->parentCats[1] = Profiler::urlize($v);// directly redirect onto this server - $v = ''; // remove from filter - - return true; - } - - return false; - } - - protected function cbProfession($cr, $skillId) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return; - - $k = 'sk_'.Util::createHash(12); - $col = 'skill'.$skillId; - - $this->formData['extraCols'][$skillId] = $col; - - if ($this->useLocalList) - { - $this->extraOpts[$k] = array( - 'j' => ['?_profiler_completion '.$k.' ON '.$k.'.id = p.id AND '.$k.'.`type` = '.Type::SKILL.' AND '.$k.'.typeId = '.$skillId.' AND '.$k.'.cur '.$cr[1].' '.$cr[2], true], - 's' => [', '.$k.'.cur AS '.$col] - ); - return [$k.'.typeId', null, '!']; - } - else - { - $this->extraOpts[$k] = array( - 'j' => ['character_skills '.$k.' ON '.$k.'.guid = c.guid AND '.$k.'.skill = '.$skillId.' AND '.$k.'.value '.$cr[1].' '.$cr[2], true], - 's' => [', '.$k.'.value AS '.$col] - ); - return [$k.'.skill', null, '!']; - } - } - - protected function cbCompletedAcv($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT)) - return false; - - if (!DB::Aowow()->selectCell('SELECT 1 FROM ?_achievement WHERE id = ?d', $cr[2])) - return false; - - $k = 'acv_'.Util::createHash(12); - - if ($this->useLocalList) - { - $this->extraOpts[$k] = ['j' => ['?_profiler_completion '.$k.' ON '.$k.'.id = p.id AND '.$k.'.`type` = '.Type::ACHIEVEMENT.' AND '.$k.'.typeId = '.$cr[2], true]]; - return [$k.'.typeId', null, '!']; - } - else - { - $this->extraOpts[$k] = ['j' => ['character_achievement '.$k.' ON '.$k.'.guid = c.guid AND '.$k.'.achievement = '.$cr[2], true]]; - return [$k.'.achievement', null, '!']; - } - } - - protected function cbWearsItems($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT)) - return false; - - if (!DB::Aowow()->selectCell('SELECT 1 FROM ?_items WHERE id = ?d', $cr[2])) - return false; - - $k = 'i_'.Util::createHash(12); - - $this->extraOpts[$k] = ['j' => ['?_profiler_items '.$k.' ON '.$k.'.id = p.id AND '.$k.'.item = '.$cr[2], true]]; - return [$k.'.item', null, '!']; - } - - protected function cbHasGuild($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($this->useLocalList) - return ['p.guild', null, $cr[1] ? '!' : null]; - else - return ['gm.guildId', null, $cr[1] ? '!' : null]; - } - - protected function cbHasGuildRank($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - if ($this->useLocalList) - return ['p.guildrank', $cr[2], $cr[1]]; - else - return ['gm.rank', $cr[2], $cr[1]]; - } - - protected function cbTeamName($cr) - { - if ($_ = $this->modularizeString(['at.name'], $cr[2])) - return ['AND', ['at.type', $this->enums[-1][$cr[0]]], $_]; - - return false; - } - - protected function cbTeamRating($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - return ['AND', ['at.type', $this->enums[-1][$cr[0]]], ['at.rating', $cr[2], $cr[1]]]; - } - - protected function cbAchievs($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - if ($this->useLocalList) - return ['p.achievementpoints', $cr[2], $cr[1]]; - else - return ['cap.counter', $cr[2], $cr[1]]; - } -} - - -class RemoteProfileList extends ProfileList -{ - protected $queryBase = 'SELECT `c`.*, `c`.`guid` AS ARRAY_KEY FROM characters c'; - protected $queryOpts = array( - 'c' => [['gm', 'g', 'cap']], // 12698: use criteria of Achievement 4496 as shortcut to get total achievement points - 'cap' => ['j' => ['character_achievement_progress cap ON cap.guid = c.guid AND cap.criteria = 12698', true], 's' => ', IFNULL(cap.counter, 0) AS achievementpoints'], - 'gm' => ['j' => ['guild_member gm ON gm.guid = c.guid', true], 's' => ', gm.rank AS guildrank'], - 'g' => ['j' => ['guild g ON g.guildid = gm.guildid', true], 's' => ', g.guildid AS guild, g.name AS guildname'], - 'atm' => ['j' => ['arena_team_member atm ON atm.guid = c.guid', true], 's' => ', atm.personalRating AS rating'], - 'at' => [['atm'], 'j' => 'arena_team at ON atm.arenaTeamId = at.arenaTeamId', 's' => ', at.name AS arenateam, IF(at.captainGuid = c.guid, 1, 0) AS captain'] - ); - - public function __construct($conditions = [], $miscData = null) - { - // select DB by realm - if (!$this->selectRealms($miscData)) - { - trigger_error('no access to auth-db or table realmlist is empty', E_USER_WARNING); - return; - } - - parent::__construct($conditions, $miscData); - - if ($this->error) - return; - - reset($this->dbNames); // only use when querying single realm - $realmId = key($this->dbNames); - $realms = Profiler::getRealms(); - $talentSpells = []; - $talentLookup = []; - $distrib = null; - $limit = CFG_SQL_LIMIT_DEFAULT; - - foreach ($conditions as $c) - if (is_int($c)) - $limit = $c; - - // post processing - foreach ($this->iterate() as $guid => &$curTpl) - { - // battlegroup - $curTpl['battlegroup'] = CFG_BATTLEGROUP; - - // realm - [$r, $g] = explode(':', $guid); - if (!empty($realms[$r])) - { - $curTpl['realm'] = $r; - $curTpl['realmName'] = $realms[$r]['name']; - $curTpl['region'] = $realms[$r]['region']; - } - else - { - trigger_error('character "'.$curTpl['name'].'" belongs to nonexistant realm #'.$r, E_USER_WARNING); - unset($this->templates[$guid]); - continue; - } - - // temp id - $curTpl['id'] = 0; - - // talent points pre - $talentLookup[$r][$g] = []; - $talentSpells[] = $curTpl['class']; - $curTpl['activespec'] = $curTpl['activeTalentGroup']; - - // equalize distribution - if ($limit != CFG_SQL_LIMIT_NONE) - { - if (empty($distrib[$curTpl['realm']])) - $distrib[$curTpl['realm']] = 1; - else - $distrib[$curTpl['realm']]++; - } - - // char is pending rename - if ($curTpl['at_login'] & 0x1) - { - if (!isset($this->rnItr[$curTpl['name']])) - $this->rnItr[$curTpl['name']] = DB::Aowow()->selectCell('SELECT MAX(renameItr) FROM ?_profiler_profiles WHERE realm = ?d AND realmGUID IS NOT NULL AND name = ?', $r, $curTpl['name']) ?: 0; - - // already saved as "pending rename" - if ($rnItr = DB::Aowow()->selectCell('SELECT renameItr FROM ?_profiler_profiles WHERE realm = ?d AND realmGUID = ?d', $r, $g)) - $curTpl['renameItr'] = $rnItr; - // not yet recognized: get max itr - else - $curTpl['renameItr'] = ++$this->rnItr[$curTpl['name']]; - } - else - $curTpl['renameItr'] = 0; - - $curTpl['cuFlags'] = 0; - } - - foreach ($talentLookup as $realm => $chars) - $talentLookup[$realm] = DB::Characters($realm)->selectCol('SELECT guid AS ARRAY_KEY, spell AS ARRAY_KEY2, talentGroup FROM character_talent ct WHERE guid IN (?a)', array_keys($chars)); - - $talentSpells = DB::Aowow()->select('SELECT spell AS ARRAY_KEY, tab, `rank` FROM ?_talents WHERE class IN (?a)', array_unique($talentSpells)); - - if ($distrib !== null) - { - $total = array_sum($distrib); - foreach ($distrib as &$d) - $d = ceil($limit * $d / $total); - } - - foreach ($this->iterate() as $guid => &$curTpl) - { - if ($distrib !== null) - { - if ($limit <= 0 || $distrib[$curTpl['realm']] <= 0) - { - unset($this->templates[$guid]); - continue; - } - - $distrib[$curTpl['realm']]--; - $limit--; - } - - [$r, $g] = explode(':', $guid); - - // talent points post - $curTpl['talenttree1'] = 0; - $curTpl['talenttree2'] = 0; - $curTpl['talenttree3'] = 0; - if (!empty($talentLookup[$r][$g])) - { - $talents = array_filter($talentLookup[$r][$g], function($v) use ($curTpl) { return $curTpl['activespec'] == $v; } ); - foreach (array_intersect_key($talentSpells, $talents) as $spell => $data) - $curTpl['talenttree'.($data['tab'] + 1)] += $data['rank']; - } - } - } - - public function getListviewData($addInfoMask = 0, array $reqCols = []) - { - $data = parent::getListviewData($addInfoMask, $reqCols); - - // not wanted on server list - foreach ($data as &$d) - unset($d['published']); - - return $data; - } - - public function initializeLocalEntries() - { - $baseData = $guildData = []; - foreach ($this->iterate() as $guid => $__) - { - $baseData[$guid] = array( - 'realm' => $this->getField('realm'), - 'realmGUID' => $this->getField('guid'), - 'name' => $this->getField('name'), - 'renameItr' => $this->getField('renameItr'), - 'race' => $this->getField('race'), - 'class' => $this->getField('class'), - 'level' => $this->getField('level'), - 'gender' => $this->getField('gender'), - 'guild' => $this->getField('guild') ?: null, - 'guildrank' => $this->getField('guild') ? $this->getField('guildrank') : null, - 'cuFlags' => PROFILER_CU_NEEDS_RESYNC - ); - - if ($this->getField('guild')) - $guildData[] = array( - 'realm' => $this->getField('realm'), - 'realmGUID' => $this->getField('guild'), - 'name' => $this->getField('guildname'), - 'nameUrl' => Profiler::urlize($this->getField('guildname')), - 'cuFlags' => PROFILER_CU_NEEDS_RESYNC - ); - } - - // basic guild data (satisfying table constraints) - if ($guildData) - { - foreach (Util::createSqlBatchInsert($guildData) as $ins) - DB::Aowow()->query('INSERT IGNORE INTO ?_profiler_guild (?#) VALUES '.$ins, array_keys(reset($guildData))); - - // merge back local ids - $localGuilds = DB::Aowow()->selectCol('SELECT realm AS ARRAY_KEY, realmGUID AS ARRAY_KEY2, id FROM ?_profiler_guild WHERE realm IN (?a) AND realmGUID IN (?a)', - array_column($guildData, 'realm'), array_column($guildData, 'realmGUID') - ); - - foreach ($baseData as &$bd) - if ($bd['guild']) - $bd['guild'] = $localGuilds[$bd['realm']][$bd['guild']]; - } - - // basic char data (enough for tooltips) - if ($baseData) - { - foreach (Util::createSqlBatchInsert($baseData) as $ins) - DB::Aowow()->query('INSERT INTO ?_profiler_profiles (?#) VALUES '.$ins.' ON DUPLICATE KEY UPDATE name = VALUES(name), renameItr = VALUES(renameItr)', array_keys(reset($baseData))); - - // merge back local ids - $localIds = DB::Aowow()->select( - 'SELECT CONCAT(realm, ":", realmGUID) AS ARRAY_KEY, id, gearscore FROM ?_profiler_profiles WHERE (cuFlags & ?d) = 0 AND realm IN (?a) AND realmGUID IN (?a)', - PROFILER_CU_PROFILE, - array_column($baseData, 'realm'), - array_column($baseData, 'realmGUID') - ); - - foreach ($this->iterate() as $guid => &$_curTpl) - if (isset($localIds[$guid])) - $_curTpl = array_merge($_curTpl, $localIds[$guid]); - } - } -} - - -class LocalProfileList extends ProfileList -{ - protected $queryBase = 'SELECT p.*, p.id AS ARRAY_KEY FROM ?_profiler_profiles p'; - protected $queryOpts = array( - 'p' => [['g'], 'g' => 'p.id'], - 'ap' => ['j' => ['?_account_profiles ap ON ap.profileId = p.id', true], 's' => ', (IFNULL(ap.ExtraFlags, 0) | p.cuFlags) AS cuFlags'], - 'atm' => ['j' => ['?_profiler_arena_team_member atm ON atm.profileId = p.id', true], 's' => ', atm.captain, atm.personalRating AS rating, atm.seasonGames, atm.seasonWins'], - 'at' => [['atm'], 'j' => ['?_profiler_arena_team at ON at.id = atm.arenaTeamId', true], 's' => ', at.type'], - 'g' => ['j' => ['?_profiler_guild g ON g.id = p.guild', true], 's' => ', g.name AS guildname'] - ); - - public function __construct($conditions = [], $miscData = null) - { - parent::__construct($conditions, $miscData); - - if ($this->error) - return; - - $realms = Profiler::getRealms(); - - // post processing - $acvPoints = DB::Aowow()->selectCol('SELECT pc.id AS ARRAY_KEY, SUM(a.points) FROM ?_profiler_completion pc LEFT JOIN ?_achievement a ON a.id = pc.typeId WHERE pc.`type` = ?d AND pc.id IN (?a) GROUP BY pc.id', Type::ACHIEVEMENT, $this->getFoundIDs()); - - foreach ($this->iterate() as $id => &$curTpl) - { - if ($curTpl['realm'] && !isset($realms[$curTpl['realm']])) - continue; - - if (isset($realms[$curTpl['realm']])) - { - $curTpl['realmName'] = $realms[$curTpl['realm']]['name']; - $curTpl['region'] = $realms[$curTpl['realm']]['region']; - } - - // battlegroup - $curTpl['battlegroup'] = CFG_BATTLEGROUP; - - $curTpl['achievementpoints'] = isset($acvPoints[$id]) ? $acvPoints[$id] : 0; - } - } - - public function getProfileUrl() - { - $url = '?profile='; - - if ($this->isCustom()) - return $url.$this->getField('id'); - - return $url.implode('.', array( - Profiler::urlize($this->getField('region')), - Profiler::urlize($this->getField('realmName')), - urlencode($this->getField('name')) - )); - } -} - - -?> diff --git a/includes/types/quest.class.php b/includes/types/quest.class.php deleted file mode 100644 index 1b6e6d90..00000000 --- a/includes/types/quest.class.php +++ /dev/null @@ -1,720 +0,0 @@ - [], - 'rsc' => ['j' => '?_spell rsc ON q.rewardSpellCast = rsc.id'], // limit rewardSpellCasts - 'qse' => ['j' => '?_quests_startend qse ON q.id = qse.questId', 's' => ', qse.method'], // groupConcat..? - 'e' => ['j' => ['?_events e ON e.id = `q`.eventId', true], 's' => ', e.holidayId'] - ); - - public function __construct($conditions = [], $miscData = null) - { - parent::__construct($conditions, $miscData); - - // i don't like this very much - $currencies = DB::Aowow()->selectCol('SELECT id AS ARRAY_KEY, itemId FROM ?_currencies'); - - // post processing - foreach ($this->iterate() as $id => &$_curTpl) - { - $_curTpl['cat1'] = $_curTpl['zoneOrSort']; // should probably be in a method... - $_curTpl['cat2'] = 0; - - foreach (Game::$questClasses as $k => $arr) - { - if (in_array($_curTpl['cat1'], $arr)) - { - $_curTpl['cat2'] = $k; - break; - } - } - - // store requirements - $requires = []; - for ($i = 1; $i < 7; $i++) - { - if ($_ = $_curTpl['reqItemId'.$i]) - $requires[Type::ITEM][] = $_; - - if ($i > 4) - continue; - - if ($_curTpl['reqNpcOrGo'.$i] > 0) - $requires[Type::NPC][] = $_curTpl['reqNpcOrGo'.$i]; - else if ($_curTpl['reqNpcOrGo'.$i] < 0) - $requires[Type::OBJECT][] = -$_curTpl['reqNpcOrGo'.$i]; - - if ($_ = $_curTpl['reqSourceItemId'.$i]) - $requires[Type::ITEM][] = $_; - } - if ($requires) - $this->requires[$id] = $requires; - - // store rewards - $rewards = []; - $choices = []; - - if ($_ = $_curTpl['rewardTitleId']) - $rewards[Type::TITLE][] = $_; - - if ($_ = $_curTpl['rewardHonorPoints']) - $rewards[Type::CURRENCY][104] = $_; - - if ($_ = $_curTpl['rewardArenaPoints']) - $rewards[Type::CURRENCY][103] = $_; - - for ($i = 1; $i < 7; $i++) - { - if ($_ = $_curTpl['rewardChoiceItemId'.$i]) - $choices[Type::ITEM][$_] = $_curTpl['rewardChoiceItemCount'.$i]; - - if ($i > 5) - continue; - - if ($_ = $_curTpl['rewardFactionId'.$i]) - $rewards[Type::FACTION][$_] = $_curTpl['rewardFactionValue'.$i]; - - if ($i > 4) - continue; - - if ($_ = $_curTpl['rewardItemId'.$i]) - { - $qty = $_curTpl['rewardItemCount'.$i]; - if (in_array($_, $currencies)) - $rewards[Type::CURRENCY][array_search($_, $currencies)] = $qty; - else - $rewards[Type::ITEM][$_] = $qty; - } - } - if ($rewards) - $this->rewards[$id] = $rewards; - - if ($choices) - $this->choices[$id] = $choices; - } - } - - // static use START - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_quests WHERE id = ?d', $id); - return Util::localizedString($n, 'name'); - } - // static use END - - public function isRepeatable() - { - return $this->curTpl['flags'] & QUEST_FLAG_REPEATABLE || $this->curTpl['specialFlags'] & QUEST_FLAG_SPECIAL_REPEATABLE; - } - - public function isDaily() - { - if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) - return 1; - - if ($this->curTpl['flags'] & QUEST_FLAG_WEEKLY) - return 2; - - if ($this->curTpl['specialFlags'] & QUEST_FLAG_SPECIAL_MONTHLY) - return 3; - - return 0; - } - - // using reqPlayerKills and rewardHonor as a crutch .. has TC this even implemented..? - public function isPvPEnabled() - { - return $this->curTpl['reqPlayerKills'] || $this->curTpl['rewardHonorPoints'] || $this->curTpl['rewardArenaPoints']; - } - - // by TC definition - public function isSeasonal() - { - return in_array($this->getField('zoneOrSortBak'), [-22, -284, -366, -369, -370, -376, -374]) && !$this->isRepeatable(); - } - - public function getSourceData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - "n" => $this->getField('name', true), - "t" => Type::QUEST, - "ti" => $this->id, - "c" => $this->curTpl['cat1'], - "c2" => $this->curTpl['cat2'] - ); - } - - return $data; - } - - public function getSOMData($side = SIDE_BOTH) - { - $data = []; - - foreach ($this->iterate() as $__) - { - if (!(Game::sideByRaceMask($this->curTpl['reqRaceMask']) & $side)) - continue; - - [$series, $first] = DB::Aowow()->SelectRow( - 'SELECT IF(prev.id OR cur.nextQuestIdChain, 1, 0) AS "0", IF(prev.id IS NULL AND cur.nextQuestIdChain, 1, 0) AS "1" FROM ?_quests cur LEFT JOIN ?_quests prev ON prev.nextQuestIdChain = cur.id WHERE cur.id = ?d', - $this->id - ); - - $data[$this->id] = array( - 'level' => $this->curTpl['level'] < 0 ? MAX_LEVEL : $this->curTpl['level'], - 'name' => $this->getField('name', true), - 'category' => $this->curTpl['cat1'], - 'category2' => $this->curTpl['cat2'], - 'series' => $series, - 'first' => $first - ); - - if ($this->isDaily()) - $data[$this->id]['daily'] = 1; - } - - return $data; - } - - public function getListviewData($extraFactionId = 0) // i should formulate a propper parameter.. - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'category' => $this->curTpl['cat1'], - 'category2' => $this->curTpl['cat2'], - 'id' => $this->id, - 'level' => $this->curTpl['level'], - 'reqlevel' => $this->curTpl['minLevel'], - 'name' => $this->getField('name', true), - 'side' => Game::sideByRaceMask($this->curTpl['reqRaceMask']), - 'wflags' => 0x0, - 'xp' => $this->curTpl['rewardXP'] - ); - - if (!empty($this->rewards[$this->id][Type::CURRENCY])) - foreach ($this->rewards[$this->id][Type::CURRENCY] as $iId => $qty) - $data[$this->id]['currencyrewards'][] = [$iId, $qty]; - - if (!empty($this->rewards[$this->id][Type::ITEM])) - foreach ($this->rewards[$this->id][Type::ITEM] as $iId => $qty) - $data[$this->id]['itemrewards'][] = [$iId, $qty]; - - if (!empty($this->choices[$this->id][Type::ITEM])) - foreach ($this->choices[$this->id][Type::ITEM] as $iId => $qty) - $data[$this->id]['itemchoices'][] = [$iId, $qty]; - - if ($_ = $this->curTpl['rewardTitleId']) - $data[$this->id]['titlereward'] = $_; - - if ($_ = $this->curTpl['type']) - $data[$this->id]['type'] = $_; - - if ($_ = $this->curTpl['reqClassMask']) - $data[$this->id]['reqclass'] = $_; - - if ($_ = ($this->curTpl['reqRaceMask'] & RACE_MASK_ALL)) - if ((($_ & RACE_MASK_ALLIANCE) != RACE_MASK_ALLIANCE) && (($_ & RACE_MASK_HORDE) != RACE_MASK_HORDE)) - $data[$this->id]['reqrace'] = $_; - - if ($_ = $this->curTpl['rewardOrReqMoney']) - if ($_ > 0) - $data[$this->id]['money'] = $_; - - // todo (med): also get disables - if ($this->curTpl['flags'] & QUEST_FLAG_UNAVAILABLE) - $data[$this->id]['historical'] = true; - - // if ($this->isRepeatable()) // dafuque..? says repeatable and is used as 'disabled'..? - // $data[$this->id]['wflags'] |= QUEST_CU_REPEATABLE; - if ($this->curTpl['cuFlags'] & (CUSTOM_UNAVAILABLE | CUSTOM_DISABLED)) - $data[$this->id]['wflags'] |= QUEST_CU_REPEATABLE; - - if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) - { - $data[$this->id]['wflags'] |= QUEST_CU_DAILY; - $data[$this->id]['daily'] = true; - } - - if ($this->curTpl['flags'] & QUEST_FLAG_WEEKLY) - { - $data[$this->id]['wflags'] |= QUEST_CU_WEEKLY; - $data[$this->id]['weekly'] = true; - } - - if ($this->isSeasonal()) - $data[$this->id]['wflags'] |= QUEST_CU_SEASONAL; - - if ($this->curTpl['flags'] & QUEST_FLAG_AUTO_REWARDED) // not shown in log - $data[$this->id]['wflags'] |= QUEST_CU_SKIP_LOG; - - if ($this->curTpl['flags'] & QUEST_FLAG_AUTO_ACCEPT) // self-explanatory - $data[$this->id]['wflags'] |= QUEST_CU_AUTO_ACCEPT; - - if ($this->isPvPEnabled()) // not sure why this flag also requires auto-accept to be set - $data[$this->id]['wflags'] |= (QUEST_CU_AUTO_ACCEPT | QUEST_CU_PVP_ENABLED); - - $data[$this->id]['reprewards'] = []; - for ($i = 1; $i < 6; $i++) - { - $foo = $this->curTpl['rewardFactionId'.$i]; - $bar = $this->curTpl['rewardFactionValue'.$i]; - if ($foo && $bar) - { - $data[$this->id]['reprewards'][] = [$foo, $bar]; - - if ($extraFactionId == $foo) - $data[$this->id]['reputation'] = $bar; - } - } - } - - return $data; - } - - public function parseText($type = 'objectives', $jsEscaped = true) - { - $text = $this->getField($type, true); - if (!$text) - return ''; - - $text = Util::parseHtmlText($text); - - if ($jsEscaped) - $text = Util::jsEscape($text); - - return $text; - } - - public function renderTooltip() - { - if (!$this->curTpl) - return null; - - $title = htmlentities($this->getField('name', true)); - $level = $this->curTpl['level']; - if ($level < 0) - $level = 0; - - $x = ''; - if ($level) - { - $level = sprintf(Lang::quest('questLevel'), $level); - - if ($this->curTpl['flags'] & QUEST_FLAG_DAILY) // daily - $level .= ' '.Lang::quest('daily'); - - $x .= '
'.$title.''.$level.'
'; - } - else - $x .= '
'.$title.'
'; - - - $x .= '

'.$this->parseText('objectives', false); - - - $xReq = ''; - for ($i = 1; $i < 5; $i++) - { - $ot = $this->getField('objectiveText'.$i, true); - $rng = $this->curTpl['reqNpcOrGo'.$i]; - $rngQty = $this->curTpl['reqNpcOrGoCount'.$i]; - - if ($rngQty < 1 && (!$rng || $ot)) - continue; - - if ($ot) - $name = $ot; - else - $name = $rng > 0 ? CreatureList::getName($rng) : GameObjectList::getName(-$rng); - - $xReq .= '
- '.$name.($rngQty > 1 ? ' x '.$rngQty : null); - } - - for ($i = 1; $i < 7; $i++) - { - $ri = $this->curTpl['reqItemId'.$i]; - $riQty = $this->curTpl['reqItemCount'.$i]; - - if (!$ri || $riQty < 1) - continue; - - $xReq .= '
- '.ItemList::getName($ri).($riQty > 1 ? ' x '.$riQty : null); - } - - if ($et = $this->getField('end', true)) - $xReq .= '
- '.$et; - - if ($_ = $this->getField('rewardOrReqMoney')) - if ($_ < 0) - $xReq .= '
- '.Lang::quest('money').Lang::main('colon').Util::formatMoney(abs($_)); - - if ($xReq) - $x .= '

'.Lang::quest('requirements').Lang::main('colon').''.$xReq; - - $x .= '
'; - - return $x; - } - - public function getJSGlobals($addMask = GLOBALINFO_ANY) - { - $data = []; - - foreach ($this->iterate() as $__) - { - if ($addMask & GLOBALINFO_REWARDS) - { - // items - for ($i = 1; $i < 5; $i++) - if ($this->curTpl['rewardItemId'.$i] > 0) - $data[Type::ITEM][$this->curTpl['rewardItemId'.$i]] = $this->curTpl['rewardItemId'.$i]; - - for ($i = 1; $i < 7; $i++) - if ($this->curTpl['rewardChoiceItemId'.$i] > 0) - $data[Type::ITEM][$this->curTpl['rewardChoiceItemId'.$i]] = $this->curTpl['rewardChoiceItemId'.$i]; - - // spells - if ($this->curTpl['rewardSpell'] > 0) - $data[Type::SPELL][$this->curTpl['rewardSpell']] = $this->curTpl['rewardSpell']; - - if ($this->curTpl['rewardSpellCast'] > 0) - $data[Type::SPELL][$this->curTpl['rewardSpellCast']] = $this->curTpl['rewardSpellCast']; - - // titles - if ($this->curTpl['rewardTitleId'] > 0) - $data[Type::TITLE][$this->curTpl['rewardTitleId']] = $this->curTpl['rewardTitleId']; - - // currencies - if (!empty($this->rewards[$this->id][Type::CURRENCY])) - foreach ($this->rewards[$this->id][Type::CURRENCY] as $id => $__) - $data[Type::CURRENCY][$id] = $id; - } - - if ($addMask & GLOBALINFO_SELF) - $data[Type::QUEST][$this->id] = ['name' => $this->getField('name', true)]; - } - - return $data; - } -} - - -class QuestListFilter extends Filter -{ - public $extraOpts = []; - protected $enums = array( // massive enums could be put here, if you want to restrict inputs further to be valid IDs instead of just integers - 37 => [null, 1, 2, 3, 4, 5, 6, 7, 8, 9, null, 11, true, false], - 38 => [null, 1, 2, 3, 4, 5, 6, 7, 8, null, 10, 11, true, false], - ); - protected $genericFilter = array( - 1 => [FILTER_CR_CALLBACK, 'cbReputation', '>', null], // increasesrepwith - 2 => [FILTER_CR_NUMERIC, 'rewardXP', NUM_CAST_INT ], // experiencegained - 3 => [FILTER_CR_NUMERIC, 'rewardOrReqMoney', NUM_CAST_INT ], // moneyrewarded - 4 => [FILTER_CR_CALLBACK, 'cbSpellRewards', null, null], // spellrewarded [yn] - 5 => [FILTER_CR_FLAG, 'flags', QUEST_FLAG_SHARABLE ], // sharable - 6 => [FILTER_CR_NUMERIC, 'timeLimit', NUM_CAST_INT ], // timer - 7 => [FILTER_CR_NYI_PH, null, 1 ], // firstquestseries - 9 => [FILTER_CR_CALLBACK, 'cbEarnReputation', null, null], // objectiveearnrepwith [enum] - 10 => [FILTER_CR_CALLBACK, 'cbReputation', '<', null], // decreasesrepwith - 11 => [FILTER_CR_NUMERIC, 'suggestedPlayers', NUM_CAST_INT ], // suggestedplayers - 15 => [FILTER_CR_NYI_PH, null, 1 ], // lastquestseries - 16 => [FILTER_CR_NYI_PH, null, 1 ], // partseries - 18 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 19 => [FILTER_CR_CALLBACK, 'cbQuestRelation', 0x1, null], // startsfrom [enum] - 21 => [FILTER_CR_CALLBACK, 'cbQuestRelation', 0x2, null], // endsat [enum] - 22 => [FILTER_CR_CALLBACK, 'cbItemRewards', null, null], // itemrewards [op] [int] - 23 => [FILTER_CR_CALLBACK, 'cbItemChoices', null, null], // itemchoices [op] [int] - 24 => [FILTER_CR_CALLBACK, 'cbLacksStartEnd', null, null], // lacksstartend [yn] - 25 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 27 => [FILTER_CR_FLAG, 'flags', QUEST_FLAG_DAILY ], // daily - 28 => [FILTER_CR_FLAG, 'flags', QUEST_FLAG_WEEKLY ], // weekly - 29 => [FILTER_CR_FLAG, 'flags', QUEST_FLAG_REPEATABLE ], // repeatable - 30 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true], // id - 33 => [FILTER_CR_ENUM, 'e.holidayId' ], // relatedevent - 34 => [FILTER_CR_CALLBACK, 'cbAvailable', null, null], // availabletoplayers [yn] - 36 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 37 => [FILTER_CR_CALLBACK, 'cbClassSpec', null, null], // classspecific [enum] - 38 => [FILTER_CR_CALLBACK, 'cbRaceSpec', null, null], // racespecific [enum] - 42 => [FILTER_CR_STAFFFLAG, 'flags' ], // flags - 43 => [FILTER_CR_CALLBACK, 'cbCurrencyReward', null, null], // currencyrewarded [enum] - 44 => [FILTER_CR_CALLBACK, 'cbLoremaster', null, null], // countsforloremaster_stc [yn] - 45 => [FILTER_CR_BOOLEAN, 'rewardTitleId' ] // titlerewarded - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_RANGE, [1, 45], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 99999]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/\D/', true ], // criteria values - only numerals - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name / text - only printable chars, no delimiter - 'ex' => [FILTER_V_EQUAL, 'on', false], // also match subname - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'minle' => [FILTER_V_RANGE, [1, 99], false], // min quest level - 'maxle' => [FILTER_V_RANGE, [1, 99], false], // max quest level - 'minrl' => [FILTER_V_RANGE, [1, 99], false], // min required level - 'maxrl' => [FILTER_V_RANGE, [1, 99], false], // max required level - 'si' => [FILTER_V_LIST, [-2, -1, 1, 2, 3], false], // siede - 'ty' => [FILTER_V_LIST, [0, 1, 21, 41, 62, [81, 85], 88, 89], true ] // type - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = $this->fiData['v']; - - // name - if (isset($_v['na'])) - { - $_ = []; - if (isset($_v['ex']) && $_v['ex'] == 'on') - $_ = $this->modularizeString(['name_loc'.User::$localeId, 'objectives_loc'.User::$localeId, 'details_loc'.User::$localeId]); - else - $_ = $this->modularizeString(['name_loc'.User::$localeId]); - - if ($_) - $parts[] = $_; - } - - // level min - if (isset($_v['minle'])) - $parts[] = ['level', $_v['minle'], '>=']; // not considering quests that are always at player level (-1) - - // level max - if (isset($_v['maxle'])) - $parts[] = ['level', $_v['maxle'], '<=']; - - // reqLevel min - if (isset($_v['minrl'])) - $parts[] = ['minLevel', $_v['minrl'], '>=']; // ignoring maxLevel - - // reqLevel max - if (isset($_v['maxrl'])) - $parts[] = ['minLevel', $_v['maxrl'], '<=']; // ignoring maxLevel - - // side - if (isset($_v['si'])) - { - $ex = [['reqRaceMask', RACE_MASK_ALL, '&'], RACE_MASK_ALL, '!']; - $notEx = ['OR', ['reqRaceMask', 0], [['reqRaceMask', RACE_MASK_ALL, '&'], RACE_MASK_ALL]]; - - switch ($_v['si']) - { - case 3: - $parts[] = $notEx; - break; - case 2: - $parts[] = ['OR', $notEx, ['reqRaceMask', RACE_MASK_HORDE, '&']]; - break; - case -2: - $parts[] = ['AND', $ex, ['reqRaceMask', RACE_MASK_HORDE, '&']]; - break; - case 1: - $parts[] = ['OR', $notEx, ['reqRaceMask', RACE_MASK_ALLIANCE, '&']]; - break; - case -1: - $parts[] = ['AND', $ex, ['reqRaceMask', RACE_MASK_ALLIANCE, '&']]; - break; - } - } - - // type [list] - if (isset($_v['ty'])) - $parts[] = ['type', $_v['ty']]; - - return $parts; - } - - protected function cbReputation($cr, $sign) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT) || $cr[1] <= 0) - return false; - - if ($_ = DB::Aowow()->selectRow('SELECT * FROM ?_factions WHERE id = ?d', $cr[1])) - $this->formData['reputationCols'][] = [$cr[1], Util::localizedString($_, 'name')]; - - return [ - 'OR', - ['AND', ['rewardFactionId1', $cr[1]], ['rewardFactionValue1', 0, $sign]], - ['AND', ['rewardFactionId2', $cr[1]], ['rewardFactionValue2', 0, $sign]], - ['AND', ['rewardFactionId3', $cr[1]], ['rewardFactionValue3', 0, $sign]], - ['AND', ['rewardFactionId4', $cr[1]], ['rewardFactionValue4', 0, $sign]], - ['AND', ['rewardFactionId5', $cr[1]], ['rewardFactionValue5', 0, $sign]] - ]; - } - - protected function cbQuestRelation($cr, $flags) - { - switch ($cr[1]) - { - case 1: // npc - return ['AND', ['qse.type', Type::NPC], ['qse.method', $flags, '&']]; - case 2: // object - return ['AND', ['qse.type', Type::OBJECT], ['qse.method', $flags, '&']]; - case 3: // item - return ['AND', ['qse.type', Type::ITEM], ['qse.method', $flags, '&']]; - } - - return false; - } - - protected function cbCurrencyReward($cr) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT) || $cr[1] <= 0) - return false; - - return [ - 'OR', - ['rewardItemId1', $cr[1]], ['rewardItemId2', $cr[1]], ['rewardItemId3', $cr[1]], ['rewardItemId4', $cr[1]], - ['rewardChoiceItemId1', $cr[1]], ['rewardChoiceItemId2', $cr[1]], ['rewardChoiceItemId3', $cr[1]], ['rewardChoiceItemId4', $cr[1]], ['rewardChoiceItemId5', $cr[1]], ['rewardChoiceItemId6', $cr[1]] - ]; - } - - protected function cbAvailable($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return [['cuFlags', CUSTOM_UNAVAILABLE | CUSTOM_DISABLED, '&'], 0]; - else - return ['cuFlags', CUSTOM_UNAVAILABLE | CUSTOM_DISABLED, '&']; - } - - protected function cbItemChoices($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - $this->extraOpts['q']['s'][] = ', (IF(rewardChoiceItemId1, 1, 0) + IF(rewardChoiceItemId2, 1, 0) + IF(rewardChoiceItemId3, 1, 0) + IF(rewardChoiceItemId4, 1, 0) + IF(rewardChoiceItemId5, 1, 0) + IF(rewardChoiceItemId6, 1, 0)) as numChoices'; - $this->extraOpts['q']['h'][] = 'numChoices '.$cr[1].' '.$cr[2]; - return [1]; - } - - protected function cbItemRewards($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - $this->extraOpts['q']['s'][] = ', (IF(rewardItemId1, 1, 0) + IF(rewardItemId2, 1, 0) + IF(rewardItemId3, 1, 0) + IF(rewardItemId4, 1, 0)) as numRewards'; - $this->extraOpts['q']['h'][] = 'numRewards '.$cr[1].' '.$cr[2]; - return [1]; - } - - protected function cbLoremaster($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return ['AND', ['zoneOrSort', 0, '>'], [['flags', QUEST_FLAG_DAILY | QUEST_FLAG_WEEKLY | QUEST_FLAG_REPEATABLE , '&'], 0], [['specialFlags', QUEST_FLAG_SPECIAL_REPEATABLE | QUEST_FLAG_SPECIAL_MONTHLY , '&'], 0]]; - else - return ['OR', ['zoneOrSort', 0, '<'], ['flags', QUEST_FLAG_DAILY | QUEST_FLAG_WEEKLY | QUEST_FLAG_REPEATABLE , '&'], ['specialFlags', QUEST_FLAG_SPECIAL_REPEATABLE | QUEST_FLAG_SPECIAL_MONTHLY , '&']];; - } - - protected function cbSpellRewards($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return ['OR', ['sourceSpellId', 0, '>'], ['rewardSpell', 0, '>'], ['rsc.effect1Id', SpellList::$effects['teach']], ['rsc.effect2Id', SpellList::$effects['teach']], ['rsc.effect3Id', SpellList::$effects['teach']]]; - else - return ['AND', ['sourceSpellId', 0], ['rewardSpell', 0], ['rewardSpellCast', 0]]; - } - - protected function cbEarnReputation($cr) - { - if (!Util::checkNumeric($cr[1], NUM_REQ_INT)) - return false; - - if ($cr[1] > 0) - return ['OR', ['reqFactionId1', $cr[1]], ['reqFactionId2', $cr[1]]]; - else if ($cr[1] == FILTER_ENUM_ANY) // any - return ['OR', ['reqFactionId1', 0, '>'], ['reqFactionId2', 0, '>']]; - else if ($cr[1] == FILTER_ENUM_NONE) // none - return ['AND', ['reqFactionId1', 0], ['reqFactionId2', 0]]; - - return false; - } - - protected function cbClassSpec($cr) - { - if (!isset($this->enums[$cr[0]][$cr[1]])) - return false; - - $_ = $this->enums[$cr[0]][$cr[1]]; - if ($_ === true) - return ['AND', ['reqClassMask', 0, '!'], [['reqClassMask', CLASS_MASK_ALL, '&'], CLASS_MASK_ALL, '!']]; - else if ($_ === false) - return ['OR', ['reqClassMask', 0], [['reqClassMask', CLASS_MASK_ALL, '&'], CLASS_MASK_ALL]]; - else if (is_int($_)) - return ['AND', ['reqClassMask', (1 << ($_ - 1)), '&'], [['reqClassMask', CLASS_MASK_ALL, '&'], CLASS_MASK_ALL, '!']]; - - return false; - } - - protected function cbRaceSpec($cr) - { - if (!isset($this->enums[$cr[0]][$cr[1]])) - return false; - - $_ = $this->enums[$cr[0]][$cr[1]]; - if ($_ === true) - return ['AND', ['reqRaceMask', 0, '!'], [['reqRaceMask', RACE_MASK_ALL, '&'], RACE_MASK_ALL, '!'], [['reqRaceMask', RACE_MASK_ALLIANCE, '&'], RACE_MASK_ALLIANCE, '!'], [['reqRaceMask', RACE_MASK_HORDE, '&'], RACE_MASK_HORDE, '!']]; - else if ($_ === false) - return ['OR', ['reqRaceMask', 0], ['reqRaceMask', RACE_MASK_ALL], ['reqRaceMask', RACE_MASK_ALLIANCE], ['reqRaceMask', RACE_MASK_HORDE]]; - else if (is_int($_)) - return ['AND', ['reqRaceMask', (1 << ($_ - 1)), '&'], [['reqRaceMask', RACE_MASK_ALLIANCE, '&'], RACE_MASK_ALLIANCE, '!'], [['reqRaceMask', RACE_MASK_HORDE, '&'], RACE_MASK_HORDE, '!']]; - - return false; - } - - protected function cbLacksStartEnd($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - $missing = DB::Aowow()->selectCol('SELECT questId, max(method) a, min(method) b FROM ?_quests_startend GROUP BY questId HAVING (a | b) <> 3'); - if ($cr[1]) - return ['id', $missing]; - else - return ['id', $missing, '!']; - } -} - - -?> diff --git a/includes/types/spell.class.php b/includes/types/spell.class.php deleted file mode 100644 index 09e6c0d2..00000000 --- a/includes/types/spell.class.php +++ /dev/null @@ -1,2765 +0,0 @@ - [ 43, 44, 45, 46, 54, 55, 95, 118, 136, 160, 162, 172, 173, 176, 226, 228, 229, 473], // Weapons - 8 => [293, 413, 414, 415, 433], // Armor - 9 => [129, 185, 356, 762], // sec. Professions - 10 => [ 98, 109, 111, 113, 115, 137, 138, 139, 140, 141, 313, 315, 673, 759], // Languages - 11 => [164, 165, 171, 182, 186, 197, 202, 333, 393, 755, 773] // prim. Professions - ); - - public static $spellTypes = array( - 6 => 1, - 8 => 2, - 10 => 4 - ); - - public static $effects = array( - 'heal' => [ 0,/*3,*/10, 67, 75, 136 ], // , /*Dummy*/, Heal, Heal Max Health, Heal Mechanical, Heal Percent - 'damage' => [ 0, 2, 3, 9, 62 ], // , Dummy, School Damage, Health Leech, Power Burn - 'itemCreate' => [24, 34, 59, 66, 157 ], // createItem, changeItem, randomItem, createManaGem, createItem2 - 'trigger' => [ 3, 32, 64, 101, 142, 148, 151, 152, 155, 160, 164], // dummy, trigger missile, trigger spell, feed pet, force cast, force cast with value, unk, trigger spell 2, unk, dualwield 2H, unk, remove aura - 'teach' => [36, 57, /*133*/ ] // learn spell, learn pet spell, /*unlearn specialization*/ - ); - - public static $auras = array( - 'heal' => [ 4, 8, 62, 69, 97, 226 ], // Dummy, Periodic Heal, Periodic Health Funnel, School Absorb, Mana Shield, Periodic Dummy - 'damage' => [ 3, 4, 15, 53, 89, 162, 226 ], // Periodic Damage, Dummy, Damage Shield, Periodic Health Leech, Periodic Damage Percent, Power Burn Mana, Periodic Dummy - 'itemCreate' => [86 ], // Channel Death Item - 'trigger' => [ 4, 23, 42, 48, 109, 226, 227, 231, 236, 284 ], // dummy; 23/227: periodic trigger spell (with value); 42/231: proc trigger spell (with value); 48: unk; 109: add target trigger; 226: periodic dummy; 236: control vehicle; 284: linked - 'teach' => [ ] - ); - - private $spellVars = []; - private $refSpells = []; - private $tools = []; - private $interactive = false; - private $charLevel = MAX_LEVEL; - - protected $queryBase = 'SELECT s.*, s.id AS ARRAY_KEY FROM ?_spell s'; - protected $queryOpts = array( - 's' => [['src', 'sr', 'ic', 'ica']], // 6: Type::SPELL - 'ic' => ['j' => ['?_icons ic ON ic.id = s.iconId', true], 's' => ', ic.name AS iconString'], - 'ica' => ['j' => ['?_icons ica ON ica.id = s.iconIdAlt', true], 's' => ', ica.name AS iconStringAlt'], - 'sr' => ['j' => ['?_spellrange sr ON sr.id = s.rangeId'], 's' => ', sr.rangeMinHostile, sr.rangeMinFriend, sr.rangeMaxHostile, sr.rangeMaxFriend, sr.name_loc0 AS rangeText_loc0, sr.name_loc2 AS rangeText_loc2, sr.name_loc3 AS rangeText_loc3, sr.name_loc4 AS rangeText_loc4, sr.name_loc6 AS rangeText_loc6, sr.name_loc8 AS rangeText_loc8'], - 'src' => ['j' => ['?_source src ON type = 6 AND typeId = s.id', true], 's' => ', src1, src2, src3, src4, src5, src6, src7, src8, src9, src10, src11, src12, src13, src14, src15, src16, src17, src18, src19, src20, src21, src22, src23, src24'] - ); - - public function __construct($conditions = []) - { - parent::__construct($conditions); - - if ($this->error) - return; - - // post processing - $foo = DB::World()->selectCol('SELECT perfectItemType FROM skill_perfect_item_template WHERE spellId IN (?a)', $this->getFoundIDs()); - foreach ($this->iterate() as &$_curTpl) - { - // required for globals - if ($idx = $this->canCreateItem()) - foreach ($idx as $i) - $foo[] = (int)$_curTpl['effect'.$i.'CreateItemId']; - - for ($i = 1; $i <= 8; $i++) - if ($_curTpl['reagent'.$i] > 0) - $foo[] = (int)$_curTpl['reagent'.$i]; - - for ($i = 1; $i <= 2; $i++) - if ($_curTpl['tool'.$i] > 0) - $foo[] = (int)$_curTpl['tool'.$i]; - - // ranks - $this->ranks[$this->id] = $this->getField('rank', true); - - // sources - for ($i = 1; $i < 25; $i++) - { - if ($_ = $_curTpl['src'.$i]) - $this->sources[$this->id][$i][] = $_; - - unset($_curTpl['src'.$i]); - } - - // set full masks to 0 - $_curTpl['reqClassMask'] &= CLASS_MASK_ALL; - if ($_curTpl['reqClassMask'] == CLASS_MASK_ALL) - $_curTpl['reqClassMask'] = 0; - - $_curTpl['reqRaceMask'] &= RACE_MASK_ALL; - if ($_curTpl['reqRaceMask'] == RACE_MASK_ALL) - $_curTpl['reqRaceMask'] = 0; - - // unpack skillLines - $_curTpl['skillLines'] = []; - if ($_curTpl['skillLine1'] < 0) - { - foreach (Game::$skillLineMask[$_curTpl['skillLine1']] as $idx => $pair) - if ($_curTpl['skillLine2OrMask'] & (1 << $idx)) - $_curTpl['skillLines'][] = $pair[1]; - } - else if ($sec = $_curTpl['skillLine2OrMask']) - { - if ($this->id == 818) // and another hack .. basic Campfire (818) has deprecated skill Survival (142) as first skillLine - $_curTpl['skillLines'] = [$sec, $_curTpl['skillLine1']]; - else - $_curTpl['skillLines'] = [$_curTpl['skillLine1'], $sec]; - } - else if ($prim = $_curTpl['skillLine1']) - $_curTpl['skillLines'] = [$prim]; - - unset($_curTpl['skillLine1']); - unset($_curTpl['skillLine2OrMask']); - - if (!$_curTpl['iconString']) - $_curTpl['iconString'] = 'inv_misc_questionmark'; - } - - if ($foo) - $this->relItems = new ItemList(array(['i.id', array_unique($foo)], CFG_SQL_LIMIT_NONE)); - } - - // use if you JUST need the name - public static function getName($id) - { - $n = DB::Aowow()->SelectRow('SELECT name_loc0, name_loc2, name_loc3, name_loc4, name_loc6, name_loc8 FROM ?_spell WHERE id = ?d', $id ); - return Util::localizedString($n, 'name'); - } - // end static use - - // required for item-comparison - public function getStatGain() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $stats = []; - - for ($i = 1; $i <= 3; $i++) - { - $pts = $this->calculateAmountForCurrent($i)[1]; - $mv = $this->curTpl['effect'.$i.'MiscValue']; - $au = $this->curTpl['effect'.$i.'AuraId']; - - // Enchant Item Permanent (53) / Temporary (54) - if (in_array($this->curTpl['effect'.$i.'Id'], [53, 54])) - { - if ($mv && ($json = DB::Aowow()->selectRow('SELECT * FROM ?_item_stats WHERE `type` = ?d AND `typeId` = ?d', Type::ENCHANTMENT, $mv))) - { - $mods = []; - foreach ($json as $str => $val) - if ($val && ($idx = array_search($str, Game::$itemMods))) - $mods[$idx] = $val; - - if ($mods) - Util::arraySumByKey($stats, $mods); - } - - continue; - } - - switch ($au) - { - case 29: // ModStat MiscVal:type - if ($mv < 0) // all stats - { - for ($iMod = ITEM_MOD_AGILITY; $iMod <= ITEM_MOD_STAMINA; $iMod++) - Util::arraySumByKey($stats, [$iMod => $pts]); - } - else if ($mv == STAT_STRENGTH) // one stat - Util::arraySumByKey($stats, [ITEM_MOD_STRENGTH => $pts]); - else if ($mv == STAT_AGILITY) - Util::arraySumByKey($stats, [ITEM_MOD_AGILITY => $pts]); - else if ($mv == STAT_STAMINA) - Util::arraySumByKey($stats, [ITEM_MOD_STAMINA => $pts]); - else if ($mv == STAT_INTELLECT) - Util::arraySumByKey($stats, [ITEM_MOD_INTELLECT => $pts]); - else if ($mv == STAT_SPIRIT) - Util::arraySumByKey($stats, [ITEM_MOD_SPIRIT => $pts]); - else // one bullshit - trigger_error('AuraId 29 of spell #'.$this->id.' has wrong statId #'.$mv, E_USER_WARNING); - - break; - case 34: // Increase Health - case 230: - case 250: - Util::arraySumByKey($stats, [ITEM_MOD_HEALTH => $pts]); - break; - case 13: // damage splpwr + physical (dmg & any) - // + weapon damage - if ($mv == (1 << SPELL_SCHOOL_NORMAL)) - { - Util::arraySumByKey($stats, [ITEM_MOD_WEAPON_DMG => $pts]); - break; - } - - // full magic mask, also counts towards healing - if ($mv == SPELL_MAGIC_SCHOOLS) - { - Util::arraySumByKey($stats, [ITEM_MOD_SPELL_POWER => $pts]); - Util::arraySumByKey($stats, [ITEM_MOD_SPELL_DAMAGE_DONE => $pts]); - } - else - { - // HolySpellpower (deprecated; still used in randomproperties) - if ($mv & (1 << SPELL_SCHOOL_HOLY)) - Util::arraySumByKey($stats, [ITEM_MOD_HOLY_POWER => $pts]); - - // FireSpellpower (deprecated; still used in randomproperties) - if ($mv & (1 << SPELL_SCHOOL_FIRE)) - Util::arraySumByKey($stats, [ITEM_MOD_FIRE_POWER => $pts]); - - // NatureSpellpower (deprecated; still used in randomproperties) - if ($mv & (1 << SPELL_SCHOOL_NATURE)) - Util::arraySumByKey($stats, [ITEM_MOD_NATURE_POWER => $pts]); - - // FrostSpellpower (deprecated; still used in randomproperties) - if ($mv & (1 << SPELL_SCHOOL_FROST)) - Util::arraySumByKey($stats, [ITEM_MOD_FROST_POWER => $pts]); - - // ShadowSpellpower (deprecated; still used in randomproperties) - if ($mv & (1 << SPELL_SCHOOL_SHADOW)) - Util::arraySumByKey($stats, [ITEM_MOD_SHADOW_POWER => $pts]); - - // ArcaneSpellpower (deprecated; still used in randomproperties) - if ($mv & (1 << SPELL_SCHOOL_ARCANE)) - Util::arraySumByKey($stats, [ITEM_MOD_ARCANE_POWER => $pts]); - } - - break; - case 135: // healing splpwr (healing & any) .. not as a mask.. - Util::arraySumByKey($stats, [ITEM_MOD_SPELL_HEALING_DONE => $pts]); - break; - case 35: // ModPower - MiscVal:type see defined Powers only energy/mana in use - if ($mv == POWER_HEALTH) - Util::arraySumByKey($stats, [ITEM_MOD_HEALTH => $pts]); - else if ($mv == POWER_ENERGY) - Util::arraySumByKey($stats, [ITEM_MOD_ENERGY => $pts]); - else if ($mv == POWER_RAGE) - Util::arraySumByKey($stats, [ITEM_MOD_RAGE => $pts]); - else if ($mv == POWER_MANA) - Util::arraySumByKey($stats, [ITEM_MOD_MANA => $pts]); - else if ($mv == POWER_RUNIC_POWER) - Util::arraySumByKey($stats, [ITEM_MOD_RUNIC_POWER => $pts]); - - break; - case 189: // CombatRating MiscVal:ratingMask - case 220: - if ($mod = Game::itemModByRatingMask($mv)) - Util::arraySumByKey($stats, [$mod => $pts]); - break; - case 143: // Resistance MiscVal:school - case 83: - case 22: - if ($mv == 1) // Armor only if explicitly specified - { - Util::arraySumByKey($stats, [ITEM_MOD_ARMOR => $pts]); - break; - } - - if ($mv == 2) // holy-resistance ONLY if explicitly specified (shouldn't even exist...) - { - Util::arraySumByKey($stats, [ITEM_MOD_HOLY_RESISTANCE => $pts]); - break; - } - - for ($j = 0; $j < 7; $j++) - { - if (($mv & (1 << $j)) == 0) - continue; - - switch ($j) - { - case 2: - Util::arraySumByKey($stats, [ITEM_MOD_FIRE_RESISTANCE => $pts]); - break; - case 3: - Util::arraySumByKey($stats, [ITEM_MOD_NATURE_RESISTANCE => $pts]); - break; - case 4: - Util::arraySumByKey($stats, [ITEM_MOD_FROST_RESISTANCE => $pts]); - break; - case 5: - Util::arraySumByKey($stats, [ITEM_MOD_SHADOW_RESISTANCE => $pts]); - break; - case 6: - Util::arraySumByKey($stats, [ITEM_MOD_ARCANE_RESISTANCE => $pts]); - break; - } - } - break; - case 8: // hp5 - case 84: - case 161: - Util::arraySumByKey($stats, [ITEM_MOD_HEALTH_REGEN => $pts]); - break; - case 85: // mp5 - Util::arraySumByKey($stats, [ITEM_MOD_MANA_REGENERATION => $pts]); - break; - case 99: // atkpwr - Util::arraySumByKey($stats, [ITEM_MOD_ATTACK_POWER => $pts]); - break; // ?carries over to rngatkpwr? - case 124: // rngatkpwr - Util::arraySumByKey($stats, [ITEM_MOD_RANGED_ATTACK_POWER => $pts]); - break; - case 158: // blockvalue - Util::arraySumByKey($stats, [ITEM_MOD_BLOCK_VALUE => $pts]); - break; - case 240: // ModExpertise - Util::arraySumByKey($stats, [ITEM_MOD_EXPERTISE_RATING => $pts]); - break; - case 123: // Mod Target Resistance - if ($mv == 0x7C && $pts < 0) - Util::arraySumByKey($stats, [ITEM_MOD_SPELL_PENETRATION => -$pts]); - break; - } - } - - $data[$this->id] = $stats; - } - - return $data; - } - - public function getProfilerMods() - { - // weapon hand check: param: slot, class, subclass, value - $whCheck = '$function() { var j, w = _inventory.getInventory()[%d]; if (!w[0] || !g_items[w[0]]) { return 0; } j = g_items[w[0]].jsonequip; return (j.classs == %d && (%d & (1 << (j.subclass)))) ? %d : 0; }'; - - $data = $this->getStatGain(); // flat gains - foreach ($data as $id => &$spellData) - { - foreach ($spellData as $modId => $val) - { - if (!isset(Game::$itemMods[$modId])) - continue; - - if ($modId == ITEM_MOD_EXPERTISE_RATING) // not a rating .. pure expertise - $spellData['exp'] = $val; - else - $spellData[Game::$itemMods[$modId]] = $val; - - unset($spellData[$modId]); - } - - // apply weapon restrictions - $this->getEntry($id); - $class = $this->getField('equippedItemClass'); - $subClass = $this->getField('equippedItemSubClassMask'); - $slot = $subClass & 0x5000C ? 18 : 16; - if ($class != ITEM_CLASS_WEAPON || !$subClass) - continue; - - foreach ($spellData as $json => $pts) - $spellData[$json] = [1, 'functionOf', sprintf($whCheck, $slot, $class, $subClass, $pts)]; - } - - // 4 possible modifiers found - // => [0.15, 'functionOf', ] - // => [0.33, 'percentOf', ] - // => [123, 'add'] - // => ... as from getStatGain() - - $modXByStat = function (&$arr, $stat, $pts) use (&$mv) - { - if ($mv == STAT_STRENGTH) - $arr[$stat ?: 'str'] = [$pts / 100, 'percentOf', 'str']; - else if ($mv == STAT_AGILITY) - $arr[$stat ?: 'agi'] = [$pts / 100, 'percentOf', 'agi']; - else if ($mv == STAT_STAMINA) - $arr[$stat ?: 'sta'] = [$pts / 100, 'percentOf', 'sta']; - else if ($mv == STAT_INTELLECT) - $arr[$stat ?: 'int'] = [$pts / 100, 'percentOf', 'int']; - else if ($mv == STAT_SPIRIT) - $arr[$stat ?: 'spi'] = [$pts / 100, 'percentOf', 'spi']; - }; - - $modXBySchool = function (&$arr, $stat, $val, $mask = null) use (&$mv) - { - if (($mask ?: $mv) & (1 << SPELL_SCHOOL_HOLY)) - $arr['hol'.$stat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'hol'.$stat]; - if (($mask ?: $mv) & (1 << SPELL_SCHOOL_FIRE)) - $arr['fir'.$stat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'fir'.$stat]; - if (($mask ?: $mv) & (1 << SPELL_SCHOOL_NATURE)) - $arr['nat'.$stat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'nat'.$stat]; - if (($mask ?: $mv) & (1 << SPELL_SCHOOL_FROST)) - $arr['fro'.$stat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'fro'.$stat]; - if (($mask ?: $mv) & (1 << SPELL_SCHOOL_SHADOW)) - $arr['sha'.$stat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'sha'.$stat]; - if (($mask ?: $mv) & (1 << SPELL_SCHOOL_ARCANE)) - $arr['arc'.$stat] = is_array($val) ? $val : [$val / 100, 'percentOf', 'arc'.$stat]; - }; - - $jsonStat = function ($stat) - { - if ($stat == STAT_STRENGTH) - return 'str'; - if ($stat == STAT_AGILITY) - return 'agi'; - if ($stat == STAT_STAMINA) - return 'sta'; - if ($stat == STAT_INTELLECT) - return 'int'; - if ($stat == STAT_SPIRIT) - return 'spi'; - }; - - foreach ($this->iterate() as $id => $__) - { - // Priest: Spirit of Redemption is a spell but also a passive. *yaaayyyy* - if (($this->getField('cuFlags') & SPELL_CU_TALENTSPELL) && $id != 20711) - continue; - - // curious cases of OH MY FUCKING GOD WHY?! - if ($id == 16268) // Shaman - Spirit Weapons (parry is normaly stored in g_statistics) - { - $data[$id]['parrypct'] = [5, 'add']; - continue; - } - - if ($id == 20550) // Tauren - Endurance (dependant on base health) ... if you are looking for something elegant, look away! - { - $data[$id]['health'] = [0.05, 'functionOf', '$function(p) { return g_statistics.combo[p.classs][p.level][5]; }']; - continue; - } - - for ($i = 1; $i < 4; $i++) - { - $pts = $this->calculateAmountForCurrent($i)[1]; - $mv = $this->getField('effect'.$i.'MiscValue'); - $mvB = $this->getField('effect'.$i.'MiscValueB'); - $au = $this->getField('effect'.$i.'AuraId'); - $class = $this->getField('equippedItemClass'); - $subClass = $this->getField('equippedItemSubClassMask'); - - - /* ISSUE! - mods formated like ['' => [, 'percentOf', '']] are applied as multiplier and not - as a flat value (that is equal to the percentage, like they should be). So the stats-table won't show the actual deficit - */ - - switch ($au) - { - case 101: // Mod Resistance Percent - case 142: // Mod Base Resistance Percent - if ($mv == 1) // Armor only if explicitly specified only affects armor from equippment - $data[$id]['armor'] = [$pts / 100, 'percentOf', ['armor', 0]]; - else if ($mv) - $modXBySchool($data[$id], 'res', $pts); - break; - case 182: // Mod Resistance Of Stat Percent - if ($mv == 1) // Armor only if explicitly specified - $data[$id]['armor'] = [$pts / 100, 'percentOf', $jsonStat($mvB)]; - else if ($mv) - $modXBySchool($data[$id], 'res', [$pts / 100, 'percentOf', $jsonStat($mvB)]); - break; - case 137: // mod stat percent - if ($mv > -1) // one stat - $modXByStat($data[$id], null, $pts); - else if ($mv < 0) // all stats - for ($iMod = ITEM_MOD_AGILITY; $iMod <= ITEM_MOD_STAMINA; $iMod++) - $data[$id][Game::$itemMods[$iMod]] = [$pts / 100, 'percentOf', Game::$itemMods[$iMod]]; - break; - case 174: // Mod Spell Damage Of Stat Percent - $mv = $mv ?: SPELL_MAGIC_SCHOOLS; - $modXBySchool($data[$id], 'spldmg', [$pts / 100, 'percentOf', $jsonStat($mvB)]); - break; - case 212: // Mod Ranged Attack Power Of Stat Percent - $modXByStat($data[$id], 'rgdatkpwr', $pts); - break; - case 268: // Mod Attack Power Of Stat Percent - $modXByStat($data[$id], 'mleatkpwr', $pts); - break; - case 175: // Mod Spell Healing Of Stat Percent - $modXByStat($data[$id], 'splheal', $pts); - break; - case 219: // Mod Mana Regeneration from Stat - $modXByStat($data[$id], 'manargn', $pts); - break; - case 134: // Mod Mana Regeneration Interrupt - $data[$id]['icmanargn'] = [$pts / 100, 'percentOf', 'oocmanargn']; - break; - case 57: // Mod Spell Crit Chance - case 71: // Mod Spell Crit Chance School - $mv = $mv ?: SPELL_MAGIC_SCHOOLS; - $modXBySchool($data[$id], 'splcritstrkpct', [$pts, 'add']); - if (($mv & SPELL_MAGIC_SCHOOLS) == SPELL_MAGIC_SCHOOLS) - $data[$id]['splcritstrkpct'] = [$pts, 'add']; - break; - case 285: // Mod Attack Power Of Armor - $data[$id]['mleatkpwr'] = [1 / $pts, 'percentOf', 'fullarmor']; - $data[$id]['rgdatkpwr'] = [1 / $pts, 'percentOf', 'fullarmor']; - break; - case 52: // Mod Physical Crit Percent - if ($class < 1 || ($class == ITEM_CLASS_WEAPON && ($subClass & 0x5000C))) - $data[$id]['rgdcritstrkpct'] = [1, 'functionOf', sprintf($whCheck, 18, $class, $subClass, $pts)]; - // $data[$id]['rgdcritstrkpct'] = [$pts, 'add']; - if ($class < 1 || ($class == ITEM_CLASS_WEAPON && ($subClass & 0xA5F3))) - $data[$id]['mlecritstrkpct'] = [1, 'functionOf', sprintf($whCheck, 16, $class, $subClass, $pts)]; - // $data[$id]['mlecritstrkpct'] = [$pts, 'add']; - break; - case 47: // Mod Parry Percent - $data[$id]['parrypct'] = [$pts, 'add']; - break; - case 49: // Mod Dodge Percent - $data[$id]['dodgepct'] = [$pts, 'add']; - break; - case 51: // Mod Block Percent - $data[$id]['blockpct'] = [$pts, 'add']; - break; - case 132: // Mod Increase Energy Percent - if ($mv == POWER_HEALTH) - $data[$id]['health'] = [$pts / 100, 'percentOf', 'health']; - else if ($mv == POWER_ENERGY) - $data[$id]['energy'] = [$pts / 100, 'percentOf', 'energy']; - else if ($mv == POWER_MANA) - $data[$id]['mana'] = [$pts / 100, 'percentOf', 'mana']; - else if ($mv == POWER_RAGE) - $data[$id]['rage'] = [$pts / 100, 'percentOf', 'rage']; - else if ($mv == POWER_RUNIC_POWER) - $data[$id]['runic'] = [$pts / 100, 'percentOf', 'runic']; - break; - case 133: // Mod Increase Health Percent - $data[$id]['health'] = [$pts / 100, 'percentOf', 'health']; - break; - case 150: // Mod Shield Blockvalue Percent - $data[$id]['block'] = [$pts / 100, 'percentOf', 'block']; - break; - case 290: // Mod Crit Percent - $data[$id]['mlecritstrkpct'] = [$pts, 'add']; - $data[$id]['rgdcritstrkpct'] = [$pts, 'add']; - $data[$id]['splcritstrkpct'] = [$pts, 'add']; - break; - case 237: // Mod Spell Damage Of Attack Power - $mv = $mv ?: SPELL_MAGIC_SCHOOLS; - $modXBySchool($data[$id], 'spldmg', [$pts / 100, 'percentOf', 'mleatkpwr']); - break; - case 238: // Mod Spell Healing Of Attack Power - $data[$id]['splheal'] = [$pts / 100, 'percentOf', 'mleatkpwr']; - break; - case 166: // Mod Attack Power Percent [ingmae only melee..?] - $data[$id]['mleatkpwr'] = [$pts / 100, 'percentOf', 'mleatkpwr']; - break; - case 88: // Mod Health Regeneration Percent - $data[$id]['healthrgn'] = [$pts / 100, 'percentOf', 'healthrgn']; - break; - } - } - } - - return $data; - } - - // halper - public function getReagentsForCurrent() - { - $data = []; - - for ($i = 1; $i <= 8; $i++) - if ($this->curTpl['reagent'.$i] > 0 && $this->curTpl['reagentCount'.$i]) - $data[$this->curTpl['reagent'.$i]] = [$this->curTpl['reagent'.$i], $this->curTpl['reagentCount'.$i]]; - - return $data; - } - - public function getToolsForCurrent() - { - if ($this->tools) - return $this->tools; - - $tools = []; - for ($i = 1; $i <= 2; $i++) - { - // TotemCategory - if ($_ = $this->curTpl['toolCategory'.$i]) - { - $tc = DB::Aowow()->selectRow('SELECT * FROM ?_totemcategory WHERE id = ?d', $_); - $tools[$i + 1] = array( - 'id' => $_, - 'name' => Util::localizedString($tc, 'name')); - } - - // Tools - if (!$this->curTpl['tool'.$i]) - continue; - - foreach ($this->relItems->iterate() as $relId => $__) - { - if ($relId != $this->curTpl['tool'.$i]) - continue; - - $tools[$i - 1] = array( - 'itemId' => $relId, - 'name' => $this->relItems->getField('name', true), - 'quality' => $this->relItems->getField('quality') - ); - - break; - } - } - - $this->tools = array_reverse($tools); - - return $this->tools; - } - - public function getModelInfo($spellId = 0, $effIdx = 0) - { - $displays = [0 => []]; - foreach ($this->iterate() as $id => $__) - { - if ($spellId && $spellId != $id) - continue; - - for ($i = 1; $i < 4; $i++) - { - $effMV = $this->curTpl['effect'.$i.'MiscValue']; - if (!$effMV) - continue; - - // GO Model from MiscVal - if (in_array($this->curTpl['effect'.$i.'Id'], [50, 76, 104, 105, 106, 107])) - { - if (isset($displays[Type::OBJECT][$id])) - $displays[Type::OBJECT][$id][0][] = $i; - else - $displays[Type::OBJECT][$id] = [[$i], $effMV]; - } - // NPC Model from MiscVal - else if (in_array($this->curTpl['effect'.$i.'Id'], [28, 90, 56, 112, 134]) || in_array($this->curTpl['effect'.$i.'AuraId'], [56, 78])) - { - if (isset($displays[Type::NPC][$id])) - $displays[Type::NPC][$id][0][] = $i; - else - $displays[Type::NPC][$id] = [[$i], $effMV]; - } - // Shapeshift - else if ($this->curTpl['effect'.$i.'AuraId'] == 36) - { - $subForms = array( - 892 => [892, 29407, 29406, 29408, 29405], // Cat - NE - 8571 => [8571, 29410, 29411, 29412], // Cat - Tauren - 2281 => [2281, 29413, 29414, 29416, 29417], // Bear - NE - 2289 => [2289, 29415, 29418, 29419, 29420, 29421] // Bear - Tauren - ); - - if ($st = DB::Aowow()->selectRow('SELECT *, displayIdA as model1, displayIdH as model2 FROM ?_shapeshiftforms WHERE id = ?d', $effMV)) - { - foreach ([1, 2] as $j) - if (isset($subForms[$st['model'.$j]])) - $st['model'.$j] = $subForms[$st['model'.$j]][array_rand($subForms[$st['model'.$j]])]; - - $displays[0][$id][$i] = array( - 'typeId' => 0, - 'displayId' => $st['model2'] ? $st['model'.rand(1, 2)] : $st['model1'], - 'creatureType' => $st['creatureType'], - 'displayName' => Lang::game('st', $effMV) - ); - } - } - } - } - - $results = $displays[0]; - - if (!empty($displays[Type::NPC])) - { - $nModels = new CreatureList(array(['id', array_column($displays[Type::NPC], 1)])); - foreach ($nModels->iterate() as $nId => $__) - { - foreach ($displays[Type::NPC] as $srcId => [$indizes, $npcId]) - { - if ($npcId == $nId) - { - foreach ($indizes as $idx) - { - $results[$srcId][$idx] = array( - 'typeId' => $nId, - 'displayId' => $nModels->getRandomModelId(), - 'displayName' => $nModels->getField('name', true) - ); - } - } - } - } - } - - if (!empty($displays[Type::OBJECT])) - { - $oModels = new GameObjectList(array(['id', array_column($displays[Type::OBJECT], 1)])); - foreach ($oModels->iterate() as $oId => $__) - { - foreach ($displays[Type::OBJECT] as $srcId => [$indizes, $objId]) - { - if ($objId == $oId) - { - foreach ($indizes as $idx) - { - $results[$srcId][$idx] = array( - 'typeId' => $oId, - 'displayId' => $oModels->getField('displayId'), - 'displayName' => $oModels->getField('name', true) - ); - } - } - } - } - } - - if ($spellId && $effIdx) - return !empty($results[$spellId][$effIdx]) ? $results[$spellId][$effIdx] : 0; - - return $results; - } - - private function createRangesForCurrent() - { - if (!$this->curTpl['rangeMaxHostile']) - return ''; - - // minRange exists; show as range - if ($this->curTpl['rangeMinHostile']) - return sprintf(Lang::spell('range'), $this->curTpl['rangeMinHostile'].' - '.$this->curTpl['rangeMaxHostile']); - // friend and hostile differ; do color - else if ($this->curTpl['rangeMaxHostile'] != $this->curTpl['rangeMaxFriend']) - return sprintf(Lang::spell('range'), ''.$this->curTpl['rangeMaxHostile'].' - '.$this->curTpl['rangeMaxFriend']. ''); - // hardcode: "melee range" - else if ($this->curTpl['rangeMaxHostile'] == 5) - return Lang::spell('meleeRange'); - // hardcode "unlimited range" - else if ($this->curTpl['rangeMaxHostile'] == 50000) - return Lang::spell('unlimRange'); - // regular case - else - return sprintf(Lang::spell('range'), $this->curTpl['rangeMaxHostile']); - } - - public function createPowerCostForCurrent() - { - $str = ''; - - // check for custom PowerDisplay - $pt = $this->curTpl['powerType']; - - if ($pt == POWER_RUNE && ($rCost = ($this->curTpl['powerCostRunes'] & 0x333))) - { // Frost 2|1 - Unholy 2|1 - Blood 2|1 - $runes = []; - if ($_ = (($rCost & 0x300) >> 8)) - $runes[] = $_.' '.Lang::spell('powerRunes', 2); - if ($_ = (($rCost & 0x030) >> 4)) - $runes[] = $_.' '.Lang::spell('powerRunes', 1); - if ($_ = ($rCost & 0x003)) - $runes[] = $_.' '.Lang::spell('powerRunes', 0); - - $str .= implode(', ', $runes); - } - else if ($this->curTpl['powerCostPercent'] > 0) // power cost: pct over static - $str .= $this->curTpl['powerCostPercent']."% ".sprintf(Lang::spell('pctCostOf'), mb_strtolower(Lang::spell('powerTypes', $pt))); - else if ($this->curTpl['powerCost'] > 0 || $this->curTpl['powerPerSecond'] > 0 || $this->curTpl['powerCostPerLevel'] > 0) - $str .= ($pt == POWER_RAGE || $pt == POWER_RUNIC_POWER ? $this->curTpl['powerCost'] / 10 : $this->curTpl['powerCost']).' '.Util::ucFirst(Lang::spell('powerTypes', $pt)); - - // append periodic cost - if ($this->curTpl['powerPerSecond'] > 0) - $str .= sprintf(Lang::spell('costPerSec'), $this->curTpl['powerPerSecond']); - - // append level cost (todo (low): work in as scaling cost) - if ($this->curTpl['powerCostPerLevel'] > 0) - $str .= sprintf(Lang::spell('costPerLevel'), $this->curTpl['powerCostPerLevel']); - - return $str; - } - - public function createCastTimeForCurrent($short = true, $noInstant = true) - { - if ($this->isChanneledSpell()) - return Lang::spell('channeled'); - else if ($this->curTpl['castTime'] > 0) - return $short ? sprintf(Lang::spell('castIn'), $this->curTpl['castTime']) : Util::formatTime($this->curTpl['castTime'] * 1000); - // show instant only for player/pet/npc abilities (todo (low): unsure when really hidden (like talent-case)) - else if ($noInstant && !in_array($this->curTpl['typeCat'], [11, 7, -3, -6, -8, 0]) && !($this->curTpl['cuFlags'] & SPELL_CU_TALENTSPELL)) - return ''; - // SPELL_ATTR0_ABILITY instant ability.. yeah, wording thing only (todo (low): rule is imperfect) - else if ($this->curTpl['damageClass'] != 1 || $this->curTpl['attributes0'] & SPELL_ATTR0_ABILITY) - return Lang::spell('instantPhys'); - else // instant cast - return Lang::spell('instantMagic'); - } - - private function createCooldownForCurrent() - { - if ($this->curTpl['recoveryTime']) - return sprintf(Lang::game('cooldown'), Util::formatTime($this->curTpl['recoveryTime'], true)); - else if ($this->curTpl['recoveryCategory']) - return sprintf(Lang::game('cooldown'), Util::formatTime($this->curTpl['recoveryCategory'], true)); - else - return ''; - } - - // formulae base from TC - private function calculateAmountForCurrent($effIdx, $altTpl = null) - { - $ref = $altTpl ?: $this; - $level = $this->charLevel; - $rppl = $ref->getField('effect'.$effIdx.'RealPointsPerLevel'); - $base = $ref->getField('effect'.$effIdx.'BasePoints'); - $add = $ref->getField('effect'.$effIdx.'DieSides'); - $maxLvl = $ref->getField('maxLevel'); - $baseLvl = $ref->getField('baseLevel'); - - /* when should level scaling be actively worked into tooltips? - if ($rppl) - { - if ($level > $maxLvl && $maxLvl > 0) - $level = $maxLvl; - else if ($level < $baseLvl) - $level = $baseLvl; - - if (!$ref->getField('atributes0') & SPELL_ATTR0_PASSIVE) - $level -= $ref->getField('spellLevel'); - - $base += (int)($level * $rppl); - } - */ - - return [ - $add ? $base + 1 : $base, - $base + $add, - $rppl ? '' : null, - $rppl ? '' : null - ]; - } - - public function canCreateItem() - { - $idx = []; - for ($i = 1; $i < 4; $i++) - if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::$effects['itemCreate']) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::$auras['itemCreate'])) - if ($this->curTpl['effect'.$i.'CreateItemId'] > 0) - $idx[] = $i; - - return $idx; - } - - public function canTriggerSpell() - { - $idx = []; - for ($i = 1; $i < 4; $i++) - if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::$effects['trigger']) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::$auras['trigger'])) - if ($this->curTpl['effect'.$i.'TriggerSpell'] > 0 || ($this->curTpl['effect'.$i.'Id'] == 155 && $this->curTpl['effect'.$i.'MiscValue'] > 0)) - $idx[] = $i; - - return $idx; - } - - public function canTeachSpell() - { - $idx = []; - for ($i = 1; $i < 4; $i++) - if (in_array($this->curTpl['effect'.$i.'Id'], SpellList::$effects['teach']) || in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::$auras['teach'])) - if ($this->curTpl['effect'.$i.'TriggerSpell'] > 0) - $idx[] = $i; - - return $idx; - } - - public function isChanneledSpell() - { - return $this->curTpl['attributes1'] & (SPELL_ATTR1_CHANNELED_1 | SPELL_ATTR1_CHANNELED_2); - } - - public function isHealingSpell() - { - for ($i = 1; $i < 4; $i++) - if (!in_array($this->curTpl['effect'.$i.'Id'], SpellList::$effects['heal']) && !in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::$auras['heal'])) - return false; - - return true; - } - - public function isDamagingSpell() - { - for ($i = 1; $i < 4; $i++) - if (!in_array($this->curTpl['effect'.$i.'Id'], SpellList::$effects['damage']) && !in_array($this->curTpl['effect'.$i.'AuraId'], SpellList::$auras['damage'])) - return false; - - return true; - } - - public function periodicEffectsMask() - { - $effMask = 0x0; - - for ($i = 1; $i < 4; $i++) - if ($this->curTpl['effect'.$i.'Periode'] > 0) - $effMask |= 1 << ($i - 1); - - return $effMask; - } - - // description-, buff-parsing component - private function resolveEvaluation($formula) - { - // see Traits in javascript locales - - $PlayerName = Lang::main('name'); - $pl = $PL = /* playerLevel set manually ? $this->charLevel : */ $this->interactive ? sprintf(Util::$dfnString, 'LANG.level', Lang::game('level')) : Lang::game('level'); - $ap = $AP = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.atkpwr[0]', Lang::spell('traitShort', 'atkpwr')) : Lang::spell('traitShort', 'atkpwr'); - $rap = $RAP = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.rgdatkpwr[0]', Lang::spell('traitShort', 'rgdatkpwr')) : Lang::spell('traitShort', 'rgdatkpwr'); - $sp = $SP = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.splpwr[0]', Lang::spell('traitShort', 'splpwr')) : Lang::spell('traitShort', 'splpwr'); - $spa = $SPA = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.arcsplpwr[0]', Lang::spell('traitShort', 'arcsplpwr')) : Lang::spell('traitShort', 'arcsplpwr'); - $spfi = $SPFI = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.firsplpwr[0]', Lang::spell('traitShort', 'firsplpwr')) : Lang::spell('traitShort', 'firsplpwr'); - $spfr = $SPFR = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.frosplpwr[0]', Lang::spell('traitShort', 'frosplpwr')) : Lang::spell('traitShort', 'frosplpwr'); - $sph = $SPH = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.holsplpwr[0]', Lang::spell('traitShort', 'holsplpwr')) : Lang::spell('traitShort', 'holsplpwr'); - $spn = $SPN = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.natsplpwr[0]', Lang::spell('traitShort', 'natsplpwr')) : Lang::spell('traitShort', 'natsplpwr'); - $sps = $SPS = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.shasplpwr[0]', Lang::spell('traitShort', 'shasplpwr')) : Lang::spell('traitShort', 'shasplpwr'); - $bh = $BH = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.splheal[0]', Lang::spell('traitShort', 'splheal')) : Lang::spell('traitShort', 'splheal'); - $spi = $SPI = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.spi[0]', Lang::spell('traitShort', 'spi')) : Lang::spell('traitShort', 'spi'); - $sta = $STA = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.sta[0]', Lang::spell('traitShort', 'sta')) : Lang::spell('traitShort', 'sta'); - $str = $STR = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.str[0]', Lang::spell('traitShort', 'str')) : Lang::spell('traitShort', 'str'); - $agi = $AGI = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.agi[0]', Lang::spell('traitShort', 'agi')) : Lang::spell('traitShort', 'agi'); - $int = $INT = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.int[0]', Lang::spell('traitShort', 'int')) : Lang::spell('traitShort', 'int'); - - // only 'ron test spell', guess its %-dmg mod; no idea what bc2 might be - $pa = '<$PctArcane>'; // %arcane - $pfi = '<$PctFire>'; // %fire - $pfr = '<$PctFrost>'; // %frost - $ph = '<$PctHoly>'; // %holy - $pn = '<$PctNature>'; // %nature - $ps = '<$PctShadow>'; // %shadow - $pbh = '<$PctHeal>'; // %heal - $pbhd = '<$PctHealDone>'; // %heal done - $bc2 = '<$bc2>'; // bc2 - - $HND = $hnd = $this->interactive ? sprintf(Util::$dfnString, '[Hands required by weapon]', 'HND') : 'HND'; // todo (med): localize this one - $MWS = $mws = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.mlespeed[0]', 'MWS') : 'MWS'; - $mw = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.dmgmin1[0]', 'mw') : 'mw'; - $MW = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.dmgmax1[0]', 'MW') : 'MW'; - $mwb = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.mledmgmin[0]', 'mwb') : 'mwb'; - $MWB = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.mledmgmax[0]', 'MWB') : 'MWB'; - $rwb = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.rgddmgmin[0]', 'rwb') : 'rwb'; - $RWB = $this->interactive ? sprintf(Util::$dfnString, 'LANG.traits.rgddmgmax[0]', 'RWB') : 'RWB'; - - $cond = $COND = function($a, $b, $c) { return $a ? $b : $c; }; - $eq = $EQ = function($a, $b) { return $a == $b; }; - $gt = $GT = function($a, $b) { return $a > $b; }; - $gte = $GTE = function($a, $b) { return $a >= $b; }; - $floor = $FLOOR = function($a) { return floor($a); }; - $max = $MAX = function($a, $b) { return max($a, $b); }; - $min = $MIN = function($a, $b) { return min($a, $b); }; - - if (preg_match_all('/\$\w+\b/i', $formula, $vars)) - { - - $evalable = true; - - foreach ($vars[0] as $var) // oh lord, forgive me this sin .. but is_callable seems to bug out and function_exists doesn't find lambda-functions >.< - { - $var = substr($var, 1); - - if (isset($$var)) - { - $eval = eval('return @$'.$var.';'); // attention: error suppression active here (will be logged anyway) - if (getType($eval) == 'object') - continue; - else if (is_numeric($eval)) - continue; - } - else - $$var = ''; - - $evalable = false; - break; - } - - if (!$evalable) - { - // can't eval constructs because of strings present. replace constructs with strings - $cond = $COND = !$this->interactive ? 'COND' : sprintf(Util::$dfnString, 'COND(a, b, c)
a ? b : c', 'COND'); - $eq = $EQ = !$this->interactive ? 'EQ' : sprintf(Util::$dfnString, 'EQ(a, b)
a == b', 'EQ'); - $gt = $GT = !$this->interactive ? 'GT' : sprintf(Util::$dfnString, 'GT(a, b)
a > b', 'GT'); - $gte = $GTE = !$this->interactive ? 'GTE' : sprintf(Util::$dfnString, 'GTE(a, b)
a >= b', 'GTE'); - $floor = $FLOOR = !$this->interactive ? 'FLOOR' : sprintf(Util::$dfnString, 'FLOOR(a)', 'FLOOR'); - $min = $MIN = !$this->interactive ? 'MIN' : sprintf(Util::$dfnString, 'MIN(a, b)', 'MIN'); - $max = $MAX = !$this->interactive ? 'MAX' : sprintf(Util::$dfnString, 'MAX(a, b)', 'MAX'); - $pl = $PL = !$this->interactive ? 'PL' : sprintf(Util::$dfnString, 'LANG.level', 'PL'); - - // note the " ! - return eval('return "'.$formula.'";'); - } - else - return eval('return '.$formula.';'); - } - - // since this function may be called recursively, there are cases, where the already evaluated string is tried to be evaled again, throwing parse errors - // todo (med): also quit, if we replaced vars with non-interactive text - if (strstr($formula, '') || strstr($formula, '%s (%s)'; - $result[4] = $rType; - } - - $result[0] = $base; - break; - case 'n': // ProcCharges - case 'N': - $base = $srcSpell->getField('procCharges'); - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 'o': // TotalAmount for periodic auras (with variance) - case 'O': - [$min, $max, $modStrMin, $modStrMax] = $this->calculateAmountForCurrent($effIdx, $srcSpell); - $periode = $srcSpell->getField('effect'.$effIdx.'Periode'); - $duration = $srcSpell->getField('duration'); - - if (!$periode) - { - // Mod Power Regeneration & Mod Health Regeneration have an implicit periode of 5sec - $aura = $srcSpell->getField('effect'.$effIdx.'AuraId'); - if ($aura == 84 || $aura == 85) - $periode = 5000; - else - $periode = 3000; - } - - $min *= $duration / $periode; - $max *= $duration / $periode; - - if (in_array($op, $signs) && is_numeric($oparg)) - { - eval("\$min = $min $op $oparg;"); - eval("\$max = $max $op $oparg;"); - } - - if ($this->interactive) - { - $result[2] = $modStrMin.'%s'; - $result[3] = $modStrMax.'%s'; - } - - $result[0] = $min; - $result[1] = $max; - break; - case 'q': // EffectMiscValue - case 'Q': - $base = $srcSpell->getField('effect'.$effIdx.'MiscValue'); - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 'r': // SpellRange - case 'R': - $base = $srcSpell->getField('rangeMaxHostile'); - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 's': // BasePoints (with variance) - case 'S': - [$min, $max, $modStrMin, $modStrMax] = $this->calculateAmountForCurrent($effIdx, $srcSpell); - $mv = $srcSpell->getField('effect'.$effIdx.'MiscValue'); - $aura = $srcSpell->getField('effect'.$effIdx.'AuraId'); - - if (in_array($op, $signs) && is_numeric($oparg)) - { - eval("\$min = $min $op $oparg;"); - eval("\$max = $max $op $oparg;"); - } - // Aura giving combat ratings - $rType = 0; - if ($aura == 189) - if ($rType = Game::itemModByRatingMask($mv)) - $usesScalingRating = true; - // Aura end - - if ($rType) - { - $result[2] = '%s (%s)'; - $result[4] = $rType; - } - else if ($aura == 189 && $this->interactive) - { - $result[2] = $modStrMin.'%s'; - $result[3] = $modStrMax.'%s'; - } - - $result[0] = $min; - $result[1] = $max; - break; - case 't': // Periode - case 'T': - $base = $srcSpell->getField('effect'.$effIdx.'Periode') / 1000; - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 'u': // StackCount - case 'U': - $base = $srcSpell->getField('stackAmount'); - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 'v': // MaxTargetLevel - case 'V': - $base = $srcSpell->getField('MaxTargetLevel'); - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 'x': // ChainTargetCount - case 'X': - $base = $srcSpell->getField('effect'.$effIdx.'ChainTarget'); - - if (in_array($op, $signs) && is_numeric($oparg) && is_numeric($base)) - eval("\$base = $base $op $oparg;"); - - $result[0] = $base; - break; - case 'z': // HomeZone - $result[2] = Lang::spell('home'); - break; - } - - // handle excessively precise floats - if (is_float($result[0])) - $result[0] = round($result[0], 2); - if (isset($result[1]) && is_float($result[1])) - $result[1] = round($result[1], 2); - - return $result; - } - - // description-, buff-parsing component - private function resolveFormulaString($formula, $precision = 0, &$scaling) - { - $fSuffix = '%s'; - $fRating = 0; - - // step 1: formula unpacking redux - while (($formStartPos = strpos($formula, '${')) !== false) - { - $formBrktCnt = 0; - $formPrecision = 0; - $formCurPos = $formStartPos; - - $formOutStr = ''; - - while ($formCurPos < strlen($formula)) - { - $char = $formula[$formCurPos]; - - if ($char == '}') - $formBrktCnt--; - - if ($formBrktCnt) - $formOutStr .= $char; - - if ($char == '{') - $formBrktCnt++; - - if (!$formBrktCnt && $formCurPos != $formStartPos) - break; - - $formCurPos++; - } - - if (isset($formula[++$formCurPos]) && $formula[$formCurPos] == '.') - { - $formPrecision = (int)$formula[++$formCurPos]; - ++$formCurPos; // for some odd reason the precision decimal survives if we dont increment further.. - } - - [$formOutStr, $fSuffix, $fRating] = $this->resolveFormulaString($formOutStr, $formPrecision, $scaling); - - $formula = substr_replace($formula, $formOutStr, $formStartPos, ($formCurPos - $formStartPos)); - } - - // note: broken tooltip on this one - // ${58644m1/-10} gets matched as a formula (ok), 58644m1 has no $ prefixed (not ok) - // the client scraps the m1 and prints -5864 - if ($this->id == 58644) - $formula = '$'.$formula; - - // step 2: resolve variables - $pos = 0; // continue strpos-search from this offset - $str = ''; - while (($npos = strpos($formula, '$', $pos)) !== false) - { - if ($npos != $pos) - $str .= substr($formula, $pos, $npos - $pos); - - $pos = $npos++; - - if ($formula[$pos] == '$') - $pos++; - - $varParts = $this->matchVariableString(substr($formula, $pos), $len); - if (!$varParts) - { - $str .= '#'; // mark as done, reset below - continue; - } - - $pos += $len; - - // we are resolving a formula -> omit ranges - $var = $this->resolveVariableString($varParts, $scaling); - - // time within formula -> rebase to seconds and omit timeUnit - if (strtolower($varParts['var']) == 'd') - { - $var[0] /= 1000; - unset($var[2]); - } - - $str .= $var[0]; - - // overwrite eventually inherited strings - if (isset($var[2])) - $fSuffix = $var[2]; - - // overwrite eventually inherited ratings - if (isset($var[4])) - $fRating = $var[4]; - } - $str .= substr($formula, $pos); - $str = str_replace('#', '$', $str); // reset marks - - // step 3: try to evaluate result - $evaled = $this->resolveEvaluation($str); - - $return = is_numeric($evaled) ? round($evaled, $precision) : $evaled; - - return [$return, $fSuffix, $fRating]; - } - - // should probably used only once to create ?_spell. come to think of it, it yields the same results every time.. it absolutely has to! - // although it seems to be pretty fast, even on those pesky test-spells with extra complex tooltips (Ron Test Spell X)) - public function parseText($type = 'description', $level = MAX_LEVEL, $interactive = false, &$scaling = false) - { - // oooo..kaaayy.. parsing text in 6 or 7 easy steps - // we don't use the internal iterator here. This func has to be called for the individual template. - // otherwise it will get a bit messy, when we iterate, while we iterate *yo dawg!* - - /* documentation .. sort of - bracket use - ${}.x - formulas; .x is optional; x:[0-9] .. max-precision of a floatpoint-result; default: 0 - $[] - conditionals ... like $?condition[true][false]; alternative $?!(cond1|cond2)[true]$?cond3[elseTrue][false]; ?a40120: has aura 40120; ?s40120: knows spell 40120(?) - $<> - variables - () - regular use for function-like calls - - variables in use .. caseSensitive - - game variables (optionally replace with textVars) - $PlayerName - Cpt. Obvious - $PL / $pl - PlayerLevel - $STR - Strength Attribute (not seen) - $AGI - Agility Attribute (not seen) - $STA - Stamina Attribute (not seen) - $INT - Intellect Attribute (not seen) - $SPI - Spirit Attribute - $AP - Atkpwr - $RAP - RngAtkPwr - $HND - hands used by weapon (1H, 2H) => (1, 2) - $MWS - MainhandWeaponSpeed - $mw / $MW - MainhandWeaponDamage Min/Max - $rwb / $RWB - RangedWeapon..Bonus? Min/Max - $sp - Spellpower - $spa - Spellpower Arcane - $spfi - Spellpower Fire - $spfr - Spellpower Frost - $sph - Spellpower Holy - $spn - Spellpower Nature - $sps - Spellpower Shadow - $bh - Bonus Healing - $pa - %-ArcaneDmg (as float) // V seems broken - $pfi - %-FireDmg (as float) - $pfr - %-FrostDmg (as float) - $ph - %-HolyDmg (as float) - $pn - %-NatureDmg (as float) - $ps - %-ShadowDmg (as float) - $pbh - %-HealingBonus (as float) - $pbhd - %-Healing Done (as float) // all above seem broken - $bc2 - baseCritChance? always 3.25 (unsure) - - spell variables (the stuff we can actually parse) rounding... >5 up? - $a - SpellRadius; per EffectIdx - $b - PointsPerComboPoint; per EffectIdx - $d / $D - SpellDuration; appended timeShorthand; d/D maybe base/max duration?; interpret "0" as "until canceled" - $e - EffectValueMultiplier; per EffectIdx - $f / $F - EffectDamageMultiplier; per EffectIdx - $g / $G - Gender-Switch $Gmale:female; - $h / $H - ProcChance - $i - MaxAffectedTargets - $l - LastValue-Switch; last value as condition $Ltrue:false; - $m / $M - BasePoints; per EffectIdx; m/M +1/+effectDieSides - $n - ProcCharges - $o - TotalAmount (for periodic auras); per EffectIdx - $q - EffectMiscValue; per EffectIdx - $r - SpellRange (hostile) - $s / $S - BasePoints; per EffectIdx; as Range, if applicable - $t / $T - EffectPeriode; per EffectIdx - $u - StackAmount - $v - MaxTargetLevel - $x - MaxAffectedTargets - $z - no place like - - deviations from standard procedures - division - example: $/10;2687s1 => $2687s1/10 - - also: $61829/5;s1 => $61829s1/5 - - functions in use .. caseInsensitive - $cond(a, b, c) - like SQL, if A is met use B otherwise use C - $eq(a, b) - a == b - $floor(a) - floor() - $gt(a, b) - a > b - $gte(a, b) - a >= b - $min(a, b) - min() - $max(a, b) - max() - */ - - $this->interactive = $interactive; - $this->charLevel = $level; - - // step 0: get text - $data = $this->getField($type, true); - if (empty($data) || $data == "[]") // empty tooltip shouldn't be displayed anyway - return ['', []]; - - // step 1: if the text is supplemented with text-variables, get and replace them - if ($this->curTpl['spellDescriptionVariableId'] > 0) - { - if (empty($this->spellVars[$this->id])) - { - $spellVars = DB::Aowow()->SelectCell('SELECT vars FROM ?_spellvariables WHERE id = ?d', $this->curTpl['spellDescriptionVariableId']); - $spellVars = explode("\n", $spellVars); - foreach ($spellVars as $sv) - if (preg_match('/\$(\w*\d*)=(.*)/i', trim($sv), $matches)) - $this->spellVars[$this->id][$matches[1]] = $matches[2]; - } - - // replace self-references - $reset = true; - while ($reset) - { - $reset = false; - foreach ($this->spellVars[$this->id] as $k => $sv) - { - if (preg_match('/\$<(\w*\d*)>/i', $sv, $matches)) - { - $this->spellVars[$this->id][$k] = str_replace('$<'.$matches[1].'>', '${'.$this->spellVars[$this->id][$matches[1]].'}', $sv); - $reset = true; - } - } - } - - // finally, replace SpellDescVars - foreach ($this->spellVars[$this->id] as $k => $sv) - $data = str_replace('$<'.$k.'>', $sv, $data); - } - - // step 2: resolving conditions - // aura- or spell-conditions cant be resolved for our purposes, so force them to false for now (todo (low): strg+f "know" in aowowPower.js ^.^) - - /* sequences - a) simple - $?cond[A][B] // simple case of b) - b) elseif - $?cond[A]?cond[B]..[C] // can probably be repeated as often as you wanted - c) recursive - $?cond[A][$?cond[B][..]] // can probably be stacked as deep as you wanted - - only case a) can be used for KNOW-parameter - */ - - $relSpells = []; - $data = $this->handleConditions($data, $scaling, $relSpells, true); - - // step 3: unpack formulas ${ .. }.X - $data = $this->handleFormulas($data, $scaling, true); - - // step 4: find and eliminate regular variables - $data = $this->handleVariables($data, $scaling, true); - - // step 5: variable-dependant variable-text - // special case $lONE:ELSE[:ELSE2]; or $|ONE:ELSE[:ELSE2]; - while (preg_match('/([\d\.]+)([^\d]*)(\$[l|]:*)([^:]*):([^;]*);/i', $data, $m)) - { - $plurals = explode(':', $m[5]); - $replace = ''; - - if (count($plurals) == 2) // special case: ruRU - { - switch (substr($m[1], -1)) // check last digit of number - { - case 1: - // but not 11 (teen number) - if (!in_array($m[1], [11])) - { - $replace = $m[4]; - break; - } - case 2: - case 3: - case 4: - // but not 12, 13, 14 (teen number) [11 is passthrough] - if (!in_array($m[1], [11, 12, 13, 14])) - { - $replace = $plurals[0]; - break; - } - break; - default: - $replace = $plurals[1]; - } - - } - else - $replace = ($m[1] == 1 ? $m[4] : $plurals[0]); - - $data = str_ireplace($m[1].$m[2].$m[3].$m[4].':'.$m[5].';', $m[1].$m[2].$replace, $data); - } - - // step 6: HTMLize - // colors - $data = preg_replace('/\|cff([a-f0-9]{6})(.+?)\|r/i', '$2', $data); - - // line endings - $data = strtr($data, ["\r" => '', "\n" => '
']); - - return [$data, $relSpells]; - } - - private function handleFormulas($data, &$scaling, $topLevel = false) - { - // they are stacked recursively but should be balanced .. hf - while (($formStartPos = strpos($data, '${')) !== false) - { - $formBrktCnt = 0; - $formPrecision = 0; - $formCurPos = $formStartPos; - - $formOutStr = ''; - - while ($formCurPos < strlen($data)) // only hard-exit condition, we'll hit those breaks eventually^^ - { - $char = $data[$formCurPos]; - - if ($char == '}') - $formBrktCnt--; - - if ($formBrktCnt) - $formOutStr .= $char; - - if ($char == '{') - $formBrktCnt++; - - if (!$formBrktCnt && $formCurPos != $formStartPos) - break; - - // advance position - $formCurPos++; - } - - $formCurPos++; - - // check for precision-modifiers - if ($formCurPos + 1 < strlen($data) && $data[$formCurPos] == '.' && is_numeric($data[$formCurPos + 1])) - { - $formPrecision = $data[$formCurPos + 1]; - $formCurPos += 2; - } - [$formOutVal, $formOutStr, $ratingId] = $this->resolveFormulaString($formOutStr, $formPrecision ?: ($topLevel ? 0 : 10), $scaling); - - if ($ratingId && Util::checkNumeric($formOutVal) && $this->interactive) - $resolved = sprintf($formOutStr, $ratingId, abs($formOutVal), sprintf(Util::$setRatingLevelString, $this->charLevel, $ratingId, abs($formOutVal), Util::setRatingLevel($this->charLevel, $ratingId, abs($formOutVal)))); - else if ($ratingId && Util::checkNumeric($formOutVal)) - $resolved = sprintf($formOutStr, $ratingId, abs($formOutVal), Util::setRatingLevel($this->charLevel, $ratingId, abs($formOutVal))); - else - $resolved = sprintf($formOutStr, Util::checkNumeric($formOutVal) ? abs($formOutVal) : $formOutVal); - - $data = substr_replace($data, $resolved, $formStartPos, ($formCurPos - $formStartPos)); - } - - return $data; - } - - private function handleVariables($data, &$scaling, $topLevel = false) - { - $pos = 0; // continue strpos-search from this offset - $str = ''; - while (($npos = strpos($data, '$', $pos)) !== false) - { - if ($npos != $pos) - $str .= substr($data, $pos, $npos - $pos); - - $pos = $npos++; - - if ($data[$pos] == '$') - $pos++; - - $varParts = $this->matchVariableString(substr($data, $pos), $len); - if (!$varParts) - { - $str .= '#'; // mark as done, reset below - continue; - } - - $pos += $len; - - $var = $this->resolveVariableString($varParts, $scaling); - $resolved = is_numeric($var[0]) ? abs($var[0]) : $var[0]; - if (isset($var[2])) - { - if (isset($var[4]) && $this->interactive) - $resolved = sprintf($var[2], $var[4], abs($var[0]), sprintf(Util::$setRatingLevelString, $this->charLevel, $var[4], abs($var[0]), Util::setRatingLevel($this->charLevel, $var[4], abs($var[0])))); - else if (isset($var[4])) - $resolved = sprintf($var[2], $var[4], abs($var[0]), Util::setRatingLevel($this->charLevel, $var[4], abs($var[0]))); - else - $resolved = sprintf($var[2], $resolved); - } - - if (isset($var[1]) && $var[0] != $var[1] && !isset($var[4])) - { - $_ = is_numeric($var[1]) ? abs($var[1]) : $var[1]; - $resolved .= Lang::game('valueDelim'); - $resolved .= isset($var[3]) ? sprintf($var[3], $_) : $_; - } - - $str .= $resolved; - } - $str .= substr($data, $pos); - $str = str_replace('#', '$', $str); // reset marker - - return $str; - } - - private function handleConditions($data, &$scaling, &$relSpells, $topLevel = false) - { - while (($condStartPos = strpos($data, '$?')) !== false) - { - $condBrktCnt = 0; - $condCurPos = $condStartPos + 2; // after the '$?' - $targetPart = 3; // we usually want the second pair of brackets - $curPart = 0; // parts: $? 0 [ 1 ] 2 [ 3 ] 4 ... - $condParts = []; - $isLastElse = false; - - while ($condCurPos < strlen($data)) // only hard-exit condition, we'll hit those breaks eventually^^ - { - $char = $data[$condCurPos]; - - // advance position - $condCurPos++; - - if ($char == '[') - { - $condBrktCnt++; - - if ($condBrktCnt == 1) - $curPart++; - - // previously there was no condition -> last else - if ($condBrktCnt == 1) - if (($curPart && ($curPart % 2)) && (!isset($condParts[$curPart - 1]) || empty(trim($condParts[$curPart - 1])))) - $isLastElse = true; - - if (empty($condParts[$curPart])) - continue; - } - - if (empty($condParts[$curPart])) - $condParts[$curPart] = $char; - else - $condParts[$curPart] .= $char; - - if ($char == ']') - { - $condBrktCnt--; - - if (!$condBrktCnt) - { - $condParts[$curPart] = substr($condParts[$curPart], 0, -1); - $curPart++; - } - - if ($condBrktCnt) - continue; - - if ($isLastElse) - break; - } - } - - // check if it is know-compatible - $know = 0; - if (preg_match('/\(?(\!?)[as](\d+)\)?$/i', $condParts[0], $m)) - { - if (!strstr($condParts[1], '$?')) - if (!strstr($condParts[3], '$?')) - if (!isset($condParts[5])) - $know = $m[2]; - - // found a negation -> switching condition target - if ($m[1] == '!') - $targetPart = 1; - } - // if not, what part of the condition should be used? - else if (preg_match('/(([\W\D]*[as]\d+)|([^\[]*))/i', $condParts[0], $m) && !empty($m[3])) - { - $cnd = $this->resolveEvaluation($m[3]); - if ((is_numeric($cnd) || is_bool($cnd)) && $cnd) // only case, deviating from normal; positive result -> use [true] - $targetPart = 1; - } - - // recursive conditions - if (strstr($condParts[$targetPart], '$?')) - $condParts[$targetPart] = $this->handleConditions($condParts[$targetPart], $scaling, $relSpells); - - if ($know && $topLevel) - { - foreach ([1, 3] as $pos) - { - if (strstr($condParts[$pos], '${')) - $condParts[$pos] = $this->handleFormulas($condParts[$pos], $scaling); - - if (strstr($condParts[$pos], '$')) - $condParts[$pos] = $this->handleVariables($condParts[$pos], $scaling); - } - - // false condition first - if (!isset($relSpells[$know])) - $relSpells[$know] = []; - - $relSpells[$know][] = [$condParts[3], $condParts[1]]; - - $data = substr_replace($data, ''.$condParts[$targetPart].'', $condStartPos, ($condCurPos - $condStartPos)); - } - else - $data = substr_replace($data, $condParts[$targetPart], $condStartPos, ($condCurPos - $condStartPos)); - } - - return $data; - } - - public function renderBuff($level = MAX_LEVEL, $interactive = false) - { - if (!$this->curTpl) - return ['', []]; - - // doesn't have a buff - if (!$this->getField('buff', true)) - return ['', []]; - - $this->interactive = $interactive; - - $x = ''; - - // spellName - $x .= ''; - - // dispelType (if applicable) - if ($this->curTpl['dispelType']) - if ($dispel = Lang::game('dt', $this->curTpl['dispelType'])) - $x .= ''; - - $x .= '
'.$this->getField('name', true).''.$dispel.'
'; - - $x .= '
'; - - // parse Buff-Text - $btt = $this->parseText('buff', $level, $this->interactive, $scaling); - $x .= $btt[0].'
'; - - // duration - if ($this->curTpl['duration'] > 0) - $x .= ''.sprintf(Lang::spell('remaining'), Util::formatTime($this->curTpl['duration'])).''; - - $x .= '
'; - - // scaling information - spellId:min:max:curr - $x .= ''; - - return [$x, $btt[1]]; - } - - public function renderTooltip($level = MAX_LEVEL, $interactive = false) - { - if (!$this->curTpl) - return ['', []]; - - $this->interactive = $interactive; - - // fetch needed texts - $name = $this->getField('name', true); - $rank = $this->getField('rank', true); - $desc = $this->parseText('description', $level, $this->interactive, $scaling); - $tools = $this->getToolsForCurrent(); - $cool = $this->createCooldownForCurrent(); - $cast = $this->createCastTimeForCurrent(); - $cost = $this->createPowerCostForCurrent(); - $range = $this->createRangesForCurrent(); - - // get reagents - $reagents = $this->getReagentsForCurrent(); - foreach ($reagents as &$r) - $r[2] = ItemList::getName($r[0]); - - $reagents = array_reverse($reagents); - - // get stances - $stances = ''; - if ($this->curTpl['stanceMask'] && !($this->curTpl['attributes2'] & SPELL_ATTR2_NOT_NEED_SHAPESHIFT)) - $stances = Lang::game('requires2').' '.Lang::getStances($this->curTpl['stanceMask']); - - // get item requirement (skip for professions) - $reqItems = ''; - if ($this->curTpl['typeCat'] != 11) - { - $class = $this->getField('equippedItemClass'); - $mask = $this->getField('equippedItemSubClassMask'); - $reqItems = Lang::getRequiredItems($class, $mask); - } - - // get created items (may need improvement) - $createItem = ''; - if (in_array($this->curTpl['typeCat'], [9, 11])) // only Professions - { - foreach ($this->canCreateItem() as $idx) - { - if ($this->curTpl['effect'.$idx.'Id'] == 53)// Enchantment (has createItem Scroll of Enchantment) - continue; - - foreach ($this->relItems->iterate() as $cId => $__) - { - if ($cId != $this->curTpl['effect'.$idx.'CreateItemId']) - continue; - - $createItem = $this->relItems->renderTooltip(true, $this->id); - break 2; - } - } - } - - $x = ''; - $x .= '
'; - - // name & rank - if ($rank) - $x .= '
'.$name.''.$rank.'
'; - else - $x .= ''.$name.'
'; - - // powerCost & ranges - if ($range && $cost) - $x .= '
'.$cost.''.$range.'
'; - else if ($cost || $range) - $x .= $range.$cost.'
'; - - // castTime & cooldown - if ($cast && $cool) // tabled layout - { - $x .= ''; - $x .= ''; - if ($stances) - $x.= ''; - - $x .= '
'.$cast.''.$cool.'
'.$stances.'
'; - } - else if ($cast || $cool) // line-break layout - { - $x .= $cast.$cool; - - if ($stances) - $x .= '
'.$stances; - } - - $x .= '
'; - - $xTmp = []; - - if ($tools) - { - $_ = Lang::spell('tools').':
'; - while ($tool = array_pop($tools)) - { - if (isset($tool['itemId'])) - $_ .= ''.$tool['name'].''; - else if (isset($tool['id'])) - $_ .= ''.$tool['name'].''; - else - $_ .= $tool['name']; - - if (!empty($tools)) - $_ .= ', '; - else - $_ .= '
'; - } - - $xTmp[] = $_.'
'; - } - - if ($reagents) - { - $_ = Lang::spell('reagents').':
'; - while ($reagent = array_pop($reagents)) - { - $_ .= ''.$reagent[2].''; - if ($reagent[1] > 1) - $_ .= ' ('.$reagent[1].')'; - - $_ .= empty($reagents) ? '
' : ', '; - } - - $xTmp[] = $_.'
'; - } - - if ($reqItems) - $xTmp[] = Lang::game('requires2').' '.$reqItems; - - if ($desc[0]) - $xTmp[] = ''.$desc[0].''; - - if ($createItem) - $xTmp[] = $createItem; - - if ($xTmp) - $x .= '
'.implode('
', $xTmp).'
'; - - // scaling information - spellId:min:max:curr - $x .= ''; - - return [$x, $desc[1]]; - } - - public function getTalentHeadForCurrent() - { - // power cost: pct over static - $cost = $this->createPowerCostForCurrent(); - - // ranges - $range = $this->createRangesForCurrent(); - - // cast times - $cast = $this->createCastTimeForCurrent(); - - // cooldown or categorycooldown - $cool = $this->createCooldownForCurrent(); - - // assemble parts - // upper: cost :: range - // lower: time :: cooldown - $x = ''; - - // upper - if ($cost && $range) - $x .= '
'.$cost.''.$range.'
'; - else - $x .= $cost.$range; - - if (($cost xor $range) && ($cast xor $cool)) - $x .= '
'; - - // lower - if ($cast && $cool) - $x .= '
'.$cast.''.$cool.'
'; - else - $x .= $cast.$cool; - - return $x; - } - - public function getColorsForCurrent() : array - { - $gry = $this->curTpl['skillLevelGrey']; - $ylw = $this->curTpl['skillLevelYellow']; - $grn = (int)(($ylw + $gry) / 2); - $org = $this->curTpl['learnedAt']; - - if ($ylw < $org) - $ylw = 0; - - if ($grn < $org || $grn < $ylw) - $grn = 0; - - if ($org >= $ylw || $org >= $grn || $org >= $gry) - $org = 0; - - return $gry > 1 ? [$org, $ylw, $grn, $gry] : []; - } - - public function getListviewData($addInfoMask = 0x0) - { - $data = []; - - if ($addInfoMask & ITEMINFO_MODEL) - $modelInfo = $this->getModelInfo(); - - foreach ($this->iterate() as $__) - { - $quality = ($this->curTpl['cuFlags'] & SPELL_CU_QUALITY_MASK) >> 8; - $talent = $this->curTpl['cuFlags'] & (SPELL_CU_TALENT | SPELL_CU_TALENTSPELL) && $this->curTpl['spellLevel'] <= 1; - - $data[$this->id] = array( - 'id' => $this->id, - 'name' => ($quality ?: '@').$this->getField('name', true), - 'icon' => $this->curTpl['iconStringAlt'] ?: $this->curTpl['iconString'], - 'level' => $talent ? $this->curTpl['talentLevel'] : $this->curTpl['spellLevel'], - 'school' => $this->curTpl['schoolMask'], - 'cat' => $this->curTpl['typeCat'], - 'trainingcost' => $this->curTpl['trainingCost'], - 'skill' => count($this->curTpl['skillLines']) > 4 ? array_merge(array_splice($this->curTpl['skillLines'], 0, 4), [-1]): $this->curTpl['skillLines'], // display max 4 skillLines (fills max three lines in listview) - 'reagents' => array_values($this->getReagentsForCurrent()), - 'source' => [] - // 'talentspec' => $this->curTpl['skillLines'][0] not used: g_chr_specs has the wrong structure for it; also setting .cat and .type does the same - ); - - // Sources - if (!empty($this->sources[$this->id])) - { - $data[$this->id]['source'] = array_keys($this->sources[$this->id]); - if (!empty($this->sources[$this->id][3])) - $data[$this->id]['sourcemore'] = [['p' => $this->sources[$this->id][3][0]]]; - } - - // Proficiencies - if ($this->curTpl['typeCat'] == -11) - foreach (self::$spellTypes as $cat => $type) - if (in_array($this->curTpl['skillLines'][0], self::$skillLines[$cat])) - $data[$this->id]['type'] = $type; - - // creates item - foreach ($this->canCreateItem() as $idx) - { - $max = $this->curTpl['effect'.$idx.'DieSides'] + $this->curTpl['effect'.$idx.'BasePoints']; - $min = $this->curTpl['effect'.$idx.'DieSides'] > 1 ? 1 : $max; - - $data[$this->id]['creates'] = [$this->curTpl['effect'.$idx.'CreateItemId'], $min, $max]; - break; - } - - // Profession - if (in_array($this->curTpl['typeCat'], [9, 11])) - { - if ($la = $this->curTpl['learnedAt']) - $data[$this->id]['learnedat'] = $la; - else if (($la = $this->curTpl['reqSkillLevel']) > 1) - $data[$this->id]['learnedat'] = $la; - - $data[$this->id]['colors'] = $this->getColorsForCurrent(); - } - - // glyph - if ($this->curTpl['typeCat'] == -13) - $data[$this->id]['glyphtype'] = $this->curTpl['cuFlags'] & SPELL_CU_GLYPH_MAJOR ? 1 : 2; - - if ($r = $this->getField('rank', true)) - $data[$this->id]['rank'] = $r; - - if ($mask = $this->curTpl['reqClassMask']) - $data[$this->id]['reqclass'] = $mask; - - if ($mask = $this->curTpl['reqRaceMask']) - $data[$this->id]['reqrace'] = $mask; - - - if ($addInfoMask & ITEMINFO_MODEL) - { - // may have multiple models set, in this case i've no idea what should be picked - for ($i = 1; $i < 4; $i++) - { - if (!empty($modelInfo[$this->id][$i])) - { - $data[$this->id]['npcId'] = $modelInfo[$this->id][$i]['typeId']; - $data[$this->id]['displayId'] = $modelInfo[$this->id][$i]['displayId']; - $data[$this->id]['displayName'] = $modelInfo[$this->id][$i]['displayName']; - break; - } - } - } - } - - return $data; - } - - public function getJSGlobals($addMask = GLOBALINFO_SELF, &$extra = []) - { - $data = []; - - if ($this->relItems && ($addMask & GLOBALINFO_RELATED)) - $data = $this->relItems->getJSGlobals(); - - foreach ($this->iterate() as $id => $__) - { - if ($addMask & GLOBALINFO_RELATED) - { - if ($mask = $this->curTpl['reqClassMask']) - for ($i = 0; $i < 11; $i++) - if ($mask & (1 << $i)) - $data[Type::CHR_CLASS][$i + 1] = $i + 1; - - if ($mask = $this->curTpl['reqRaceMask']) - for ($i = 0; $i < 11; $i++) - if ($mask & (1 << $i)) - $data[Type::CHR_RACE][$i + 1] = $i + 1; - - // play sound effect - for ($i = 1; $i < 4; $i++) - if ($this->getField('effect'.$i.'Id') == 131 || $this->getField('effect'.$i.'Id') == 132) - $data[Type::SOUND][$this->getField('effect'.$i.'MiscValue')] = $this->getField('effect'.$i.'MiscValue'); - } - - if ($addMask & GLOBALINFO_SELF) - { - $iconString = $this->curTpl['iconStringAlt'] ? 'iconStringAlt' : 'iconString'; - - $data[Type::SPELL][$id] = array( - 'icon' => $this->curTpl[$iconString], - 'name' => $this->getField('name', true), - ); - } - - if ($addMask & GLOBALINFO_EXTRA) - { - $buff = $this->renderBuff(MAX_LEVEL, true); - $tTip = $this->renderTooltip(MAX_LEVEL, true); - - foreach ($tTip[1] as $relId => $_) - if (empty($data[Type::SPELL][$relId])) - $data[Type::SPELL][$relId] = $relId; - - foreach ($buff[1] as $relId => $_) - if (empty($data[Type::SPELL][$relId])) - $data[Type::SPELL][$relId] = $relId; - - $extra[$id] = array( - 'id' => $id, - 'tooltip' => $tTip[0], - 'buff' => !empty($buff[0]) ? $buff[0] : null, - 'spells' => $tTip[1], - 'buffspells' => !empty($buff[1]) ? $buff[1] : null - ); - } - } - - return $data; - } - - // mostly similar to TC - public function getCastingTimeForBonus($asDOT = false) - { - $areaTargets = [7, 8, 15, 16, 20, 24, 30, 31, 33, 34, 37, 54, 56, 59, 104, 108]; - $castingTime = $this->IsChanneledSpell() ? $this->curTpl['duration'] : ($this->curTpl['castTime'] * 1000); - - if (!$castingTime) - return 3500; - - if ($castingTime > 7000) - $castingTime = 7000; - - if ($castingTime < 1500) - $castingTime = 1500; - - if ($asDOT && !$this->isChanneledSpell()) - $castingTime = 3500; - - $overTime = 0; - $nEffects = 0; - $isDirect = false; - $isArea = false; - - for ($i = 1; $i <= 3; $i++) - { - if (in_array($this->curTpl['effect'.$i.'Id'], [2, 7, 8, 9, 62, 67])) - $isDirect = true; - else if (in_array($this->curTpl['effect'.$i.'AuraId'], [3, 8, 53])) - if ($_ = $this->curTpl['duration']) - $overTime = $_; - else if ($this->curTpl['effect'.$i.'AuraId']) - $nEffects++; - - if (in_array($this->curTpl['effect'.$i.'ImplicitTargetA'], $areaTargets) || in_array($this->curTpl['effect'.$i.'ImplicitTargetB'], $areaTargets)) - $isArea = true; - } - - // Combined Spells with Both Over Time and Direct Damage - if ($overTime > 0 && $castingTime > 0 && $isDirect) - { - // mainly for DoTs which are 3500 here otherwise - $originalCastTime = $this->curTpl['castTime'] * 1000; - if ($this->curTpl['attributes0'] & SPELL_ATTR0_REQ_AMMO) - $originalCastTime += 500; - - if ($originalCastTime > 7000) - $originalCastTime = 7000; - - if ($originalCastTime < 1500) - $originalCastTime = 1500; - - // Portion to Over Time - $PtOT = ($overTime / 15000) / (($overTime / 15000) + (OriginalCastTime / 3500)); - - if ($asDOT) - $castingTime = $castingTime * $PtOT; - else if ($PtOT < 1) - $castingTime = $castingTime * (1 - $PtOT); - else - $castingTime = 0; - } - - // Area Effect Spells receive only half of bonus - if ($isArea) - $castingTime /= 2; - - // -5% of total per any additional effect - $castingTime -= ($nEffects * 175); - if ($castingTime < 0) - $castingTime = 0; - - return $castingTime; - } - - public function getSourceData() - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'n' => $this->getField('name', true), - 't' => Type::SPELL, - 'ti' => $this->id, - 's' => empty($this->curTpl['skillLines']) ? 0 : $this->curTpl['skillLines'][0], - 'c' => $this->curTpl['typeCat'], - 'icon' => $this->curTpl['iconStringAlt'] ? $this->curTpl['iconStringAlt'] : $this->curTpl['iconString'], - ); - } - - return $data; - } -} - - -class SpellListFilter extends Filter -{ - const MAX_SPELL_EFFECT = 167; - const MAX_SPELL_AURA = 316; - - protected $enums = array( - 9 => array( // sources index - 1 => true, // Any - 2 => false, // None - 3 => 1, // Crafted - 4 => 2, // Drop - 6 => 4, // Quest - 7 => 5, // Vendor - 8 => 6, // Trainer - 9 => 7, // Discovery - 10 => 9 // Talent - ), - 40 => array( // damage class index - 1 => 0, // none - 2 => 1, // magic - 3 => 2, // melee - 4 => 3 // ranged - ), - 45 => array( // power type index - // 1 => ??, // burning embers - // 2 => ??, // chi - // 3 => ??, // demonic fury - 4 => POWER_ENERGY, // energy - 5 => POWER_FOCUS, // focus - 6 => POWER_HEALTH, // health - // 7 => ??, // holy power - 8 => POWER_MANA, // mana - 9 => POWER_RAGE, // rage - 10 => POWER_RUNE, // runes - 11 => POWER_RUNIC_POWER, // runic power - // 12 => ??, // shadow orbs - // 13 => ??, // soul shard - 14 => POWER_HAPPINESS, // happiness v custom v - 15 => -1, // ammo - 16 => -41, // pyrite - 17 => -61, // steam pressure - 18 => -101, // heat - 19 => -121, // ooze - 20 => -141, // blood power - 21 => -142 // wrath - ) - ); - - // cr => [type, field, misc, extraCol] - protected $genericFilter = array( // misc (bool): _NUMERIC => useFloat; _STRING => localized; _FLAG => match Value; _BOOLEAN => stringSet - 1 => [FILTER_CR_CALLBACK, 'cbCost', ], // costAbs [op] [int] - 2 => [FILTER_CR_NUMERIC, 'powerCostPercent', NUM_CAST_INT ], // prcntbasemanarequired - 3 => [FILTER_CR_BOOLEAN, 'spellFocusObject' ], // requiresnearbyobject - 4 => [FILTER_CR_NUMERIC, 'trainingcost', NUM_CAST_INT ], // trainingcost - 5 => [FILTER_CR_BOOLEAN, 'reqSpellId' ], // requiresprofspec - 8 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_SCREENSHOT ], // hasscreenshots - 9 => [FILTER_CR_CALLBACK, 'cbSource', ], // source [enum] - 10 => [FILTER_CR_FLAG, 'cuFlags', SPELL_CU_FIRST_RANK ], // firstrank - 11 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_COMMENT ], // hascomments - 12 => [FILTER_CR_FLAG, 'cuFlags', SPELL_CU_LAST_RANK ], // lastrank - 13 => [FILTER_CR_NUMERIC, 'rankNo', NUM_CAST_INT ], // rankno - 14 => [FILTER_CR_NUMERIC, 'id', NUM_CAST_INT, true ], // id - 15 => [FILTER_CR_STRING, 'ic.name', ], // icon - 17 => [FILTER_CR_FLAG, 'cuFlags', CUSTOM_HAS_VIDEO ], // hasvideos - 19 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION ], // scaling - 20 => [FILTER_CR_CALLBACK, 'cbReagents', ], // has Reagents [yn] - // 22 => [FILTER_CR_NYI_PH, null, null, null ], // proficiencytype [proficiencytype] pointless - 25 => [FILTER_CR_BOOLEAN, 'skillLevelYellow' ], // rewardsskillups - 27 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_CHANNELED_1, true ], // channeled [yn] - 28 => [FILTER_CR_NUMERIC, 'castTime', NUM_CAST_FLOAT ], // casttime [num] - 29 => [FILTER_CR_CALLBACK, 'cbAuraNames', ], // appliesaura [effectauranames] - // 31 => [FILTER_CR_NYI_PH, null, null, null ], // usablewhenshapeshifted [yn] pointless - 33 => [FILTER_CR_CALLBACK, 'cbInverseFlag', 'attributes0', SPELL_ATTR0_CANT_USED_IN_COMBAT], // combatcastable [yn] - 34 => [FILTER_CR_CALLBACK, 'cbInverseFlag', 'attributes2', SPELL_ATTR2_CANT_CRIT ], // chancetocrit [yn] - 35 => [FILTER_CR_CALLBACK, 'cbInverseFlag', 'attributes3', SPELL_ATTR3_IGNORE_HIT_RESULT ], // chancetomiss [yn] - 36 => [FILTER_CR_FLAG, 'attributes3', SPELL_ATTR3_DEATH_PERSISTENT ], // persiststhroughdeath [yn] - 38 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_ONLY_STEALTHED ], // requiresstealth [yn] - 39 => [FILTER_CR_CALLBACK, 'cbSpellstealable', 'attributes4', SPELL_ATTR4_NOT_STEALABLE ], // spellstealable [yn] - 40 => [FILTER_CR_ENUM, 'damageClass' ], // damagetype [damagetype] - 41 => [FILTER_CR_FLAG, 'stanceMask', (1 << (22 - 1)) ], // requiresmetamorphosis [yn] - 42 => [FILTER_CR_FLAG, 'attributes5', SPELL_ATTR5_USABLE_WHILE_STUNNED ], // usablewhenstunned [yn] - 44 => [FILTER_CR_CALLBACK, 'cbUsableInArena' ], // usableinarenas [yn] - 45 => [FILTER_CR_ENUM, 'powerType' ], // resourcetype [resourcetype] - // 46 => [FILTER_CR_NYI_PH, null, null, null ], // disregardimmunity [yn] - 47 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_UNAFFECTED_BY_SCHOOL_IMMUNE ], // disregardschoolimmunity [yn] - 48 => [FILTER_CR_CALLBACK, 'cbEquippedWeapon', 0x0004000C, false ], // reqrangedweapon [yn] - 49 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_ON_NEXT_SWING ], // onnextswingplayers [yn] - 50 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_PASSIVE ], // passivespell [yn] - 51 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_DONT_DISPLAY_IN_AURA_BAR ], // hiddenaura [yn] - 52 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_ON_NEXT_SWING_2 ], // onnextswingnpcs [yn] - 53 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_DAYTIME_ONLY ], // daytimeonly [yn] - 54 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_NIGHT_ONLY ], // nighttimeonly [yn] - 55 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_INDOORS_ONLY ], // indoorsonly [yn] - 56 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_OUTDOORS_ONLY ], // outdoorsonly [yn] - 57 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_CANT_CANCEL ], // uncancellableaura [yn] - 58 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION ], // damagedependsonlevel [yn] - 59 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_STOP_ATTACK_TARGET ], // stopsautoattack [yn] - 60 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_IMPOSSIBLE_DODGE_PARRY_BLOCK ], // cannotavoid [yn] - 61 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_CASTABLE_WHILE_DEAD ], // usabledead [yn] - 62 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_CASTABLE_WHILE_MOUNTED ], // usablemounted [yn] - 63 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_DISABLED_WHILE_ACTIVE ], // delayedrecoverystarttime [yn] - 64 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_CASTABLE_WHILE_SITTING ], // usablesitting [yn] - 65 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_DRAIN_ALL_POWER ], // usesallpower [yn] - 66 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_CHANNELED_2, true ], // channeled [yn] - 67 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_CANT_BE_REFLECTED ], // cannotreflect [yn] - 68 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_NOT_BREAK_STEALTH ], // usablestealthed [yn] - 69 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_NEGATIVE_1 ], // harmful [yn] - 70 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_CANT_TARGET_IN_COMBAT ], // targetnotincombat [yn] - 71 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_NO_THREAT ], // nothreat [yn] - 72 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_IS_PICKPOCKET ], // pickpocket [yn] - 73 => [FILTER_CR_FLAG, 'attributes1', SPELL_ATTR1_DISPEL_AURAS_ON_IMMUNITY ], // dispelauraonimmunity [yn] - 74 => [FILTER_CR_CALLBACK, 'cbEquippedWeapon', 0x00100000, false ], // reqfishingpole [yn] - 75 => [FILTER_CR_FLAG, 'attributes2', SPELL_ATTR2_CANT_TARGET_TAPPED ], // requntappedtarget [yn] - // 76 => [FILTER_CR_NYI_PH, null, null, null ], // targetownitem [yn] // the flag for this has to be somewhere.... - 77 => [FILTER_CR_FLAG, 'attributes2', SPELL_ATTR2_NOT_NEED_SHAPESHIFT ], // doesntreqshapeshift [yn] - 78 => [FILTER_CR_FLAG, 'attributes2', SPELL_ATTR2_FOOD_BUFF ], // foodbuff [yn] - 79 => [FILTER_CR_FLAG, 'attributes3', SPELL_ATTR3_ONLY_TARGET_PLAYERS ], // targetonlyplayer [yn] - 80 => [FILTER_CR_CALLBACK, 'cbEquippedWeapon', 1 << INVTYPE_WEAPONMAINHAND, true ], // reqmainhand [yn] - 81 => [FILTER_CR_FLAG, 'attributes3', SPELL_ATTR3_NO_INITIAL_AGGRO ], // doesntengagetarget [yn] - 82 => [FILTER_CR_CALLBACK, 'cbEquippedWeapon', 0x00080000, false ], // reqwand [yn] - 83 => [FILTER_CR_CALLBACK, 'cbEquippedWeapon', 1 << INVTYPE_WEAPONOFFHAND, true ], // reqoffhand [yn] - 84 => [FILTER_CR_FLAG, 'attributes0', SPELL_ATTR0_HIDE_IN_COMBAT_LOG ], // nolog [yn] - 85 => [FILTER_CR_FLAG, 'attributes4', SPELL_ATTR4_FADES_WHILE_LOGGED_OUT ], // auratickswhileloggedout [yn] - 87 => [FILTER_CR_FLAG, 'attributes5', SPELL_ATTR5_START_PERIODIC_AT_APPLY ], // startstickingatapplication [yn] - 88 => [FILTER_CR_FLAG, 'attributes5', SPELL_ATTR5_USABLE_WHILE_CONFUSED ], // usableconfused [yn] - 89 => [FILTER_CR_FLAG, 'attributes5', SPELL_ATTR5_USABLE_WHILE_FEARED ], // usablefeared [yn] - 90 => [FILTER_CR_FLAG, 'attributes6', SPELL_ATTR6_ONLY_IN_ARENA ], // onlyarena [yn] - 91 => [FILTER_CR_FLAG, 'attributes6', SPELL_ATTR6_NOT_IN_RAID_INSTANCE ], // notinraid [yn] - 92 => [FILTER_CR_FLAG, 'attributes7', SPELL_ATTR7_REACTIVATE_AT_RESURRECT ], // paladinaura [yn] - 93 => [FILTER_CR_FLAG, 'attributes7', SPELL_ATTR7_SUMMON_PLAYER_TOTEM ], // totemspell [yn] - 95 => [FILTER_CR_CALLBACK, 'cbBandageSpell' ], // bandagespell [yn] ...don't ask - 96 => [FILTER_CR_STAFFFLAG, 'attributes0' ], // flags1 [flags] - 97 => [FILTER_CR_STAFFFLAG, 'attributes1' ], // flags2 [flags] - 98 => [FILTER_CR_STAFFFLAG, 'attributes2' ], // flags3 [flags] - 99 => [FILTER_CR_STAFFFLAG, 'attributes3' ], // flags4 [flags] - 100 => [FILTER_CR_STAFFFLAG, 'attributes4' ], // flags5 [flags] - 101 => [FILTER_CR_STAFFFLAG, 'attributes5' ], // flags6 [flags] - 102 => [FILTER_CR_STAFFFLAG, 'attributes6' ], // flags7 [flags] - 103 => [FILTER_CR_STAFFFLAG, 'attributes7' ], // flags8 [flags] - 104 => [FILTER_CR_STAFFFLAG, 'targets' ], // flags9 [flags] - 105 => [FILTER_CR_STAFFFLAG, 'stanceMaskNot' ], // flags10 [flags] - 106 => [FILTER_CR_STAFFFLAG, 'spellFamilyFlags1' ], // flags11 [flags] - 107 => [FILTER_CR_STAFFFLAG, 'spellFamilyFlags2' ], // flags12 [flags] - 108 => [FILTER_CR_STAFFFLAG, 'spellFamilyFlags3' ], // flags13 [flags] - 109 => [FILTER_CR_CALLBACK, 'cbEffectNames', ], // effecttype [effecttype] - // 110 => [FILTER_CR_NYI_PH, null, null, null ], // scalingap [yn] // unreasonably complex for now - // 111 => [FILTER_CR_NYI_PH, null, null, null ], // scalingsp [yn] // unreasonably complex for now - 114 => [FILTER_CR_CALLBACK, 'cbReqFaction' ], // requiresfaction [side] - 116 => [FILTER_CR_BOOLEAN, 'startRecoveryTime' ] // onGlobalCooldown [yn] - ); - - // fieldId => [checkType, checkValue[, fieldIsArray]] - protected $inputFields = array( - 'cr' => [FILTER_V_RANGE, [1, 116], true ], // criteria ids - 'crs' => [FILTER_V_LIST, [FILTER_ENUM_NONE, FILTER_ENUM_ANY, [0, 99999]], true ], // criteria operators - 'crv' => [FILTER_V_REGEX, '/[\p{C};:%\\\\]/ui', true ], // criteria values - only printable chars, no delimiters - 'na' => [FILTER_V_REGEX, '/[\p{C};%\\\\]/ui', false], // name / text - only printable chars, no delimiter - 'ex' => [FILTER_V_EQUAL, 'on', false], // extended name search - 'ma' => [FILTER_V_EQUAL, 1, false], // match any / all filter - 'minle' => [FILTER_V_RANGE, [1, 99], false], // spell level min - 'maxle' => [FILTER_V_RANGE, [1, 99], false], // spell level max - 'minrs' => [FILTER_V_RANGE, [1, 999], false], // required skill level min - 'maxrs' => [FILTER_V_RANGE, [1, 999], false], // required skill level max - 'ra' => [FILTER_V_LIST, [[1, 8], 10, 11], false], // races - 'cl' => [FILTER_V_CALLBACK, 'cbClasses', true ], // classes - 'gl' => [FILTER_V_CALLBACK, 'cbGlyphs', true ], // glyph type - 'sc' => [FILTER_V_RANGE, [0, 6], true ], // magic schools - 'dt' => [FILTER_V_LIST, [[1, 6], 9], false], // dispel types - 'me' => [FILTER_V_RANGE, [1, 31], false] // mechanics - ); - - protected function createSQLForCriterium(&$cr) - { - if (in_array($cr[0], array_keys($this->genericFilter))) - if ($genCr = $this->genericCriterion($cr)) - return $genCr; - - unset($cr); - $this->error = true; - return [1]; - } - - protected function createSQLForValues() - { - $parts = []; - $_v = &$this->fiData['v']; - - //string (extended) - if (isset($_v['na'])) - { - $_ = []; - if (isset($_v['ex']) && $_v['ex'] == 'on') - $_ = $this->modularizeString(['name_loc'.User::$localeId, 'buff_loc'.User::$localeId, 'description_loc'.User::$localeId]); - else - $_ = $this->modularizeString(['name_loc'.User::$localeId]); - - if ($_) - $parts[] = $_; - } - - // spellLevel min todo (low): talentSpells (typeCat -2) commonly have spellLevel 1 (and talentLevel >1) -> query is inaccurate - if (isset($_v['minle'])) - $parts[] = ['spellLevel', $_v['minle'], '>=']; - - // spellLevel max - if (isset($_v['maxle'])) - $parts[] = ['spellLevel', $_v['maxle'], '<=']; - - // skillLevel min - if (isset($_v['minrs'])) - $parts[] = ['learnedAt', $_v['minrs'], '>=']; - - // skillLevel max - if (isset($_v['maxrs'])) - $parts[] = ['learnedAt', $_v['maxrs'], '<=']; - - // race - if (isset($_v['ra'])) - $parts[] = ['AND', [['reqRaceMask', RACE_MASK_ALL, '&'], RACE_MASK_ALL, '!'], ['reqRaceMask', $this->list2Mask([$_v['ra']]), '&']]; - - // class [list] - if (isset($_v['cl'])) - $parts[] = ['reqClassMask', $this->list2Mask($_v['cl']), '&']; - - // school [list] - if (isset($_v['sc'])) - $parts[] = ['schoolMask', $this->list2Mask($_v['sc'], true), '&']; - - // glyph type [list] wonky, admittedly, but consult SPELL_CU_* in defines and it makes sense - if (isset($_v['gl'])) - $parts[] = ['cuFlags', ($this->list2Mask($_v['gl']) << 6), '&']; - - // dispel type - if (isset($_v['dt'])) - $parts[] = ['dispelType', $_v['dt']]; - - // mechanic - if (isset($_v['me'])) - $parts[] = ['OR', ['mechanic', $_v['me']], ['effect1Mechanic', $_v['me']], ['effect2Mechanic', $_v['me']], ['effect3Mechanic', $_v['me']]]; - - return $parts; - } - - public function getGenericFilter($cr) // access required by SpellDetailPage's SpellAttributes list - { - return $this->genericFilter[$cr] ?? []; - } - - protected function cbClasses(&$val) - { - if (!$this->parentCats || !in_array($this->parentCats[0], [-13, -2, 7])) - return false; - - if (!Util::checkNumeric($val, NUM_REQ_INT)) - return false; - - $type = FILTER_V_LIST; - $valid = [[1, 9], 11]; - - return $this->checkInput($type, $valid, $val); - } - - protected function cbGlyphs(&$val) - { - if (!$this->parentCats || $this->parentCats[0] != -13) - return false; - - if (!Util::checkNumeric($val, NUM_REQ_INT)) - return false; - - $type = FILTER_V_LIST; - $valid = [1, 2]; - - return $this->checkInput($type, $valid, $val); - } - - protected function cbCost($cr) - { - if (!Util::checkNumeric($cr[2], NUM_CAST_INT) || !$this->int2Op($cr[1])) - return false; - - return ['OR', - ['AND', ['powerType', [POWER_RAGE, POWER_RUNIC_POWER]], ['powerCost', (10 * $cr[2]), $cr[1]]], - ['AND', ['powerType', [POWER_RAGE, POWER_RUNIC_POWER], '!'], ['powerCost', $cr[2], $cr[1]]] - ]; - } - - protected function cbSource($cr) - { - if (!isset($this->enums[$cr[0]][$cr[1]])) - return false; - - $_ = $this->enums[$cr[0]][$cr[1]]; - if (is_int($_)) // specific - return ['src.src'.$_, null, '!']; - else if ($_) // any - return ['OR', ['src.src1', null, '!'], ['src.src2', null, '!'], ['src.src4', null, '!'], ['src.src5', null, '!'], ['src.src6', null, '!'], ['src.src7', null, '!'], ['src.src9', null, '!'], ['src.src10', null, '!']]; - else if (!$_) // none - return ['AND', ['src.src1', null], ['src.src2', null], ['src.src4', null], ['src.src5', null], ['src.src6', null], ['src.src7', null], ['src.src9', null], ['src.src10', null]]; - - return false; - } - - protected function cbReagents($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return ['OR', ['reagent1', 0, '>'], ['reagent2', 0, '>'], ['reagent3', 0, '>'], ['reagent4', 0, '>'], ['reagent5', 0, '>'], ['reagent6', 0, '>'], ['reagent7', 0, '>'], ['reagent8', 0, '>']]; - else - return ['AND', ['reagent1', 0], ['reagent2', 0], ['reagent3', 0], ['reagent4', 0], ['reagent5', 0], ['reagent6', 0], ['reagent7', 0], ['reagent8', 0]]; - } - - protected function cbAuraNames($cr) - { - if (!Util::checkNumeric($cr[1], NUM_CAST_INT) || $cr[1] <= 0 || $cr[1] > self::MAX_SPELL_AURA) - return false; - - return ['OR', ['effect1AuraId', $cr[1]], ['effect2AuraId', $cr[1]], ['effect3AuraId', $cr[1]]]; - } - - protected function cbEffectNames($cr) - { - if (!Util::checkNumeric($cr[1], NUM_CAST_INT) || $cr[1] <= 0 || $cr[1] > self::MAX_SPELL_EFFECT) - return false; - - return ['OR', ['effect1Id', $cr[1]], ['effect2Id', $cr[1]], ['effect3Id', $cr[1]]]; - } - - protected function cbInverseFlag($cr, $field, $flag) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return [[$field, $flag, '&'], 0]; - else - return [$field, $flag, '&']; - } - - protected function cbSpellstealable($cr, $field, $flag) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return ['AND', [[$field, $flag, '&'], 0], ['dispelType', 1]]; - else - return ['OR', [$field, $flag, '&'], ['dispelType', 1, '!']]; - } - - protected function cbReqFaction($cr) - { - switch ($cr[1]) - { - case 1: // yes - return ['reqRaceMask', 0, '!']; - case 2: // alliance - return ['AND', [['reqRaceMask', RACE_MASK_HORDE, '&'], 0], ['reqRaceMask', RACE_MASK_ALLIANCE, '&']]; - case 3: // horde - return ['AND', [['reqRaceMask', RACE_MASK_ALLIANCE, '&'], 0], ['reqRaceMask', RACE_MASK_HORDE, '&']]; - case 4: // both - return ['AND', ['reqRaceMask', RACE_MASK_ALLIANCE, '&'], ['reqRaceMask', RACE_MASK_HORDE, '&']]; - case 5: // no - return ['reqRaceMask', 0]; - default: - return false; - } - } - - protected function cbEquippedWeapon($cr, $mask, $useInvType) - { - if (!$this->int2Bool($cr[1])) - return false; - - $field = $useInvType ? 'equippedItemInventoryTypeMask' : 'equippedItemSubClassMask'; - - if ($cr[1]) - return ['AND', ['equippedItemClass', ITEM_CLASS_WEAPON], [$field, $mask, '&']]; - else - return ['OR', ['equippedItemClass', ITEM_CLASS_WEAPON, '!'], [[$field, $mask, '&'], 0]]; - } - - protected function cbUsableInArena($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) - return ['AND', - [['attributes4', SPELL_ATTR4_NOT_USABLE_IN_ARENA, '&'], 0], - ['OR', ['recoveryTime', 10 * MINUTE * 1000, '<='], ['attributes4', SPELL_ATTR4_USABLE_IN_ARENA, '&']] - ]; - else - return ['OR', - ['attributes4', SPELL_ATTR4_NOT_USABLE_IN_ARENA, '&'], - ['AND', ['recoveryTime', 10 * MINUTE * 1000, '>'], [['attributes4', SPELL_ATTR4_USABLE_IN_ARENA, '&'], 0]] - ]; - } - - protected function cbBandageSpell($cr) - { - if (!$this->int2Bool($cr[1])) - return false; - - if ($cr[1]) // match exact, not as flag - return ['AND', ['attributes1', SPELL_ATTR1_CHANNELED_1 | SPELL_ATTR1_CHANNELED_2 | SPELL_ATTR1_CHANNEL_TRACK_TARGET], ['effect1ImplicitTargetA', 21]]; - else - return ['OR', ['attributes1', SPELL_ATTR1_CHANNELED_1 | SPELL_ATTR1_CHANNELED_2 | SPELL_ATTR1_CHANNEL_TRACK_TARGET, '!'], ['effect1ImplicitTargetA', 21, '!']]; - } -} - -?> diff --git a/includes/types/title.class.php b/includes/types/title.class.php deleted file mode 100644 index 77bb80ee..00000000 --- a/includes/types/title.class.php +++ /dev/null @@ -1,171 +0,0 @@ - [['src']], // 11: Type::TITLE - 'src' => ['j' => ['?_source src ON type = 11 AND typeId = t.id', true], 's' => ', src13, moreType, moreTypeId'] - ); - - public function __construct($conditions = []) - { - parent::__construct($conditions); - - // post processing - foreach ($this->iterate() as $id => &$_curTpl) - { - // preparse sources - notice: under this system titles can't have more than one source (or two for achivements), which is enough for standard TC cases but may break custom cases - if ($_curTpl['moreType'] == Type::ACHIEVEMENT) - $this->sources[$this->id][12][] = $_curTpl['moreTypeId']; - else if ($_curTpl['moreType'] == Type::QUEST) - $this->sources[$this->id][4][] = $_curTpl['moreTypeId']; - else if ($_curTpl['src13']) - $this->sources[$this->id][13][] = $_curTpl['src13']; - - // titles display up to two achievements at once - if ($_curTpl['src12Ext']) - $this->sources[$this->id][12][] = $_curTpl['src12Ext']; - - unset($_curTpl['src12Ext']); - unset($_curTpl['moreType']); - unset($_curTpl['moreTypeId']); - unset($_curTpl['src3']); - - // shorthand for more generic access - foreach (Util::$localeStrings as $i => $str) - if ($str) - $_curTpl['name_loc'.$i] = trim(str_replace('%s', '', $_curTpl['male_loc'.$i])); - } - } - - public function getListviewData() - { - $data = []; - $this->createSource(); - - foreach ($this->iterate() as $__) - { - $data[$this->id] = array( - 'id' => $this->id, - 'name' => $this->getField('male', true), - 'namefemale' => $this->getField('female', true), - 'side' => $this->curTpl['side'], - 'gender' => $this->curTpl['gender'], - 'expansion' => $this->curTpl['expansion'], - 'category' => $this->curTpl['category'] - ); - - if (!empty($this->curTpl['source'])) - $data[$this->id]['source'] = $this->curTpl['source']; - } - - return $data; - } - - public function getJSGlobals($addMask = 0) - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[Type::TITLE][$this->id]['name'] = $this->getField('male', true); - - if ($_ = $this->getField('female', true)) - $data[Type::TITLE][$this->id]['namefemale'] = $_; - } - - return $data; - } - - private function createSource() - { - $sources = array( - 4 => [], // Quest - 12 => [], // Achievements - 13 => [] // simple text - ); - - foreach ($this->iterate() as $__) - { - if (empty($this->sources[$this->id])) - continue; - - foreach (array_keys($sources) as $srcKey) - if (isset($this->sources[$this->id][$srcKey])) - $sources[$srcKey] = array_merge($sources[$srcKey], $this->sources[$this->id][$srcKey]); - } - - // fill in the details - if (!empty($sources[4])) - $sources[4] = (new QuestList(array(['id', $sources[4]])))->getSourceData(); - - if (!empty($sources[12])) - $sources[12] = (new AchievementList(array(['id', $sources[12]])))->getSourceData(); - - foreach ($this->sources as $Id => $src) - { - $tmp = []; - - // Quest-source - if (isset($src[4])) - { - foreach ($src[4] as $s) - { - if (isset($sources[4][$s]['s'])) - $this->faction2Side($sources[4][$s]['s']); - - $tmp[4][] = $sources[4][$s]; - } - } - - // Achievement-source - if (isset($src[12])) - { - foreach ($src[12] as $s) - { - if (isset($sources[12][$s]['s'])) - $this->faction2Side($sources[12][$s]['s']); - - $tmp[12][] = $sources[12][$s]; - } - } - - // other source (only one item possible, so no iteration needed) - if (isset($src[13])) - $tmp[13] = [Lang::game('pvpSources', $this->sources[$Id][13][0])]; - - $this->templates[$Id]['source'] = $tmp; - } - } - - public function getHtmlizedName($gender = GENDER_MALE) - { - $field = $gender == GENDER_FEMALE ? 'female' : 'male'; - return str_replace('%s', '<'.Util::ucFirst(Lang::main('name')).'>', $this->getField($field, true)); - } - - public function renderTooltip() { } - - private function faction2Side(&$faction) // thats weird.. and hopefully unique to titles - { - if ($faction == 2) // Horde - $faction = 0; - else if ($faction != 1) // Alliance - $faction = -1; // Both - } -} - -?> diff --git a/includes/types/user.class.php b/includes/types/user.class.php deleted file mode 100644 index 2fbceb18..00000000 --- a/includes/types/user.class.php +++ /dev/null @@ -1,61 +0,0 @@ - [['r']], - 'r' => ['j' => ['?_account_reputation r ON r.userId = a.id', true], 's' => ', IFNULL(SUM(r.amount), 0) AS reputation', 'g' => 'a.id'] - ); - - public function getListviewData() { } - - public function getJSGlobals($addMask = 0) - { - $data = []; - - foreach ($this->iterate() as $__) - { - $data[$this->curTpl['displayName']] = array( - 'border' => 0, // border around avatar (rarityColors) - 'roles' => $this->curTpl['userGroups'], - 'joined' => date(Util::$dateFormatInternal, $this->curTpl['joinDate']), - 'posts' => 0, // forum posts - // 'gold' => 0, // achievement system - // 'silver' => 0, // achievement system - // 'copper' => 0, // achievement system - 'reputation' => $this->curTpl['reputation'] - ); - - // custom titles (only ssen on user page..?) - if ($_ = $this->curTpl['title']) - $data[$this->curTpl['displayName']]['title'] = $_; - - if ($_ = $this->curTpl['avatar']) - { - $data[$this->curTpl['displayName']]['avatar'] = is_numeric($_) ? 2 : 1; - $data[$this->curTpl['displayName']]['avatarmore'] = $_; - } - - // more optional data - // sig: markdown formated string (only used in forum?) - // border: seen as null|1|3 .. changes the border around the avatar (i suspect its meaning changed and got decupled from premium-status with the introduction of patron-status) - } - - return [Type::USER => $data]; - } - - public function renderTooltip() { } -} - -?> diff --git a/includes/user.class.php b/includes/user.class.php index da0847d9..9fa1bd59 100644 --- a/includes/user.class.php +++ b/includes/user.class.php @@ -1,392 +1,397 @@ selectRow('SELECT count, unbanDate FROM ?_account_bannedips WHERE ip = ? AND type = 0', self::$ip)) - { - if ($ipBan['count'] > CFG_ACC_FAILED_AUTH_COUNT && $ipBan['unbanDate'] > time()) - return false; - else if ($ipBan['unbanDate'] <= time()) - DB::Aowow()->query('DELETE FROM ?_account_bannedips WHERE ip = ?', self::$ip); - } - - // try to restore session - if (empty($_SESSION['user'])) - return false; - - // timed out... - if (!empty($_SESSION['timeout']) && $_SESSION['timeout'] <= time()) - return false; - - $query = DB::Aowow()->SelectRow(' - SELECT a.id, a.passHash, a.displayName, a.locale, a.userGroups, a.userPerms, a.allowExpire, BIT_OR(ab.typeMask) AS bans, IFNULL(SUM(r.amount), 0) as reputation, a.avatar, a.dailyVotes, a.excludeGroups - FROM ?_account a - LEFT JOIN ?_account_banned ab ON a.id = ab.userId AND ab.end > UNIX_TIMESTAMP() - LEFT JOIN ?_account_reputation r ON a.id = r.userId - WHERE a.id = ?d - GROUP BY a.id', - $_SESSION['user'] - ); - - if (!$query) - return false; - - // password changed, terminate session - if (AUTH_MODE_SELF && $query['passHash'] != $_SESSION['hash']) - { - self::destroy(); - return false; - } - - self::$id = intval($query['id']); - self::$displayName = $query['displayName']; - self::$passHash = $query['passHash']; - self::$expires = (bool)$query['allowExpire']; - self::$reputation = $query['reputation']; - self::$banStatus = $query['bans']; - self::$groups = $query['bans'] & (ACC_BAN_TEMP | ACC_BAN_PERM) ? 0 : intval($query['userGroups']); - self::$perms = $query['bans'] & (ACC_BAN_TEMP | ACC_BAN_PERM) ? 0 : intval($query['userPerms']); - self::$dailyVotes = $query['dailyVotes']; - self::$excludeGroups = $query['excludeGroups']; - - $conditions = array( - [['cuFlags', PROFILER_CU_DELETED, '&'], 0], - ['OR', ['user', self::$id], ['ap.accountId', self::$id]] - ); - - if (self::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) - array_shift($conditions); - - self::$profiles = (new LocalProfileList($conditions)); - - if ($query['avatar']) - self::$avatar = $query['avatar']; - - if (self::$localeId != $query['locale']) // reset, if changed - self::setLocale(intVal($query['locale'])); - - // stuff, that updates on a daily basis goes here (if you keep you session alive indefinitly, the signin-handler doesn't do very much) - // - conscutive visits - // - votes per day - // - reputation for daily visit - if (self::$id) - { - $lastLogin = DB::Aowow()->selectCell('SELECT curLogin FROM ?_account WHERE id = ?d', self::$id); - // either the day changed or the last visit was >24h ago - if (date('j', $lastLogin) != date('j') || (time() - $lastLogin) > 1 * DAY) - { - // daily votes (we need to reset this one) - self::$dailyVotes = self::getMaxDailyVotes(); - - DB::Aowow()->query(' - UPDATE ?_account - SET dailyVotes = ?d, prevLogin = curLogin, curLogin = UNIX_TIMESTAMP(), prevIP = curIP, curIP = ? - WHERE id = ?d', - self::$dailyVotes, - self::$ip, - self::$id - ); - - // gain rep for daily visit - if (!(self::$banStatus & (ACC_BAN_TEMP | ACC_BAN_PERM)) && !self::isInGroup(U_GROUP_PENDING)) - Util::gainSiteReputation(self::$id, SITEREP_ACTION_DAILYVISIT); - - // increment consecutive visits (next day or first of new month and not more than 48h) - // i bet my ass i forgott a corner case - if ((date('j', $lastLogin) + 1 == date('j') || (date('j') == 1 && date('n', $lastLogin) != date('n'))) && (time() - $lastLogin) < 2 * DAY) - DB::Aowow()->query('UPDATE ?_account SET consecutiveVisits = consecutiveVisits + 1 WHERE id = ?d', self::$id); - else - DB::Aowow()->query('UPDATE ?_account SET consecutiveVisits = 0 WHERE id = ?d', self::$id); - } - } - - return true; - } - - private static function setIP() - { $ipAddr = ''; - $method = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR']; - - foreach ($method as $m) + foreach (['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'] as $env) { - if ($rawIp = getenv($m)) + if ($rawIp = getenv($env)) { - if ($m == 'HTTP_X_FORWARDED') + if ($env == 'HTTP_X_FORWARDED') $rawIp = explode(',', $rawIp)[0]; // [ip, proxy1, proxy2] - // check IPv4 if ($ipAddr = filter_var($rawIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) break; - // check IPv6 if ($ipAddr = filter_var($rawIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) break; } } self::$ip = $ipAddr ?: null; - } - /****************/ - /* set language */ - /****************/ - // set and save - public static function setLocale($set = -1) - { - $loc = LOCALE_EN; + # set locale # - // get - if ($set != -1 && isset(Util::$localeStrings[$set])) - $loc = $set; - else if (isset($_SESSION['locale']) && isset(Util::$localeStrings[$_SESSION['locale']])) - $loc = $_SESSION['locale']; - else if (!empty($_SERVER["HTTP_ACCEPT_LANGUAGE"])) + if (isset($_SESSION['locale']) && $_SESSION['locale'] instanceof Locale) + self::$preferedLoc = $_SESSION['locale']->validate() ?? Locale::getFallback(); + else if (!empty($_SERVER["HTTP_ACCEPT_LANGUAGE"]) && ($loc = Locale::tryFromHttpAcceptLanguage($_SERVER["HTTP_ACCEPT_LANGUAGE"]))) + self::$preferedLoc = $loc; + else + self::$preferedLoc = Locale::getFallback(); + + + # set basic data # + + if (empty($_SESSION['dataKey'])) // session have a dataKey to access the JScripts (yes, also the anons) + $_SESSION['dataKey'] = Util::createHash(); // just some random numbers for identification purpose + + self::$dataKey = $_SESSION['dataKey']; + self::$agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + if (!self::$ip) + return false; + + + # check IP bans # + + if ($ipBan = DB::Aowow()->selectRow('SELECT `count`, IF(`unbanDate` > UNIX_TIMESTAMP(), 1, 0) AS "active" FROM ::account_bannedips WHERE `ip` = %s AND `type` = %i', self::$ip, IP_BAN_TYPE_LOGIN_ATTEMPT)) { - $loc = strtolower(substr($_SERVER["HTTP_ACCEPT_LANGUAGE"], 0, 2)); - switch ($loc) { - case 'fr': $loc = LOCALE_FR; break; - case 'de': $loc = LOCALE_DE; break; - case 'zh': $loc = LOCALE_CN; break; // may cause issues in future with zh-tw - case 'es': $loc = LOCALE_ES; break; - case 'ru': $loc = LOCALE_RU; break; - default: $loc = LOCALE_EN; - } + if ($ipBan['count'] > Cfg::get('ACC_FAILED_AUTH_COUNT') && $ipBan['active']) + return false; + else if (!$ipBan['active']) + DB::Aowow()->qry('DELETE FROM ::account_bannedips WHERE `ip` = %s', self::$ip); } - // check; pick first viable if failed - if (CFG_LOCALES && !(CFG_LOCALES & (1 << $loc))) + + # try to restore session # + + if (empty($_SESSION['user'])) + return false; + + $session = DB::Aowow()->selectRow('SELECT `userId`, `expires` FROM ::account_sessions WHERE `status` = %i AND `sessionId` = %s', SESSION_ACTIVE, session_id()); + $userData = DB::Aowow()->selectRow( + 'SELECT a.`id`, a.`passHash`, a.`username`, a.`locale`, a.`userGroups`, a.`userPerms`, BIT_OR(ab.`typeMask`) AS "bans", IFNULL(SUM(r.`amount`), 0) AS "reputation", a.`dailyVotes`, a.`excludeGroups`, a.`status`, a.`statusTimer`, a.`email`, a.`debug`, a.`avatar`, a.`avatarborder` + FROM ::account a + LEFT JOIN ::account_banned ab ON a.`id` = ab.`userId` AND ab.`end` > UNIX_TIMESTAMP() + LEFT JOIN ::account_reputation r ON a.`id` = r.`userId` + WHERE a.`id` = %i + GROUP BY a.`id`', + $_SESSION['user'] + ); + + if (!$session || !$userData) { - foreach (Util::$localeStrings as $idx => $__) + self::destroy(); + return false; + } + else if ($session['expires'] && $session['expires'] < time()) + { + DB::Aowow()->qry('UPDATE ::account_sessions SET `touched` = %i, `status` = %i WHERE `sessionId` = %s', time(), SESSION_EXPIRED, session_id()); + self::destroy(); + return false; + } + else if ($session['userId'] != $userData['id']) // what in the name of fuck..? + { + // Don't know why, don't know how .. doesn't matter, both parties are out. + DB::Aowow()->qry('UPDATE ::account_sessions SET `touched` = %i, `status` = %i WHERE `userId` IN %in AND `status` = %i', time(), SESSION_FORCED_LOGOUT, [$userData['id'], $session['userId']], SESSION_ACTIVE); + trigger_error('User::init - tried to resume session "'.session_id().'" of user #'.$_SESSION['user'].' linked to session data for user #'.$session['userId'].' Kicked both!', E_USER_ERROR); + self::destroy(); + return false; + } + + DB::Aowow()->qry('UPDATE ::account_sessions SET `touched` = %i, `expires` = IF(`expires`, %i, 0) WHERE `sessionId` = %s', time(), time() + Cfg::get('SESSION_TIMEOUT_DELAY'), session_id()); + + if ($loc = Locale::tryFrom($userData['locale'])) + self::$preferedLoc = $loc; + + // reset expired account statuses + if ($userData['statusTimer'] && $userData['statusTimer'] < time() && $userData['status'] != ACC_STATUS_NEW) + { + DB::Aowow()->qry('UPDATE ::account SET `status` = %i, `statusTimer` = 0, `token` = "", `updateValue` = "" WHERE `id` = %i', ACC_STATUS_NONE, User::$id); + $userData['status'] = ACC_STATUS_NONE; + } + + + /*******************************/ + /* past here we are logged in */ + /*******************************/ + + self::$id = intVal($userData['id']); + self::$username = $userData['username']; + self::$reputation = $userData['reputation']; + self::$banStatus = $userData['bans']; + self::$groups = self::isBanned() ? 0 : intval($userData['userGroups']); + self::$perms = self::isBanned() ? 0 : intval($userData['userPerms']); + self::$dailyVotes = $userData['dailyVotes']; + self::$excludeGroups = $userData['excludeGroups']; + self::$status = $userData['status']; + self::$debug = $userData['debug']; + self::$email = $userData['email']; + self::$avatarborder = $userData['avatarborder']; + + + # reset premium options # + + if (!self::isPremium()) + { + if ($userData['avatar'] == 2) { - if (CFG_LOCALES & (1 << $idx)) - { - $loc = $idx; - break; - } + DB::Aowow()->qry('UPDATE ::account SET `avatar` = 1 WHERE `id` = %i', self::$id); + DB::Aowow()->qry('UPDATE ::account_avatars SET `current` = 0 WHERE `userId` = %i', self::$id); + } + + // avatar borders + // do not reset, it's just not sent to the browser + } + + + # update daily limits # + + if (!self::isBanned()) + { + $lastLogin = DB::Aowow()->selectCell('SELECT `curLogin` FROM ::account WHERE `id` = %i', self::$id); + // either the day changed or the last visit was >24h ago + if (date('j', $lastLogin) != date('j') || (time() - $lastLogin) > 1 * DAY) + { + // - daily votes (we need to reset this one) + self::$dailyVotes = self::getMaxDailyVotes(); + + DB::Aowow()->qry( + 'UPDATE ::account + SET `dailyVotes` = %i, `prevLogin` = `curLogin`, `curLogin` = UNIX_TIMESTAMP(), `prevIP` = `curIP`, `curIP` = ? + WHERE `id` = %i', + self::$dailyVotes, + self::$ip, + self::$id + ); + + // - gain reputation for daily visit + if (!(self::isBanned()) && !self::isInGroup(U_GROUP_PENDING)) + Util::gainSiteReputation(self::$id, SITEREP_ACTION_DAILYVISIT); + + // - increment consecutive visits (next day or first of new month and not more than 48h) + if ((date('j', $lastLogin) + 1 == date('j') || (date('j') == 1 && date('n', $lastLogin) != date('n'))) && (time() - $lastLogin) < 2 * DAY) + DB::Aowow()->qry('UPDATE ::account SET `consecutiveVisits` = `consecutiveVisits` + 1 WHERE `id` = %i', self::$id); + else + DB::Aowow()->qry('UPDATE ::account SET `consecutiveVisits` = 0 WHERE `id` = %i', self::$id); } } - // set - if (self::$id) - DB::Aowow()->query('UPDATE ?_account SET locale = ? WHERE id = ?', $loc, self::$id); - - self::useLocale($loc); + return true; } - // only use once - public static function useLocale($use) + public static function save(bool $toDB = false) { - self::$localeId = isset(Util::$localeStrings[$use]) ? $use : LOCALE_EN; - self::$localeString = self::localeString(self::$localeId); + $_SESSION['user'] = self::$id; + $_SESSION['locale'] = self::$preferedLoc; + // $_SESSION['dataKey'] does not depend on user login status and is set in User::init() + + if (self::isLoggedIn() && $toDB) + DB::Aowow()->qry('UPDATE ::account SET `locale` = %s WHERE `id` = %s', self::$preferedLoc->value, self::$id); } - private static function localeString($loc = -1) + public static function destroy() { - if (!isset(Util::$localeStrings[$loc])) - $loc = 0; + session_regenerate_id(true); // session itself is not destroyed; status changed => regenerate id + session_unset(); - return Util::$localeStrings[$loc]; + $_SESSION['locale'] = self::$preferedLoc; // keep locale + $_SESSION['dataKey'] = self::$dataKey; // keep dataKey + + self::$id = 0; + self::$username = ''; + self::$perms = 0; + self::$groups = U_GROUP_NONE; } + /*******************/ /* auth mechanisms */ /*******************/ - public static function Auth($name, $pass) + public static function authenticate(string $login, #[\SensitiveParameter] string $password) : int { - $user = 0; - $hash = ''; + $userId = 0; - switch (CFG_ACC_AUTH_MODE) + $result = match (Cfg::get('ACC_AUTH_MODE')) { - case AUTH_MODE_SELF: - { - if (!self::$ip) - return AUTH_INTERNAL_ERR; + AUTH_MODE_SELF => self::authSelf($login, $password, $userId), + AUTH_MODE_REALM => self::authRealm($login, $password, $userId), + AUTH_MODE_EXTERNAL => self::authExtern($login, $password, $userId), + default => AUTH_INTERNAL_ERR + }; - // handle login try limitation - $ip = DB::Aowow()->selectRow('SELECT ip, count, unbanDate FROM ?_account_bannedips WHERE type = 0 AND ip = ?', self::$ip); - if (!$ip || $ip['unbanDate'] < time()) // no entry exists or time expired; set count to 1 - DB::Aowow()->query('REPLACE INTO ?_account_bannedips (ip, type, count, unbanDate) VALUES (?, 0, 1, UNIX_TIMESTAMP() + ?d)', self::$ip, CFG_ACC_FAILED_AUTH_BLOCK); - else // entry already exists; increment count - DB::Aowow()->query('UPDATE ?_account_bannedips SET count = count + 1, unbanDate = UNIX_TIMESTAMP() + ?d WHERE ip = ?', CFG_ACC_FAILED_AUTH_BLOCK, self::$ip); - - if ($ip && $ip['count'] >= CFG_ACC_FAILED_AUTH_COUNT && $ip['unbanDate'] >= time()) - return AUTH_IPBANNED; - - $query = DB::Aowow()->SelectRow(' - SELECT a.id, a.passHash, BIT_OR(ab.typeMask) AS bans, a.status - FROM ?_account a - LEFT JOIN ?_account_banned ab ON a.id = ab.userId AND ab.end > UNIX_TIMESTAMP() - WHERE a.user = ? - GROUP BY a.id', - $name - ); - if (!$query) - return AUTH_WRONGUSER; - - self::$passHash = $query['passHash']; - if (!self::verifyCrypt($pass)) - return AUTH_WRONGPASS; - - // successfull auth; clear bans for this IP - DB::Aowow()->query('DELETE FROM ?_account_bannedips WHERE type = 0 AND ip = ?', self::$ip); - - if ($query['bans'] & (ACC_BAN_PERM | ACC_BAN_TEMP)) - return AUTH_BANNED; - - $user = $query['id']; - $hash = $query['passHash']; - break; - } - case AUTH_MODE_REALM: - { - if (!DB::isConnectable(DB_AUTH)) - return AUTH_INTERNAL_ERR; - - $wow = DB::Auth()->selectRow('SELECT a.id, a.salt, a.verifier, ab.active AS hasBan FROM account a LEFT JOIN account_banned ab ON ab.id = a.id AND active <> 0 WHERE username = ? LIMIT 1', $name); - if (!$wow) - return AUTH_WRONGUSER; - - if (!self::verifySRP6($name, $pass, $wow['salt'], $wow['verifier'])) - return AUTH_WRONGPASS; - - if ($wow['hasBan']) - return AUTH_BANNED; - - if ($_ = self::checkOrCreateInDB($wow['id'], $name)) - $user = $_; - else - return AUTH_INTERNAL_ERR; - - break; - } - case AUTH_MODE_EXTERNAL: - { - if (!file_exists('config/extAuth.php')) - return AUTH_INTERNAL_ERR; - - require 'config/extAuth.php'; - - $extGroup = -1; - $result = extAuth($name, $pass, $extId, $extGroup); - - if ($result == AUTH_OK && $extId) - { - if ($_ = self::checkOrCreateInDB($extId, $name, $extGroup)) - $user = $_; - else - return AUTH_INTERNAL_ERR; - - break; - } - - return $result; - } - default: - return AUTH_INTERNAL_ERR; + // also banned? its a feature block, not login block.. + if ($result == AUTH_OK || $result == AUTH_BANNED) + { + session_unset(); + $_SESSION['user'] = $userId; + self::$id = $userId; } - // kickstart session - session_unset(); - $_SESSION['user'] = $user; - $_SESSION['hash'] = $hash; + return $result; + } + + private static function authSelf(string $nameOrEmail, #[\SensitiveParameter] string $password, int &$userId) : int + { + if (!self::$ip) + return AUTH_INTERNAL_ERR; + + // handle login try limitation + $ipBan = DB::Aowow()->selectRow('SELECT `ip`, `count`, IF(`unbanDate` > UNIX_TIMESTAMP(), 1, 0) AS "active" FROM ::account_bannedips WHERE `type` = %i AND `ip` = %s', IP_BAN_TYPE_LOGIN_ATTEMPT, self::$ip); + if (!$ipBan || !$ipBan['active']) // no entry exists or time expired; set count to 1 + DB::Aowow()->qry('REPLACE INTO ::account_bannedips (`ip`, `type`, `count`, `unbanDate`) VALUES (%s, %i, 1, UNIX_TIMESTAMP() + %i)', self::$ip, IP_BAN_TYPE_LOGIN_ATTEMPT, Cfg::get('ACC_FAILED_AUTH_BLOCK')); + else // entry already exists; increment count + DB::Aowow()->qry('UPDATE ::account_bannedips SET `count` = `count` + 1, `unbanDate` = UNIX_TIMESTAMP() + %i WHERE `ip` = %s', Cfg::get('ACC_FAILED_AUTH_BLOCK'), self::$ip); + + if ($ipBan && $ipBan['count'] >= Cfg::get('ACC_FAILED_AUTH_COUNT') && $ipBan['active']) + return AUTH_IPBANNED; + + $email = filter_var($nameOrEmail, FILTER_VALIDATE_EMAIL); + + $query = DB::Aowow()->SelectRow( + 'SELECT a.`id`, a.`passHash`, BIT_OR(ab.`typeMask`) AS "bans", a.`status` + FROM ::account a + LEFT JOIN ::account_banned ab ON a.`id` = ab.`userId` AND ab.`end` > UNIX_TIMESTAMP() + WHERE %if', $email, 'a.`email` %else a.`login` %end = %s AND `status` <> %i + GROUP BY a.`id`', + $nameOrEmail, + ACC_STATUS_DELETED + ); + + if (!$query) + return AUTH_WRONGUSER; + + if (!self::verifyCrypt($password, $query['passHash'])) + return AUTH_WRONGPASS; + + // successfull auth; clear bans for this IP + DB::Aowow()->qry('DELETE FROM ::account_bannedips WHERE `type` = %i AND `ip` = %s', IP_BAN_TYPE_LOGIN_ATTEMPT, self::$ip); + + if ($query['bans'] & (ACC_BAN_PERM | ACC_BAN_TEMP)) + return AUTH_BANNED; + + $userId = $query['id']; return AUTH_OK; } - // create a linked account for our settings if nessecary - private static function checkOrCreateInDB($extId, $name, $userGroup = -1) + private static function authRealm(string $name, #[\SensitiveParameter] string $password, int &$userId) : int { - if (!intVal($extId)) - return 0; + if (!DB::isConnectable(DB_AUTH)) + return AUTH_INTERNAL_ERR; - $userGroup = intVal($userGroup); + $wow = DB::Auth()->selectRow('SELECT a.id, a.salt, a.verifier, ab.active AS hasBan FROM account a LEFT JOIN account_banned ab ON ab.id = a.id AND active <> 0 WHERE username = %s LIMIT 1', $name); + if (!$wow) + return AUTH_WRONGUSER; - if ($_ = DB::Aowow()->selectCell('SELECT id FROM ?_account WHERE extId = ?d', $extId)) + if (!self::verifySRP6($name, $password, $wow['salt'], $wow['verifier'])) + return AUTH_WRONGPASS; + + if ($wow['hasBan']) + return AUTH_BANNED; + + if ($_ = self::checkOrCreateInDB($wow['id'], $name)) + $userId = $_; + else + return AUTH_INTERNAL_ERR; + + return AUTH_OK; + } + + private static function authExtern(string $nameOrEmail, #[\SensitiveParameter] string $password, int &$userId) : int + { + if (!file_exists('config/extAuth.php')) + { + trigger_error('User::authExtern - AUTH_MODE_EXTERNAL is selected but config/extAuth.php does not exist!', E_USER_ERROR); + return AUTH_INTERNAL_ERR; + } + + require 'config/extAuth.php'; + + if (!function_exists('\extAuth')) + { + trigger_error('User::authExtern - AUTH_MODE_EXTERNAL is selected but function extAuth() is not defined!', E_USER_ERROR); + return AUTH_INTERNAL_ERR; + } + + $extGroup = -1; + $extId = 0; + $result = \extAuth($nameOrEmail, $password, $extId, $extGroup); + + // assert we don't have an email passed back from extAuth + if (filter_var($nameOrEmail, FILTER_VALIDATE_EMAIL)) + return AUTH_WRONGUSER; + + if ($result == AUTH_OK && $extId) + { + if ($_ = self::checkOrCreateInDB($extId, $nameOrEmail, $extGroup)) + $userId = $_; + else + return AUTH_INTERNAL_ERR; + } + + return $result; + } + + // create a linked account for our settings if necessary + private static function checkOrCreateInDB(int $extId, string $name, int $userGroup = -1) : int + { + if ($_ = DB::Aowow()->selectCell('SELECT `id` FROM ::account WHERE `extId` = %i', $extId)) { if ($userGroup >= U_GROUP_NONE) - DB::Aowow()->query('UPDATE ?_account SET userGroups = ?d WHERE extId = ?d', $userGroup, $extId); + DB::Aowow()->qry('UPDATE ::account SET `userGroups` = %i WHERE `extId` = %i', $userGroup, $extId); return $_; } - $newId = DB::Aowow()->query('INSERT IGNORE INTO ?_account (extId, user, displayName, joinDate, prevIP, prevLogin, locale, status, userGroups) VALUES (?d, ?, ?, UNIX_TIMESTAMP(), ?, UNIX_TIMESTAMP(), ?d, ?d, ?d)', + $newId = DB::Aowow()->qry('INSERT IGNORE INTO ::account (`extId`, `passHash`, `username`, `joinDate`, `prevIP`, `prevLogin`, `locale`, `status`, `userGroups`) VALUES (%i, "", %s, UNIX_TIMESTAMP(), %s, UNIX_TIMESTAMP(), %i, %i, %i)', $extId, $name, - Util::ucFirst($name), - isset($_SERVER["REMOTE_ADDR"]) ? $_SERVER["REMOTE_ADDR"] : '', - User::$localeId, - ACC_STATUS_OK, + $_SERVER["REMOTE_ADDR"] ?? '', + self::$preferedLoc->value, + ACC_STATUS_NONE, $userGroup >= U_GROUP_NONE ? $userGroup : U_GROUP_NONE ); if ($newId) Util::gainSiteReputation($newId, SITEREP_ACTION_REGISTER); - return $newId; + return $newId ?: 0; } - private static function createSalt() + // crypt used by us + public static function hashCrypt(#[\SensitiveParameter] string $pass) : string { - $algo = '$2a'; - $strength = '$09'; - $salt = '$'.Util::createHash(22); - - return $algo.$strength.$salt; + return password_hash($pass, PASSWORD_BCRYPT, ['cost' => 15]); } - // crypt used by aowow - public static function hashCrypt($pass) + public static function verifyCrypt(#[\SensitiveParameter] string $pass, string $hash) : bool { - return crypt($pass, self::createSalt()); + return password_verify($pass, $hash); } - public static function verifyCrypt($pass, $hash = '') - { - $_ = $hash ?: self::$passHash; - return $_ === crypt($pass, $_); - } - - private static function verifySRP6($user, $pass, $salt, $verifier) + // SRP6 used by TC + private static function verifySRP6(string $user, string $pass, string $salt, string $verifier) : bool { $g = gmp_init(7); $N = gmp_init('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7', 16); @@ -399,185 +404,153 @@ class User return ($verifier === str_pad(gmp_export($v, 1, GMP_LSW_FIRST), 32, chr(0), STR_PAD_RIGHT)); } - public static function isValidName($name, &$errCode = 0) - { - $errCode = 0; - - // different auth modes require different usernames - $min = 0; // external case - $max = 0; - if (CFG_ACC_AUTH_MODE == AUTH_MODE_SELF) - { - $min = 4; - $max = 16; - } - else if (CFG_ACC_AUTH_MODE == AUTH_MODE_REALM) - { - $min = 3; - $max = 32; - } - - if (($min && mb_strlen($name) < $min) || ($max && mb_strlen($name) > $max)) - $errCode = 1; - else if (preg_match('/[^\w\d\-]/i', $name)) - $errCode = 2; - - return $errCode == 0; - } - - public static function isValidPass($pass, &$errCode = 0) - { - $errCode = 0; - - // only enforce for own passwords - if (mb_strlen($pass) < 6 && CFG_ACC_AUTH_MODE == AUTH_MODE_SELF) - $errCode = 1; - // else if (preg_match('/[^\w\d!"#\$%]/', $pass)) // such things exist..? :o - // $errCode = 2; - - return $errCode == 0; - } - - public static function save() - { - $_SESSION['user'] = self::$id; - $_SESSION['hash'] = self::$passHash; - $_SESSION['locale'] = self::$localeId; - $_SESSION['timeout'] = self::$expires ? time() + CFG_SESSION_TIMEOUT_DELAY : 0; - // $_SESSION['dataKey'] does not depend on user login status and is set in User::init() - } - - public static function destroy() - { - session_regenerate_id(true); // session itself is not destroyed; status changed => regenerate id - session_unset(); - - $_SESSION['locale'] = self::$localeId; // keep locale - $_SESSION['dataKey'] = self::$dataKey; // keep dataKey - - self::$id = 0; - self::$displayName = ''; - self::$perms = 0; - self::$groups = U_GROUP_NONE; - } /*********************/ /* access management */ /*********************/ - public static function isInGroup($group) + public static function isInGroup(int $group) : bool { - return (self::$groups & $group) != 0; + return $group == U_GROUP_NONE || (self::$groups & $group) != U_GROUP_NONE; } - public static function canComment() + public static function canComment() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_COMMENT | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_COMMENT)) return false; - return self::$perms || self::$reputation >= CFG_REP_REQ_COMMENT; + return self::$perms || self::$reputation >= Cfg::get('REP_REQ_COMMENT'); } - public static function canReply() + public static function canReply() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_COMMENT | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_COMMENT)) return false; - return self::$perms || self::$reputation >= CFG_REP_REQ_REPLY; + return self::$perms || self::$reputation >= Cfg::get('REP_REQ_REPLY'); } - public static function canUpvote() + public static function canUpvote() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_COMMENT | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_COMMENT)) return false; - return self::$perms || (self::$reputation >= CFG_REP_REQ_UPVOTE && self::$dailyVotes > 0); + return self::$perms || (self::$reputation >= Cfg::get('REP_REQ_UPVOTE') && self::$dailyVotes > 0); } - public static function canDownvote() + public static function canDownvote() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_RATE | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_RATE)) return false; - return self::$perms || (self::$reputation >= CFG_REP_REQ_DOWNVOTE && self::$dailyVotes > 0); + return self::$perms || (self::$reputation >= Cfg::get('REP_REQ_DOWNVOTE') && self::$dailyVotes > 0); } - public static function canSupervote() + public static function canSupervote() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_RATE | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_RATE) || self::isInGroup(U_GROUP_PENDING)) return false; - return self::$reputation >= CFG_REP_REQ_SUPERVOTE; + return self::$reputation >= Cfg::get('REP_REQ_SUPERVOTE'); } - public static function canUploadScreenshot() + public static function canUploadScreenshot() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_SCREENSHOT | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_SCREENSHOT) || self::isInGroup(U_GROUP_PENDING)) return false; return true; } - public static function canWriteGuide() + public static function canWriteGuide() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_GUIDE | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_GUIDE) || self::isInGroup(U_GROUP_PENDING)) return false; return true; } - public static function canSuggestVideo() + public static function canSuggestVideo() : bool { - if (!self::$id || self::$banStatus & (ACC_BAN_VIDEO | ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_VIDEO) || self::isInGroup(U_GROUP_PENDING)) return false; return true; } - public static function isPremium() + public static function isPremium() : bool { - return self::isInGroup(U_GROUP_PREMIUM) || self::$reputation >= CFG_REP_REQ_PREMIUM; + return !self::isBanned() && (self::isInGroup(U_GROUP_PREMIUM) || self::$reputation >= Cfg::get('REP_REQ_PREMIUM')); } + public static function isLoggedIn() : bool + { + return self::$id > 0; // more checks? maybe check pending email verification here? (self::isInGroup(U_GROUP_PENDING)) + } + + public static function isBanned(int $addBanMask = 0x0) : bool + { + return self::$banStatus & (ACC_BAN_TEMP | ACC_BAN_PERM | $addBanMask); + } + + public static function isRecovering() : bool + { + return self::$status != ACC_STATUS_NONE && self::$status != ACC_STATUS_NEW; + } + + /**************/ /* js-related */ /**************/ - public static function decrementDailyVotes() + public static function decrementDailyVotes() : void { + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_RATE)) + return; + self::$dailyVotes--; - DB::Aowow()->query('UPDATE ?_account SET dailyVotes = ?d WHERE id = ?d', self::$dailyVotes, self::$id); + DB::Aowow()->qry('UPDATE ::account SET `dailyVotes` = %i WHERE `id` = %i', self::$dailyVotes, self::$id); } - public static function getCurDailyVotes() + public static function getCurrentDailyVotes() : int { + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_RATE) || self::$dailyVotes < 0) + return 0; + return self::$dailyVotes; } - public static function getMaxDailyVotes() + public static function getMaxDailyVotes() : int { - if (!self::$id || self::$banStatus & (ACC_BAN_PERM | ACC_BAN_TEMP)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_RATE)) return 0; - return CFG_USER_MAX_VOTES + (self::$reputation >= CFG_REP_REQ_VOTEMORE_BASE ? 1 + intVal((self::$reputation - CFG_REP_REQ_VOTEMORE_BASE) / CFG_REP_REQ_VOTEMORE_ADD) : 0); + $threshold = Cfg::get('REP_REQ_VOTEMORE_BASE'); + $extra = Cfg::get('REP_REQ_VOTEMORE_ADD'); + $base = Cfg::get('USER_MAX_VOTES'); + + return $base + max(0, intVal((self::$reputation - $threshold + $extra) / $extra)); } - public static function getReputation() + public static function getReputation() : int { + if (!self::isLoggedIn() || self::$reputation < 0) + return 0; + return self::$reputation; } - public static function getUserGlobals() + public static function getUserGlobal() : array { $gUser = array( 'id' => self::$id, - 'name' => self::$displayName, + 'name' => self::$username, 'roles' => self::$groups, 'permissions' => self::$perms, 'cookies' => [] ); - if (!self::$id || self::$banStatus & (ACC_BAN_TEMP | ACC_BAN_PERM)) + if (!self::isLoggedIn() || self::isBanned()) return $gUser; $gUser['commentban'] = !self::canComment(); @@ -585,11 +558,22 @@ class User $gUser['canDownvote'] = self::canDownvote(); $gUser['canPostReplies'] = self::canReply(); $gUser['superCommentVotes'] = self::canSupervote(); - $gUser['downvoteRep'] = CFG_REP_REQ_DOWNVOTE; - $gUser['upvoteRep'] = CFG_REP_REQ_UPVOTE; + $gUser['downvoteRep'] = Cfg::get('REP_REQ_DOWNVOTE'); + $gUser['upvoteRep'] = Cfg::get('REP_REQ_UPVOTE'); $gUser['characters'] = self::getCharacters(); + $gUser['completion'] = self::getCompletion(); $gUser['excludegroups'] = self::$excludeGroups; - $gUser['settings'] = (new StdClass); // profiler requires this to be set; has property premiumborder (NYI) + + if (self::$debug) + $gUser['debug'] = true; // csv id-list output option on listviews + + if (self::isPremium()) + { + $gUser['premium'] = 1; + $gUser['settings'] = ['premiumborder' => self::$avatarborder]; + } + else + $gUser['settings'] = (new \StdClass); // existence is checked in Profiler.js before g_user.excludegroups is applied; should this contain - "defaultModel":{"gender":2,"race":6} ? if ($_ = self::getProfilerExclusions()) $gUser = array_merge($gUser, $_); @@ -609,86 +593,115 @@ class User return $gUser; } - public static function getWeightScales() + public static function getWeightScales() : array { $result = []; - $res = DB::Aowow()->selectCol('SELECT id AS ARRAY_KEY, name FROM ?_account_weightscales WHERE userId = ?d', self::$id); + if (!self::isLoggedIn() || self::isBanned()) + return $result; + + $res = DB::Aowow()->selectPairs('SELECT `id`, `name` FROM ::account_weightscales WHERE `userId` = %i', self::$id); if (!$res) return $result; - $weights = DB::Aowow()->selectCol('SELECT id AS ARRAY_KEY, `field` AS ARRAY_KEY2, val FROM ?_account_weightscale_data WHERE id IN (?a)', array_keys($res)); + $weights = DB::Aowow()->selectCol('SELECT `id` AS ARRAY_KEY, `field` AS ARRAY_KEY2, `val` FROM ::account_weightscale_data WHERE `id` IN %in', array_keys($res)); foreach ($weights as $id => $data) $result[] = array_merge(['name' => $res[$id], 'id' => $id], $data); return $result; } - public static function getProfilerExclusions() + public static function getProfilerExclusions() : array { $result = []; - $modes = [1 => 'excludes', 2 => 'includes']; - foreach ($modes as $mode => $field) - if ($ex = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, typeId AS ARRAY_KEY2, typeId FROM ?_account_excludes WHERE mode = ?d AND userId = ?d', $mode, self::$id)) + + if (!self::isLoggedIn() || self::isBanned()) + return $result; + + if (!Cfg::get('PROFILER_ENABLE')) + return $result; + + foreach ([Profiler::COMPLETION_EXCLUDE => 'excludes', Profiler::COMPLETION_INCLUDE => 'includes'] as $mode => $field) + if ($ex = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, `typeId` AS ARRAY_KEY2, `typeId` FROM ::account_excludes WHERE `mode` = %i AND `userId` = %i', $mode, self::$id)) foreach ($ex as $type => $ids) $result[$field][$type] = array_values($ids); return $result; } - public static function getCharacters() + public static function getCharacters() : array { - if (!self::$profiles) + if (!self::loadProfiles()) return []; return self::$profiles->getJSGlobals(PROFILEINFO_CHARACTER); } - public static function getProfiles() + public static function getProfiles() : array { - if (!self::$profiles) + if (!self::loadProfiles()) return []; return self::$profiles->getJSGlobals(PROFILEINFO_PROFILE); } - public static function getGuides() + public static function getPinnedCharacter() : array + { + if (!self::loadProfiles()) + return []; + + $realms = Profiler::getRealms(); + + foreach (self::$profiles->iterate() as $id => $_) + if (self::$profiles->getField('cuFlags') & PROFILER_CU_PINNED) + if (isset($realms[self::$profiles->getField('realm')])) + return [ + $id, + self::$profiles->getField('name'), + self::$profiles->getField('region') . '.' . Profiler::urlize($realms[self::$profiles->getField('realm')]['name'], true) . '.' . Profiler::urlize(self::$profiles->getField('name'), true, true) + ]; + + return []; + } + + public static function getGuides() : array { $result = []; - if ($guides = DB::Aowow()->select('SELECT `id`, `title`, `url` FROM ?_guides WHERE `userId` = ?d AND `status` <> ?d', self::$id, GUIDE_STATUS_ARCHIVED)) + if (!self::isLoggedIn() || self::isBanned(ACC_BAN_GUIDE)) + return $result; + + if ($guides = DB::Aowow()->selectAssoc('SELECT `id`, `title`, `url` FROM ::guides WHERE `userId` = %i AND `status` <> %i', self::$id, GuideMgr::STATUS_ARCHIVED)) { // fix url - array_walk($guides, fn(&$x) => $x['url'] = '/?guide='.($x['url'] ?? $x['id'])); + array_walk($guides, fn(&$x) => $x['url'] = '?guide='.($x['url'] ?: $x['id'])); $result = $guides; } return $result; } - public static function getCookies() + public static function getCookies() : array { - $data = []; - - if (self::$id) - $data = DB::Aowow()->selectCol('SELECT name AS ARRAY_KEY, data FROM ?_account_cookies WHERE userId = ?d', self::$id); - - return $data; - } - - public static function getFavorites() - { - if (!self::$id) + if (!self::isLoggedIn()) return []; - $res = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, `typeId` AS ARRAY_KEY2, `typeId` FROM ?_account_favorites WHERE `userId` = ?d', self::$id); + return DB::Aowow()->selectPairs('SELECT `name`, `data` FROM ::account_cookies WHERE `userId` = %i', self::$id); + } + + public static function getFavorites() : array + { + if (!self::isLoggedIn() || self::isBanned()) + return []; + + $res = DB::Aowow()->selectCol('SELECT `type` AS ARRAY_KEY, `typeId` AS ARRAY_KEY2, `typeId` FROM ::account_favorites WHERE `userId` = %i', self::$id); if (!$res) return []; $data = []; foreach ($res as $type => $ids) { - $tc = Type::newList($type, [['id', array_values($ids)]]); + $tc = Type::newList($type, [['id', $ids]]); if (!$tc || $tc->error) continue; @@ -702,6 +715,81 @@ class User return $data; } + + public static function getCompletion() : array + { + if (!self::loadProfiles()) + return []; + + $ids = []; + foreach (self::$profiles->iterate() as $_) + if (!self::$profiles->isCustom()) + $ids[] = self::$profiles->id; + + if (!$ids) + return []; + + $completion = []; + + $x = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `questId` AS ARRAY_KEY2, `questId` FROM ::profiler_completion_quests WHERE `id` IN %in', $ids); + $completion[Type::QUEST] = $x ? array_map(array_values(...), $x) : []; + + $x = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `achievementId` AS ARRAY_KEY2, `achievementId` FROM ::profiler_completion_achievements WHERE `id` IN %in', $ids); + $completion[Type::ACHIEVEMENT] = $x ? array_map(array_values(...), $x) : []; + + $x = DB::Aowow()->selectAssoc('SELECT `id` AS ARRAY_KEY, `titleId` AS ARRAY_KEY2, `titleId` FROM ::profiler_completion_titles WHERE `id` IN %in', $ids); + $completion[Type::TITLE] = $x ? array_map(array_values(...), $x) : []; + + $completion[Type::ITEM] = []; + + $spells = DB::Aowow()->selectAssoc( + 'SELECT pcs.`id` AS ARRAY_KEY, pcs.`spellId` AS ARRAY_KEY2, pcs.`spellId`, i.`id` AS "itemId" + FROM ::spell s + JOIN ::profiler_completion_spells pcs ON s.`id` = pcs.`spellId` + LEFT JOIN ::items i ON i.`spellId1` IN %in AND i.`spellId2` = pcs.`spellId` + WHERE s.`typeCat` IN %in AND pcs.`id` IN %in', + LEARN_SPELLS, [-5, -6, 9, 11], $ids + ); + + if ($spells) + { + $completion[Type::SPELL] = array_map(fn($x) => array_column($x, 'spellId'), $spells); + + if ($recipes = array_map(fn($x) => array_filter(array_column($x, 'itemId')), $spells)) + foreach ($ids as $id) // array_merge_recursive does not respect numeric keys + $completion[Type::ITEM][$id] = array_merge($completion[Type::ITEM][$id] ?? [], $recipes[$id] ?? []); + } + else + $completion[Type::SPELL] = []; + + // init empty result sets + foreach ($completion as &$c) + foreach ($ids as $id) + if (!isset($c[$id])) + $c[$id] = []; + + return $completion; + } + + private static function loadProfiles() : bool + { + if (!Cfg::get('PROFILER_ENABLE')) + return false; + + if (self::$profiles === null) + { + $ap = DB::Aowow()->selectCol('SELECT `profileId` FROM ::account_profiles WHERE `accountId` = %i', self::$id); + + // the old approach [DB::OR, ['user', self::$id], ['ap.accountId', self::$id]] caused keys to not get used + $conditions = $ap ? [[DB::OR, ['user', self::$id], ['id', $ap]]] : [['user', self::$id]]; + if (!self::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU)) + $conditions[] = ['deleted', 0]; + + self::$profiles = (new LocalProfileList($conditions)); + } + + return !!self::$profiles->getFoundIDs(); + } } ?> diff --git a/includes/utilities.php b/includes/utilities.php index 50d72d92..39e9f297 100644 --- a/includes/utilities.php +++ b/includes/utilities.php @@ -1,12 +1,35 @@ $v) + if ($callback($v, $k)) + return $array[$k]; + return null; + } + + function array_find_key(array $array, callable $callback) : mixed + { + foreach ($array as $k => $v) + if ($callback($v, $k)) + return $k; + return null; + } +} + +class SimpleXML extends \SimpleXMLElement +{ + public function addCData(string $cData) : \SimpleXMLElement { $node = dom_import_simplexml($this); $no = $node->ownerDocument; @@ -16,438 +39,25 @@ class SimpleXML extends SimpleXMLElement } } -trait TrRequestData -{ - private $filtered = false; - - private function initRequestData() : void - { - if ($this->filtered) - return; - - // php bug? If INPUT_X is empty, filter_input_array returns null/fails - // only really relevant for INPUT_POST - // manuall set everything null in this case - - if (isset($this->_post) && gettype($this->_post) == 'array') - { - if ($_POST) - $this->_post = filter_input_array(INPUT_POST, $this->_post); - else - $this->_post = array_fill_keys(array_keys($this->_post), null); - } - - if (isset($this->_get) && gettype($this->_get) == 'array') - { - if ($_GET) - $this->_get = filter_input_array(INPUT_GET, $this->_get); - else - $this->_get = array_fill_keys(array_keys($this->_get), null); - } - - if (isset($this->_cookie) && gettype($this->_cookie) == 'array') - { - if ($_COOKIE) - $this->_cookie = filter_input_array(INPUT_COOKIE, $this->_cookie); - else - $this->_cookie = array_fill_keys(array_keys($this->_cookie), null); - } - - $this->filtered = true; - } - - private static function checkEmptySet(string $val) : bool - { - return $val === ''; // parameter is expected to be empty - } - - public static function checkInt(string $val) : int - { - if (preg_match('/^-?\d+$/', $val)) - return intVal($val); - - return 0; - } - - private static function checkLocale(string $val) : int - { - if (preg_match('/^'.implode('|', array_keys(array_filter(Util::$localeStrings))).'$/', $val)) - return intVal($val); - - return -1; - } - - private static function checkDomain(string $val) : string - { - if (preg_match('/^'.implode('|', array_filter(Util::$subDomains)).'$/i', $val)) - return strtolower($val); - - return ''; - } - - private static function checkIdList(string $val) : array - { - if (preg_match('/^-?\d+(,-?\d+)*$/', $val)) - return array_map('intVal', explode(',', $val)); - - return []; - } - - private static function checkIntArray(string $val) : array - { - if (preg_match('/^-?\d+(:-?\d+)*$/', $val)) - return array_map('intVal', explode(':', $val)); - - return []; - } - - private static function checkIdListUnsigned(string $val) : array - { - if (preg_match('/\d+(,\d+)*/', $val)) - return array_map('intVal', explode(',', $val)); - - return []; - } - - private static function checkFulltext(string $val) : string - { - // trim non-printable chars - return preg_replace('/[\p{Cf}\p{Co}\p{Cs}\p{Cn}]/ui', '', $val); - } -} - -abstract class CLI -{ - const CHR_BELL = 7; - const CHR_BACK = 8; - const CHR_TAB = 9; - const CHR_LF = 10; - const CHR_CR = 13; - const CHR_ESC = 27; - const CHR_BACKSPACE = 127; - - const LOG_BLANK = 0; - const LOG_OK = 1; - const LOG_WARN = 2; - const LOG_ERROR = 3; - const LOG_INFO = 4; - - private static $logHandle = null; - private static $hasReadline = null; - - - /********************/ - /* formatted output */ - /********************/ - - public static function writeTable(array $out, bool $timestamp = false) : void - { - if (!$out) - return; - - $pads = []; - $nCols = 0; - - foreach ($out as $i => $row) - { - if (!is_array($out[0])) - { - unset($out[$i]); - continue; - } - - if (!$nCols) - $nCols = count($row); - - for ($j = 0; $j < $nCols - 1; $j++) // don't pad last column - $pads[$j] = max($pads[$j], mb_strlen($row[$j])); - } - self::write(); - - foreach ($out as $row) - { - for ($i = 0; $i < $nCols - 1; $i++) // don't pad last column - $row[$i] = str_pad($row[$i], $pads[$i] + 2); - - self::write(' '.implode($row), -1, $timestamp); - } - - self::write(); - } - - - /***********/ - /* logging */ - /***********/ - - public static function initLogFile(string $file = '') : void - { - if (!$file) - return; - - $file = self::nicePath($file); - if (!file_exists($file)) - self::$logHandle = fopen($file, 'w'); - else - { - $logFileParts = pathinfo($file); - - $i = 1; - while (file_exists($logFileParts['dirname'].'/'.$logFileParts['filename'].$i.(isset($logFileParts['extension']) ? '.'.$logFileParts['extension'] : ''))) - $i++; - - $file = $logFileParts['dirname'].'/'.$logFileParts['filename'].$i.(isset($logFileParts['extension']) ? '.'.$logFileParts['extension'] : ''); - self::$logHandle = fopen($file, 'w'); - } - } - - public static function red(string $str) : string - { - return OS_WIN ? $str : "\e[31m".$str."\e[0m"; - } - - public static function green(string $str) : string - { - return OS_WIN ? $str : "\e[32m".$str."\e[0m"; - } - - public static function yellow(string $str) : string - { - return OS_WIN ? $str : "\e[33m".$str."\e[0m"; - } - - public static function blue(string $str) : string - { - return OS_WIN ? $str : "\e[36m".$str."\e[0m"; - } - - public static function bold(string $str) : string - { - return OS_WIN ? $str : "\e[1m".$str."\e[0m"; - } - - public static function write(string $txt = '', int $lvl = self::LOG_BLANK, bool $timestamp = true) : void - { - $msg = ''; - if ($txt) - { - if ($timestamp) - $msg = str_pad(date('H:i:s'), 10); - - switch ($lvl) - { - case self::LOG_ERROR: // red critical error - $msg .= '['.self::red('ERR').'] '; - break; - case self::LOG_WARN: // yellow notice - $msg .= '['.self::yellow('WARN').'] '; - break; - case self::LOG_OK: // green success - $msg .= '['.self::green('OK').'] '; - break; - case self::LOG_INFO: // blue info - $msg .= '['.self::blue('INFO').'] '; - break; - case self::LOG_BLANK: - $msg .= ' '; - break; - } - - $msg .= $txt."\n"; - } - else - $msg = "\n"; - - echo $msg; - - if (self::$logHandle) // remove highlights for logging - fwrite(self::$logHandle, preg_replace(["/\e\[\d+m/", "/\e\[0m/"], '', $msg)); - - flush(); - } - - public static function nicePath(string $fileOrPath, string ...$pathParts) : string - { - $path = ''; - - if ($pathParts) - { - foreach ($pathParts as &$pp) - $pp = trim($pp); - - $path .= implode(DIRECTORY_SEPARATOR, $pathParts); - } - - $path .= ($path ? DIRECTORY_SEPARATOR : '').trim($fileOrPath); - - // remove quotes (from erronous user input) - $path = str_replace(['"', "'"], ['', ''], $path); - - if (!$path) // empty strings given. (faulty dbc data?) - return ''; - - if (DIRECTORY_SEPARATOR == '/') // *nix - { - $path = str_replace('\\', '/', $path); - $path = preg_replace('/\/+/i', '/', $path); - } - else if (DIRECTORY_SEPARATOR == '\\') // win - { - $path = str_replace('/', '\\', $path); - $path = preg_replace('/\\\\+/i', '\\', $path); - } - else - CLI::write('Dafuq! Your directory separator is "'.DIRECTORY_SEPARATOR.'". Please report this!', CLI::LOG_ERROR); - - // resolve *nix home shorthand - if (!OS_WIN) - { - if (preg_match('/^~(\w+)\/.*/i', $path, $m)) - $path = '/home/'.substr($path, 1); - else if (substr($path, 0, 2) == '~/') - $path = getenv('HOME').substr($path, 1); - else if ($path[0] == DIRECTORY_SEPARATOR && substr($path, 0, 6) != '/home/') - $path = substr($path, 1); - } - - return $path; - } - - - /**************/ - /* read input */ - /**************/ - - /* - since the CLI on WIN ist not interactive, the following things have to be considered - you do not receive keystrokes but whole strings upon pressing (wich also appends a \r) - as such and probably other control chars can not be registered - this also means, you can't hide input at all, least process it - */ - - public static function read(array &$fields, bool $singleChar = false) : bool - { - // first time set - if (self::$hasReadline === null) - self::$hasReadline = function_exists('readline_callback_handler_install'); - - // prevent default output if able - if (self::$hasReadline) - readline_callback_handler_install('', function() { }); - - foreach ($fields as $name => $data) - { - $vars = ['desc', 'isHidden', 'validPattern']; - foreach ($vars as $idx => $v) - $$v = isset($data[$idx]) ? $data[$idx] : false; - - $charBuff = ''; - - if ($desc) - echo "\n".$desc.": "; - - while (true) { - $r = [STDIN]; - $w = $e = null; - $n = stream_select($r, $w, $e, 200000); - - if ($n && in_array(STDIN, $r)) { - $char = stream_get_contents(STDIN, 1); - $keyId = ord($char); - - // ignore this one - if ($keyId == self::CHR_TAB) - continue; - - // WIN sends \r\n as sequence, ignore one - if ($keyId == self::CHR_CR && OS_WIN) - continue; - - // will not be send on WIN .. other ways of returning from setup? (besides ctrl + c) - if ($keyId == self::CHR_ESC) - { - echo chr(self::CHR_BELL); - return false; - } - else if ($keyId == self::CHR_BACKSPACE) - { - if (!$charBuff) - continue; - - $charBuff = mb_substr($charBuff, 0, -1); - if (!$isHidden && self::$hasReadline) - echo chr(self::CHR_BACK)." ".chr(self::CHR_BACK); - } - else if ($keyId == self::CHR_LF) - { - $fields[$name] = $charBuff; - break; - } - else if (!$validPattern || preg_match($validPattern, $char)) - { - $charBuff .= $char; - if (!$isHidden && self::$hasReadline) - echo $char; - - if ($singleChar && self::$hasReadline) - { - $fields[$name] = $charBuff; - break; - } - } - } - } - } - - echo chr(self::CHR_BELL); - - foreach ($fields as $f) - if (strlen($f)) - return true; - - $fields = null; - return true; - } -} - abstract class Util { - const FILE_ACCESS = 0777; + /* NOTE! + * FILE_ACCESS should be 0755 or less, but CLI and web interface both access the same files. While in CLI php is executed with the current users perms, + * while the web interface is always executed by www-data (or whoever runs the web server) who does not own the files previously created via CLI. + * And thus web interface actions fail with permission denied, unless the files are flagged +wx for everyone. + * This probably has to be solved on the system level by having www-data and the CLI user share a group or something. + */ + public const FILE_ACCESS = 0777; + public const DIR_ACCESS = 0777; - const GEM_SCORE_BASE_WOTLK = 16; // rare quality wotlk gem score - const GEM_SCORE_BASE_BC = 8; // rare quality bc gem score + private const GEM_SCORE_BASE_WOTLK = 16; // rare quality wotlk gem score + private const GEM_SCORE_BASE_BC = 8; // rare quality bc gem score private static $perfectGems = null; - public static $localeStrings = array( // zero-indexed - 'enus', null, 'frfr', 'dede', 'zhcn', null, 'eses', null, 'ruru' - ); - - public static $subDomains = array( - 'www', null, 'fr', 'de', 'cn', null, 'es', null, 'ru' - ); - - public static $regions = array( - 'us', 'eu', 'kr', 'tw', 'cn' - ); - - # todo (high): find a sensible way to write data here on setup - private static $gtCombatRatings = array( - 12 => 1.5, 13 => 13.8, 14 => 13.8, 15 => 5, 16 => 10, 17 => 10, 18 => 8, 19 => 14, 20 => 14, - 21 => 14, 22 => 10, 23 => 10, 24 => 8, 25 => 0, 26 => 0, 27 => 0, 28 => 10, 29 => 10, - 30 => 10, 31 => 10, 32 => 14, 33 => 0, 34 => 0, 35 => 28.75, 36 => 10, 37 => 2.5, 44 => 4.268292513760655 - ); - - public static $itemFilter = array( - 20 => 'str', 21 => 'agi', 23 => 'int', 22 => 'sta', 24 => 'spi', 25 => 'arcres', 26 => 'firres', 27 => 'natres', - 28 => 'frores', 29 => 'shares', 30 => 'holres', 37 => 'mleatkpwr', 32 => 'dps', 35 => 'damagetype', 33 => 'dmgmin1', 34 => 'dmgmax1', - 36 => 'speed', 38 => 'rgdatkpwr', 39 => 'rgdhitrtng', 40 => 'rgdcritstrkrtng', 41 => 'armor', 44 => 'blockrtng', 43 => 'block', 42 => 'defrtng', - 45 => 'dodgertng', 46 => 'parryrtng', 48 => 'splhitrtng', 49 => 'splcritstrkrtng', 50 => 'splheal', 51 => 'spldmg', 52 => 'arcsplpwr', 53 => 'firsplpwr', - 54 => 'frosplpwr', 55 => 'holsplpwr', 56 => 'natsplpwr', 60 => 'healthrgn', 61 => 'manargn', 57 => 'shasplpwr', 77 => 'atkpwr', 78 => 'mlehastertng', - 79 => 'resirtng', 84 => 'mlecritstrkrtng', 94 => 'splpen', 95 => 'mlehitrtng', 96 => 'critstrkrtng', 97 => 'feratkpwr', 100 => 'nsockets', 101 => 'rgdhastertng', - 102 => 'splhastertng', 103 => 'hastertng', 114 => 'armorpenrtng', 115 => 'health', 116 => 'mana', 117 => 'exprtng', 119 => 'hitrtng', 123 => 'splpwr', - 134 => 'mledps', 135 => 'mledmgmin', 136 => 'mledmgmax', 137 => 'mlespeed', 138 => 'rgddps', 139 => 'rgddmgmin', 140 => 'rgddmgmax', 141 => 'rgdspeed' + public static $regions = array( + 'us', 'eu', 'kr', 'tw', 'cn', 'dev' ); public static $ssdMaskFields = array( @@ -459,23 +69,11 @@ abstract class Util 'clothChestArmor', 'leatherChestArmor', 'mailChestArmor', 'plateChestArmor' ); - public static $weightScales = array( - 'agi', 'int', 'sta', 'spi', 'str', 'health', 'mana', 'healthrgn', 'manargn', - 'armor', 'blockrtng', 'block', 'defrtng', 'dodgertng', 'parryrtng', 'resirtng', - 'atkpwr', 'feratkpwr', 'armorpenrtng', 'critstrkrtng', 'exprtng', 'hastertng', 'hitrtng', 'splpen', - 'splpwr', 'arcsplpwr', 'firsplpwr', 'frosplpwr', 'holsplpwr', 'natsplpwr', 'shasplpwr', - 'dmg', 'mledps', 'rgddps', 'mledmgmin', 'rgddmgmin', 'mledmgmax', 'rgddmgmax', 'mlespeed', 'rgdspeed', - 'arcres', 'firres', 'frores', 'holres', 'natres', 'shares', - 'mleatkpwr', 'mlecritstrkrtng', 'mlehastertng', 'mlehitrtng', 'rgdatkpwr', 'rgdcritstrkrtng', 'rgdhastertng', 'rgdhitrtng', - 'splcritstrkrtng', 'splhastertng', 'splhitrtng', 'spldmg', 'splheal', - 'nsockets' - ); - public static $dateFormatInternal = "Y/m/d H:i:s"; public static $changeLevelString = '%s'; - public static $setRatingLevelString = '%s'; + public static $lvTabNoteString = '%s'; public static $filterResultString = '$$WH.sprintf(LANG.lvnote_filterresults, \'%s\')'; public static $tryFilteringString = '$$WH.sprintf(%s, %s, %s) + LANG.dash + LANG.lvnote_tryfiltering.replace(\'\', \'\')'; @@ -486,193 +84,65 @@ abstract class Util public static $mapSelectorString = '%s (%d)'; - public static $guideratingString = " $(document).ready(function() {\n $('#guiderating').append(GetStars(%.10F, %s, %u, %u));\n });"; - - public static $expansionString = array( // 3 & 4 unused .. obviously - null, 'bc', 'wotlk', 'cata', 'mop' - ); - - public static $bgImagePath = array ( - 'tiny' => 'style="background-image: url(%s/images/wow/icons/tiny/%s.gif)"', - 'small' => 'style="background-image: url(%s/images/wow/icons/small/%s.jpg)"', - 'medium' => 'style="background-image: url(%s/images/wow/icons/medium/%s.jpg)"', - 'large' => 'style="background-image: url(%s/images/wow/icons/large/%s.jpg)"', - ); - - public static $configCats = array( // don't mind the ordering ... please? - 1 => 'Site', 'Caching', 'Account', 'Session', 'Site Reputation', 'Google Analytics', 'Profiler', 0 => 'Other' - ); + public static $expansionString = [null, 'bc', 'wotlk']; public static $tcEncoding = '0zMcmVokRsaqbdrfwihuGINALpTjnyxtgevElBCDFHJKOPQSUWXYZ123456789'; - public static $wowheadLink = ''; private static $notes = []; - public static function addNote(int $uGroupMask, string $str) : void + public static function addNote(string $note, int $uGroupMask = U_GROUP_EMPLOYEE, int $level = LOG_LEVEL_ERROR) : void { - self::$notes[] = [$uGroupMask, $str]; + self::$notes[] = [$note, $uGroupMask, $level]; } public static function getNotes() : array { $notes = []; - - foreach (self::$notes as $data) - if (!$data[0] || User::isInGroup($data[0])) - $notes[] = $data[1]; - - return $notes; - } - - private static $execTime = 0.0; - - public static function execTime(bool $set = false) : string - { - if ($set) + $severity = LOG_LEVEL_INFO; + foreach (self::$notes as $k => [$note, $uGroup, $level]) { - self::$execTime = microTime(true); - return ''; + if ($uGroup && !User::isInGroup($uGroup)) + continue; + + if ($level < $severity) + $severity = $level; + + $notes[] = $note; + unset(self::$notes[$k]); } - if (!self::$execTime) - return ''; - - $newTime = microTime(true); - $tDiff = $newTime - self::$execTime; - self::$execTime = $newTime; - - return self::formatTime($tDiff * 1000, true); + return [$notes, $severity]; } public static function formatMoney(int $qty) : string { - $money = ''; + if ($qty <= 0) + return ''; - if ($qty >= 10000) - { - $g = floor($qty / 10000); - $money .= ''.$g.' '; - $qty -= $g * 10000; - } + $parts = []; - if ($qty >= 100) - { - $s = floor($qty / 100); - $money .= ''.$s.' '; - $qty -= $s * 100; - } + if ($g = intdiv($qty, 10000)) + $parts[] = ''.$g.''; - if ($qty > 0) - $money .= ''.$qty.''; + if ($s = intdiv($qty % 10000, 100)) + $parts[] = ''.$s.''; - return $money; + if ($c = ($qty % 100)) + $parts[] = ''.$c.''; + + return implode(' ', $parts); } - private static function parseTime(int $msec) : array + // pageTexts, questTexts and mails + public static function parseHtmlText(string|array $text, bool $markdown = false) : string|array { - $time = [0, 0, 0, 0, 0]; - - if ($_ = ($msec % 1000)) - $time[0] = $_; - - $sec = $msec / 1000; - - if ($sec >= 3600 * 24) + if (is_array($text)) { - $time[4] = floor($sec / 3600 / 24); - $sec -= $time[4] * 3600 * 24; + foreach ($text as &$t) + $t = self::parseHtmlText($t, $markdown); + + return $text; } - if ($sec >= 3600) - { - $time[3] = floor($sec / 3600); - $sec -= $time[3] * 3600; - } - - if ($sec >= 60) - { - $time[2] = floor($sec / 60); - $sec -= $time[2] * 60; - } - - if ($sec > 0) - { - $time[1] = (int)$sec; - $sec -= $time[1]; - } - - return $time; - } - - public static function formatTime(int $msec, bool $short = false) : string - { - [$ms, $s, $m, $h, $d] = self::parseTime(abs($msec)); - - if ($short) - { - if ($_ = round($d / 364)) - return $_." ".Lang::timeUnits('ab', 0); - if ($_ = round($d / 30)) - return $_." ".Lang::timeUnits('ab', 1); - if ($_ = round($d / 7)) - return $_." ".Lang::timeUnits('ab', 2); - if ($_ = round($d)) - return $_." ".Lang::timeUnits('ab', 3); - if ($_ = round($h)) - return $_." ".Lang::timeUnits('ab', 4); - if ($_ = round($m)) - return $_." ".Lang::timeUnits('ab', 5); - if ($_ = round($s + $ms / 1000, 2)) - return $_." ".Lang::timeUnits('ab', 6); - if ($ms) - return $ms." ".Lang::timeUnits('ab', 7); - - return '0 '.Lang::timeUnits('ab', 6); - } - else - { - $_ = $d + $h / 24; - if ($_ > 1 && !($_ % 364)) // whole years - return round(($d + $h / 24) / 364, 2)." ".Lang::timeUnits($d / 364 == 1 && !$h ? 'sg' : 'pl', 0); - if ($_ > 1 && !($_ % 30)) // whole month - return round(($d + $h / 24) / 30, 2)." ".Lang::timeUnits($d / 30 == 1 && !$h ? 'sg' : 'pl', 1); - if ($_ > 1 && !($_ % 7)) // whole weeks - return round(($d + $h / 24) / 7, 2)." ".Lang::timeUnits($d / 7 == 1 && !$h ? 'sg' : 'pl', 2); - if ($d) - return round($d + $h / 24, 2)." ".Lang::timeUnits($d == 1 && !$h ? 'sg' : 'pl', 3); - if ($h) - return round($h + $m / 60, 2)." ".Lang::timeUnits($h == 1 && !$m ? 'sg' : 'pl', 4); - if ($m) - return round($m + $s / 60, 2)." ".Lang::timeUnits($m == 1 && !$s ? 'sg' : 'pl', 5); - if ($s) - return round($s + $ms / 1000, 2)." ".Lang::timeUnits($s == 1 && !$ms ? 'sg' : 'pl', 6); - if ($ms) - return $ms." ".Lang::timeUnits($ms == 1 ? 'sg' : 'pl', 7); - - return '0 '.Lang::timeUnits('pl', 6); - } - } - - public static function formatTimeDiff(int $sec) : string - { - $delta = time() - $sec; - - [, $s, $m, $h, $d] = self::parseTime($delta * 1000); - - if ($delta > (1 * MONTH)) // use absolute - return date(Lang::main('dateFmtLong'), $sec); - else if ($delta > (2 * DAY)) // days ago - return Lang::main('timeAgo', [$d . ' ' . Lang::timeUnits('pl', 3)]); - else if ($h) // hours, minutes ago - return Lang::main('timeAgo', [$h . ' ' . Lang::timeUnits('ab', 4) . ' ' . $m . ' ' . Lang::timeUnits('ab', 5)]); - else if ($m) // minutes, seconds ago - return Lang::main('timeAgo', [$m . ' ' . Lang::timeUnits('ab', 5) . ' ' . $m . ' ' . Lang::timeUnits('ab', 6)]); - else // seconds ago - return Lang::main('timeAgo', [$s . ' ' . Lang::timeUnits($s == 1 ? 'sg' : 'pl', 6)]); - } - - // pageText for Books (Item or GO) and questText - public static function parseHtmlText(string $text, bool $markdown = false) : string - { if (stristr($text, '')) // text is basically a html-document with weird linebreak-syntax { $pairs = array( @@ -686,62 +156,55 @@ abstract class Util // html may contain 'Pictures' and FlavorImages and "stuff" $text = preg_replace_callback( '/src="([^"]+)"/i', - function ($m) { return 'src="'.STATIC_URL.'/images/wow/'.strtr($m[1], ['\\' => '/']).'.png"'; }, + function ($m) { return sprintf('src="%s/images/wow/%s.png"', Cfg::get('STATIC_URL'), strtr($m[1], ['\\' => '/'])); }, strtr($text, $pairs) ); } else $text = strtr($text, ["\n" => $markdown ? '[br]' : '
', "\r" => '']); + // escape fake html-ish tags the browser skipsh dishplaying ...! + $text = preg_replace('/<([^\s\/]+)>/iu', '<\1>', $text); + $from = array( - '/\|T([\w]+\\\)*([^\.]+)\.blp:\d+\|t/ui', // images (force size to tiny) |T:|t - '/\|c(\w{6})\w{2}([^\|]+)\|r/ui', // color |c|r - '/\$g\s*([^:;]+)\s*:\s*([^:;]+)\s*(:?[^:;]*);/ui',// directed gender-reference $g::: - '/\$t([^;]+);/ui', // nonsense, that the client apparently ignores - '/\|\d\-?\d?\((\$\w)\)/ui', // and another modifier for something russian |3-6($r) + '/\$g\s*([^:;]*)\s*:\s*([^:;]*)\s*(:?[^:;]*);/ui',// directed gender-reference $g:: + '/\$t([^;]+);/ui', // HK rank. $t:; (maybe male/female if pvp unranked? Gets replaced with current HK rank.) '/<([^\"=\/>]+\s[^\"=\/>]+)>/ui', // emotes (workaround: at least one whitespace and never " or = between brackets) - '/\$(\d+)w/ui', // worldState(?)-ref found on some pageTexts $1234w + '/\$(\d+)w/ui', // worldState(%d)-ref found on some pageTexts $1234w '/\$c/i', // class-ref '/\$r/i', // race-ref '/\$n/i', // name-ref - '/\$b/i', // line break - '/\|n/i' // what .. the fuck .. another type of line terminator? (only in spanish though) + '/\$b/i' // line break ); $toMD = array( - '[icon name=\2]', - '[span color=#\1>\2[/span]', '<\1/\2>', - '', - '\1', + '<'.implode('/', Lang::game('pvpRank', 1)).'>', '<\1>', '[span class=q0>WorldState #\1[/span]', '<'.Lang::game('class').'>', '<'.Lang::game('race').'>', '<'.Lang::main('name').'>', - '[br]', - '' + '[br]' ); $toHTML = array( - '', - '\2', '<\1/\2>', - '', - '\1', + '<'.implode('/', Lang::game('pvpRank', 1)).'>', '<\1>', 'WorldState #\1', '<'.Lang::game('class').'>', '<'.Lang::game('race').'>', '<'.Lang::main('name').'>', - '
', - '' + '
' ); - return preg_replace($from, $markdown ? $toMD : $toHTML, $text); + $text = preg_replace($from, $markdown ? $toMD : $toHTML, $text); + + return Lang::unescapeUISequences($text, $markdown ? Lang::FMT_MARKUP : Lang::FMT_HTML); } - public static function asHex($val) : string + public static function asHex(int $val) : string { $_ = decHex($val); while (fMod(strLen($_), 4)) // in 4-blocks @@ -750,17 +213,20 @@ abstract class Util return '0x'.strToUpper($_); } - public static function asBin($val) : string + public static function asBin(int $val) : string { $_ = decBin($val); while (fMod(strLen($_), 4)) // in 4-blocks $_ = '0'.$_; - return 'b'.strToUpper($_); + return 'b'.$_; } - public static function htmlEscape($data) + public static function htmlEscape(string|array|null $data) : string|array { + if (empty($data)) // null, '', [] and not "0" + return ''; + if (is_array($data)) { foreach ($data as &$v) @@ -769,11 +235,14 @@ abstract class Util return $data; } - return htmlspecialchars($data, ENT_QUOTES, 'utf-8'); + return htmlspecialchars($data, ENT_QUOTES | ENT_DISALLOWED | ENT_HTML5, 'utf-8'); } - public static function jsEscape($data) + public static function jsEscape(string|array|null $data) : string|array { + if (empty($data)) // null, '', [] and not "0" + return ''; + if (is_array($data)) { foreach ($data as &$v) @@ -783,6 +252,7 @@ abstract class Util } return strtr($data, array( + '/' => '\/', '\\' => '\\\\', "'" => "\\'", '"' => '\\"', @@ -791,21 +261,23 @@ abstract class Util )); } - public static function defStatic($data) + public static function defStatic(array|string $data) : array|string { if (is_array($data)) { foreach ($data as &$v) - $v = self::defStatic($v); + if ($v) + $v = self::defStatic($v); return $data; } return strtr($data, array( - ' '' => 'scr"+"ipt>', - 'HOST_URL' => HOST_URL, - 'STATIC_URL' => STATIC_URL + 'HOST_URL' => Cfg::get('HOST_URL'), + 'STATIC_URL' => Cfg::get('STATIC_URL'), + 'NAME' => Cfg::get('NAME'), + 'NAME_SHORT' => Cfg::get('NAME_SHORT'), + 'CONTACT_EMAIL' => Cfg::get('CONTACT_EMAIL') )); } @@ -817,19 +289,19 @@ abstract class Util $silent = true; // default case: selected locale available - if (!empty($data[$field.'_loc'.User::$localeId])) - return $data[$field.'_loc'.User::$localeId]; + if (!empty($data[$field.'_loc'.Lang::getLocale()->value])) + return $data[$field.'_loc'.Lang::getLocale()->value]; // locale not enUS; aowow-type localization available; add brackets if not silent - else if (User::$localeId != LOCALE_EN && !empty($data[$field.'_loc0'])) + else if (Lang::getLocale() != Locale::EN && !empty($data[$field.'_loc0'])) return $silent ? $data[$field.'_loc0'] : '['.$data[$field.'_loc0'].']'; // locale not enUS; TC localization; add brackets if not silent - else if (User::$localeId != LOCALE_EN && !empty($data[$field])) + else if (Lang::getLocale() != Locale::EN && !empty($data[$field])) return $silent ? $data[$field] : '['.$data[$field].']'; // locale enUS; TC localization; return normal - else if (User::$localeId == LOCALE_EN && !empty($data[$field])) + else if (Lang::getLocale() == Locale::EN && !empty($data[$field])) return $data[$field]; // nothing to find; be empty @@ -838,12 +310,13 @@ abstract class Util } // for item and spells - public static function setRatingLevel(int $level, int $type, int $val) : string + public static function setRatingLevel(int $level, int $statId, int $val, bool $interactive = false) : string { - if (in_array($type, [ITEM_MOD_DEFENSE_SKILL_RATING, ITEM_MOD_DODGE_RATING, ITEM_MOD_PARRY_RATING, ITEM_MOD_BLOCK_RATING, ITEM_MOD_RESILIENCE_RATING]) && $level < 34) + if (in_array($statId, [Stat::DEFENSE_RTG, Stat::DODGE_RTG, Stat::PARRY_RTG, Stat::BLOCK_RTG, Stat::RESILIENCE_RTG]) && $level < 34) $level = 34; - if (!isset(self::$gtCombatRatings[$type])) + $factor = Stat::getRatingPctFactor($statId); + if (!$factor) $result = 0; else { @@ -857,91 +330,98 @@ abstract class Util $c = 2 / 52; // do not use localized number format here! - $result = number_format($val / self::$gtCombatRatings[$type] / $c, 2); + $result = number_format($val / $factor / $c, 2); } - if (!in_array($type, array(ITEM_MOD_DEFENSE_SKILL_RATING, ITEM_MOD_EXPERTISE_RATING))) + if (!in_array($statId, [Stat::DEFENSE_RTG, Stat::EXPERTISE_RTG])) $result .= '%'; - return sprintf(Lang::item('ratingString'), ''.$result, ''.$level); + $result = Lang::item('ratingString', [$statId, $result, $level]); + + return $interactive ? sprintf(self::$setRatingLevelString, $level, $statId, $val, $result) : $result; } - public static function powerUseLocale($domain = 'www') + // default ucFirst doesn't convert UTF-8 chars (php 8.4 finally implemented this .. see ya in 2027) + public static function ucFirst(string $str) : string { - foreach (Util::$localeStrings as $k => $v) - { - if (strstr($v, $domain)) - { - User::useLocale($k); - Lang::load(User::$localeString); - return; - } - } - - if ($domain == 'www') - { - User::useLocale(LOCALE_EN); - Lang::load(User::$localeString); - } - } - - // default ucFirst doesn't convert UTF-8 chars - public static function ucFirst($str) - { - $len = mb_strlen($str) - 1; $first = mb_substr($str, 0, 1); - $rest = mb_substr($str, 1, $len); + $rest = mb_substr($str, 1); return mb_strtoupper($first).$rest; } - public static function ucWords($str) + public static function ucWords(string $str) : string { return mb_convert_case($str, MB_CASE_TITLE); } - public static function lower($str) + public static function lower(string $str) : string { return mb_strtolower($str); } - // note: valid integer > 32bit are returned as float - public static function checkNumeric(&$data, $typeCast = NUM_ANY) + public static function strrev(string $str) : string + { + $out = ''; + for ($i = 1, $len = mb_strlen($str); $i <= $len; $i++) + $out .= mb_substr($str, -$i, 1); + + return $out; + } + + // doesn't handle scientific notation .. why would you input 3e3 for 3000..? + public static function checkNumeric(mixed &$data, int $typeCast = NUM_ANY) : bool { if ($data === null) return false; - else if (!is_array($data)) + + if (is_array($data)) { - $rawData = $data; // do not transform strings - - $data = trim($data); - if (preg_match('/^-?\d*,\d+$/', $data)) - $data = strtr($data, ',', '.'); - - if (is_numeric($data)) - { - $data += 0; // becomes float or int - - if ((is_float($data) && $typeCast == NUM_REQ_INT) || - (is_int($data) && $typeCast == NUM_REQ_FLOAT)) - return false; - - if (is_float($data) && $typeCast == NUM_CAST_INT) - $data = intval($data); - - if (is_int($data) && $typeCast == NUM_CAST_FLOAT) - $data = floatval($data); - - return true; - } - - $data = $rawData; - return false; + array_walk($data, function(&$x) use($typeCast) { self::checkNumeric($x, $typeCast); }); + return false; // always false for passed arrays } - array_walk($data, function(&$x) use($typeCast) { self::checkNumeric($x, $typeCast); }); + // already in required state + if ((is_float($data) && $typeCast == NUM_REQ_FLOAT) || + (is_int($data) && $typeCast == NUM_REQ_INT)) + return true; - return false; // always false for passed arrays + // irreconcilable state + if ((!is_int($data) && $typeCast == NUM_REQ_INT) || + (!is_float($data) && $typeCast == NUM_REQ_FLOAT)) + return false; + + $number = $data; // do not transform strings, store state + $nMatches = 0; + + $number = trim($number); + $number = preg_replace('/^(-?\d*)[.,](\d+)$/', '$1.$2', $number, -1, $nMatches); + + // is float string + if ($nMatches) + { + if ($typeCast == NUM_CAST_INT) + $data = intVal($number); + else // NUM_CAST_FLOAT || NUM_ANY + $data = floatVal($number); + + return true; + } + + // is int string (is_numeric can only handle strings in base 10) + if (is_numeric($number) || preg_match('/^0[xb]?\d+/', $number)) + { + $number = intVal($number, 0); // 'base 0' auto-detects base + if ($typeCast == NUM_CAST_FLOAT) + $data = floatVal($number); + else // NUM_CAST_INT || NUM_ANY + $data = $number; + + return true; + } + + // is string string + return false; } public static function arraySumByKey(array &$ref, array ...$adds) : void @@ -954,16 +434,98 @@ abstract class Util foreach ($arr as $k => $v) { if (!isset($ref[$k])) - $ref[$k] = 0; + { + if (is_array($v)) + $ref[$k] = []; + else if (is_numeric($v)) + $ref[$k] = 0; + else + continue; + } - $ref[$k] += $v; + if (is_array($ref[$k]) && is_array($v)) + Util::arraySumByKey($ref[$k], $v); + else if (is_numeric($ref[$k]) && is_numeric($v)) + $ref[$k] += $v; } } } - public static function isValidEmail($email) + public static function createNumRange(int $min, int $max, string $delim = '', ?callable $fn = null) : string { - return preg_match('/^([a-z0-9._-]+)(\+[a-z0-9._-]+)?(@[a-z0-9.-]+\.[a-z]{2,4})$/i', $email); + if (!$min && !$max) + return ''; + + $fn ??= fn($x) => $x; + $_min = $fn($min); + $_max = $fn($max); + + return $max > $min ? $_min . ($delim ?: Lang::game('valueDelim')) . $_max : $_min; + } + + public static function validateLogin(?string $val) : string + { + if ($_ = self::validateEmail($val)) + return $_; + if ($_ = self::validateUsername($val)) + return $_; + + return ''; + } + + public static function validateUsername(?string $name, ?int &$errCode = 0) : string + { + if (is_null($name) || $name === '') + return ''; + + $errCode = 0; + $nameMatch = []; + [$min, $max, $pattern] = match(Cfg::get('ACC_AUTH_MODE')) + { + AUTH_MODE_SELF => [4, 16, '/^[a-z0-9]{4,16}$/i'], + AUTH_MODE_REALM => [3, 32, '/^[^[:cntrl:]]+$/'],// i don't think TC has character requirements on the login..? + default => [0, 0, '/^[^[:cntrl:]]+$/'] // external case with unknown requirements + }; + + if (($min && mb_strlen($name) < $min) || ($max && mb_strlen($name) > $max)) + $errCode = 1; + else if ($pattern && !preg_match($pattern, trim(urldecode($name)), $nameMatch)) + $errCode = 2; + + return $errCode ? '' : ($nameMatch[0] ?: $name); + } + + public static function validatePassword(?string $pass, ?int &$errCode = 0) : string + { + if (is_null($pass) || $pass === '') + return ''; + + $errCode = 0; + $passMatch = ''; + [$min, $max, $pattern] = match(Cfg::get('ACC_AUTH_MODE')) + { + AUTH_MODE_SELF => [6, 0, '/^[^[:cntrl:]]+$/'], + AUTH_MODE_REALM => [0, 0, '/^[^[:cntrl:]]+$/'], + default => [0, 0, '/^[^[:cntrl:]]+$/'] + }; + + if (($min && mb_strlen($pass) < $min) || ($max && mb_strlen($pass) > $max)) + $errCode = 1; + else if ($pattern && !preg_match($pattern, $pass, $passMatch)) + $errCode = 2; + + return $errCode ? '' : ($passMatch[0] ?: $pass); + } + + public static function validateEmail(?string $email) : string + { + if (is_null($email) || $email === '') + return ''; + + if (preg_match('/^([a-z0-9._-]+)(\+[a-z0-9._-]+)?(@[a-z0-9.-]+\.[a-z]{2,4})$/i', urldecode(trim($email)), $m)) + return $m[0]; + + return ''; } public static function loadStaticFile($file, &$result, $localized = false) @@ -971,8 +533,8 @@ abstract class Util $success = true; if ($localized) { - if (file_exists('datasets/'.User::$localeString.'/'.$file)) - $result .= file_get_contents('datasets/'.User::$localeString.'/'.$file); + if (file_exists('datasets/'.Lang::getLocale()->json().'/'.$file)) + $result .= file_get_contents('datasets/'.Lang::getLocale()->json().'/'.$file); else if (file_exists('datasets/enus/'.$file)) $result .= file_get_contents('datasets/enus/'.$file); else @@ -989,7 +551,8 @@ abstract class Util return $success; } - public static function createHash($length = 40) // just some random numbers for unsafe identifictaion purpose + // just some random numbers for unsafe identification purpose + public static function createHash(int $length = 40) : string { static $seed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; $hash = ''; @@ -1040,26 +603,26 @@ abstract class Util switch ($action) { case SITEREP_ACTION_REGISTER: - $x['amount'] = CFG_REP_REWARD_REGISTER; + $x['amount'] = Cfg::get('REP_REWARD_REGISTER'); break; case SITEREP_ACTION_DAILYVISIT: $x['sourceA'] = time(); - $x['amount'] = CFG_REP_REWARD_DAILYVISIT; + $x['amount'] = Cfg::get('REP_REWARD_DAILYVISIT'); break; case SITEREP_ACTION_COMMENT: if (empty($miscData['id'])) return false; $x['sourceA'] = $miscData['id']; // commentId - $x['amount'] = CFG_REP_REWARD_COMMENT; + $x['amount'] = Cfg::get('REP_REWARD_COMMENT'); break; case SITEREP_ACTION_UPVOTED: case SITEREP_ACTION_DOWNVOTED: if (empty($miscData['id']) || empty($miscData['voterId'])) return false; - DB::Aowow()->query( // delete old votes the user has cast - 'DELETE FROM ?_account_reputation WHERE sourceA = ?d AND sourceB = ?d AND userId = ?d AND action IN (?a)', + DB::Aowow()->qry( // delete old votes the user has cast + 'DELETE FROM ::account_reputation WHERE sourceA = %i AND sourceB = %i AND userId = %i AND action IN %in', $miscData['id'], $miscData['voterId'], $user, @@ -1068,15 +631,15 @@ abstract class Util $x['sourceA'] = $miscData['id']; // commentId $x['sourceB'] = $miscData['voterId']; - $x['amount'] = $action == SITEREP_ACTION_UPVOTED ? CFG_REP_REWARD_UPVOTED : CFG_REP_REWARD_DOWNVOTED; + $x['amount'] = $action == SITEREP_ACTION_UPVOTED ? Cfg::get('REP_REWARD_UPVOTED') : Cfg::get('REP_REWARD_DOWNVOTED'); break; - case SITEREP_ACTION_UPLOAD: + case SITEREP_ACTION_SUBMIT_SCREENSHOT: + case SITEREP_ACTION_SUGGEST_VIDEO: if (empty($miscData['id']) || empty($miscData['what'])) return false; $x['sourceA'] = $miscData['id']; // screenshotId or videoId - $x['sourceB'] = $miscData['what']; // screenshot:1 or video:NYD - $x['amount'] = CFG_REP_REWARD_UPLOAD; + $x['amount'] = $action == SITEREP_ACTION_SUBMIT_SCREENSHOT ? Cfg::get('REP_REWARD_SUBMIT_SCREENSHOT') : Cfg::get('REP_REWARD_SUGGEST_VIDEO'); break; case SITEREP_ACTION_GOOD_REPORT: // NYI case SITEREP_ACTION_BAD_REPORT: @@ -1084,14 +647,14 @@ abstract class Util return false; $x['sourceA'] = $miscData['id']; - $x['amount'] = $action == SITEREP_ACTION_GOOD_REPORT ? CFG_REP_REWARD_GOOD_REPORT : CFG_REP_REWARD_BAD_REPORT; + $x['amount'] = $action == SITEREP_ACTION_GOOD_REPORT ? Cfg::get('REP_REWARD_GOOD_REPORT') : Cfg::get('REP_REWARD_BAD_REPORT'); break; case SITEREP_ACTION_ARTICLE: if (empty($miscData['id'])) // guideId return false; $x['sourceA'] = $miscData['id']; - $x['amount'] = CFG_REP_REWARD_ARTICLE; + $x['amount'] = Cfg::get('REP_REWARD_ARTICLE'); break; case SITEREP_ACTION_USER_WARNED: // NYI case SITEREP_ACTION_USER_SUSPENDED: @@ -1099,231 +662,48 @@ abstract class Util return false; $x['sourceA'] = $miscData['id']; - $x['amount'] = $action == SITEREP_ACTION_USER_WARNED ? CFG_REP_REWARD_USER_WARNED : CFG_REP_REWARD_USER_SUSPENDED; + $x['amount'] = $action == SITEREP_ACTION_USER_WARNED ? Cfg::get('REP_REWARD_USER_WARNED') : Cfg::get('REP_REWARD_USER_SUSPENDED'); break; } - $x = array_merge($x, array( + $x += array( 'userId' => $user, 'action' => $action, - 'date' => !empty($miscData['date']) ? $miscData['date'] : time() - )); - - return DB::Aowow()->query('INSERT IGNORE INTO ?_account_reputation (?#) VALUES (?a)', array_keys($x), array_values($x)); - } - - public static function getServerConditions($srcType, $srcGroup = null, $srcEntry = null) - { - if (!$srcGroup && !$srcEntry) - return []; - - $result = []; - $jsGlobals = []; - - $conditions = DB::World()->select( - 'SELECT SourceTypeOrReferenceId, SourceEntry, SourceGroup, ElseGroup, - ConditionTypeOrReference, ConditionTarget, ConditionValue1, ConditionValue2, ConditionValue3, NegativeCondition - FROM conditions - WHERE SourceTypeOrReferenceId IN (?a) AND ?# = ?d - ORDER BY SourceTypeOrReferenceId, SourceEntry, SourceGroup, ElseGroup ASC', - is_array($srcType) ? $srcType : [$srcType], - $srcGroup ? 'SourceGroup' : 'SourceEntry', - $srcGroup ?: $srcEntry + 'date' => $miscData['date'] ?? time() ); - foreach ($conditions as $c) - { - switch ($c['SourceTypeOrReferenceId']) - { - case CND_SRC_SPELL_CLICK_EVENT: // 18 - case CND_SRC_VEHICLE_SPELL: // 21 - case CND_SRC_NPC_VENDOR: // 23 - $jsGlobals[Type::NPC][] = $c['SourceGroup']; - break; - } - - switch ($c['ConditionTypeOrReference']) - { - case CND_AURA: // 1 - $c['ConditionValue2'] = null; // do not use his param - case CND_SPELL: // 25 - $jsGlobals[Type::SPELL][] = $c['ConditionValue1']; - break; - case CND_ITEM: // 2 - $c['ConditionValue3'] = null; // do not use his param - case CND_ITEM_EQUIPPED: // 3 - $jsGlobals[Type::ITEM][] = $c['ConditionValue1']; - break; - case CND_MAPID: // 22 - break down to area or remap for use with g_zone_categories - switch ($c['ConditionValue1']) - { - case 530: // outland - $c['ConditionValue1'] = 8; - break; - case 571: // northrend - $c['ConditionValue1'] = 10; - break; - case 0: // old world is fine - case 1: - break; - default: // remap for area - $cnd = array( - ['mapId', (int)$c['ConditionValue1']], - ['parentArea', 0], // not child zones - [['cuFlags', CUSTOM_EXCLUDE_FOR_LISTVIEW, '&'], 0], - 1 // only one result - ); - $zone = new ZoneList($cnd); - if (!$zone->error) - { - $jsGlobals[Type::ZONE][] = $zone->getField('id'); - $c['ConditionTypeOrReference'] = CND_ZONEID; - $c['ConditionValue1'] = $zone->getField('id'); - break; - } - else - continue 3; - } - case CND_ZONEID: // 4 - case CND_AREAID: // 23 - $jsGlobals[Type::ZONE][] = $c['ConditionValue1']; - break; - case CND_REPUTATION_RANK: // 5 - $jsGlobals[Type::FACTION][] = $c['ConditionValue1']; - break; - case CND_SKILL: // 7 - $jsGlobals[Type::SKILL][] = $c['ConditionValue1']; - break; - case CND_QUESTREWARDED: // 8 - case CND_QUESTTAKEN: // 9 - case CND_QUEST_NONE: // 14 - case CND_QUEST_COMPLETE: // 28 - $jsGlobals[Type::QUEST][] = $c['ConditionValue1']; - break; - case CND_ACTIVE_EVENT: // 12 - $jsGlobals[Type::WORLDEVENT][] = $c['ConditionValue1']; - break; - case CND_ACHIEVEMENT: // 17 - $jsGlobals[Type::ACHIEVEMENT][] = $c['ConditionValue1']; - break; - case CND_TITLE: // 18 - $jsGlobals[Type::TITLE][] = $c['ConditionValue1']; - break; - case CND_NEAR_CREATURE: // 29 - $jsGlobals[Type::NPC][] = $c['ConditionValue1']; - break; - case CND_NEAR_GAMEOBJECT: // 30 - $jsGlobals[Type::OBJECT][] = $c['ConditionValue1']; - break; - case CND_CLASS: // 15 - for ($i = 0; $i < 11; $i++) - if ($c['ConditionValue1'] & (1 << $i)) - $jsGlobals[Type::CHR_CLASS][] = $i + 1; - break; - case CND_RACE: // 16 - for ($i = 0; $i < 11; $i++) - if ($c['ConditionValue1'] & (1 << $i)) - $jsGlobals[Type::CHR_RACE][] = $i + 1; - break; - case CND_OBJECT_ENTRY: // 31 - if ($c['ConditionValue1'] == 3) - $jsGlobals[Type::NPC][] = $c['ConditionValue2']; - else if ($c['ConditionValue1'] == 5) - $jsGlobals[Type::OBJECT][] = $c['ConditionValue2']; - break; - case CND_TEAM: // 6 - if ($c['ConditionValue1'] == 469) // Alliance - $c['ConditionValue1'] = 1; - else if ($c['ConditionValue1'] == 67) // Horde - $c['ConditionValue1'] = 2; - else - continue 2; - } - - $res = [$c['NegativeCondition'] ? -$c['ConditionTypeOrReference'] : $c['ConditionTypeOrReference']]; - foreach ([1, 2, 3] as $i) - if (($_ = $c['ConditionValue'.$i]) || $c['ConditionTypeOrReference'] = CND_DISTANCE_TO) - $res[] = $_; - - $group = $c['SourceEntry']; - if (!in_array($c['SourceTypeOrReferenceId'], [CND_SRC_CREATURE_TEMPLATE_VEHICLE, CND_SRC_SPELL, CND_SRC_QUEST_ACCEPT, CND_SRC_QUEST_SHOW_MARK, CND_SRC_SPELL_PROC])) - $group = $c['SourceEntry'] . ':' . $c['SourceGroup']; - - $result[$c['SourceTypeOrReferenceId']] [$group] [$c['ElseGroup']] [] = $res; - } - - return [$result, $jsGlobals]; - } - - public static function sendNoCacheHeader() - { - header('Expires: Sat, 01 Jan 2000 01:00:00 GMT'); - header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); - header('Cache-Control: no-store, no-cache, must-revalidate'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); + return DB::Aowow()->qry('INSERT IGNORE INTO ::account_reputation %v', $x); } public static function toJSON($data, $forceFlags = 0) { $flags = $forceFlags ?: (JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE); - if (CFG_DEBUG && !$forceFlags) + if (Cfg::get('DEBUG') && !$forceFlags) $flags |= JSON_PRETTY_PRINT; $json = json_encode($data, $flags); // handle strings prefixed with $ as js-variables // literal: match everything (lazy) between first pair of unescaped double quotes. First character must be $. - $json = preg_replace_callback('/(? str_replace('\"', '"', $m[1]), $json); return $json; } - public static function createSqlBatchInsert(array $data) - { - $nRows = 100; - $nItems = count(reset($data)); - $result = []; - $buff = []; - - if (!count($data)) - return []; - - foreach ($data as $d) - { - if (count($d) != $nItems) - return []; - - $d = array_map(function ($x) { - if ($x === null) - return 'NULL'; - - return DB::Aowow()->escape($x); - }, $d); - - $buff[] = implode(',', $d); - - if (count($buff) >= $nRows) - { - $result[] = '('.implode('),(', $buff).')'; - $buff = []; - } - } - - if ($buff) - $result[] = '('.implode('),(', $buff).')'; - - return $result; - } /*****************/ /* file handling */ /*****************/ - public static function writeFile($file, $content) + public static function writeFile(string $file, string $content) : bool { $success = false; + + $parentDir = mb_substr($file, 0, mb_strrpos($file, '/')); + if (!self::writeDir($parentDir)) + return false; + if ($handle = @fOpen($file, "w")) { if (fWrite($handle, $content)) @@ -1337,25 +717,32 @@ abstract class Util trigger_error('could not create file', E_USER_ERROR); if ($success) - @chmod($file, Util::FILE_ACCESS); + @chmod($file, self::FILE_ACCESS); return $success; } - public static function writeDir($dir) + public static function writeDir(string $dir, bool &$exist = true) : bool { - // remove multiple slashes - $dir = preg_replace('|/+|', '/', $dir); + // remove multiple slashes; trailing slashes + $dir = preg_replace(['/\/+/', '/\/$/'], ['/', ''], $dir) ?: '.'; + $exist = is_dir($dir); - if (is_dir($dir)) + if ($exist) { - if (!is_writable($dir) && !@chmod($dir, Util::FILE_ACCESS)) - trigger_error('cannot write into directory', E_USER_ERROR); + if (fileperms($dir) != self::DIR_ACCESS && !@chmod($dir, self::DIR_ACCESS)) + trigger_error(CLI::bold($dir) . ' may be inaccessible to the web service.', E_USER_WARNING); return is_writable($dir); } - if (@mkdir($dir, Util::FILE_ACCESS, true)) + // apparently chmod can't edit a whole path at once + $path = ''; + foreach(explode('/', $dir) as $segment) + if (is_dir($path .= $segment.'/') && fileperms($path) != self::DIR_ACCESS) + @chmod($path, self::DIR_ACCESS); + + if (@mkdir($dir, self::DIR_ACCESS, true)) return true; trigger_error('could not create directory', E_USER_ERROR); @@ -1367,8 +754,11 @@ abstract class Util /* Good Skill */ /**************/ - public static function getEquipmentScore($itemLevel, $quality, $slot, $nSockets = 0) + public static function getEquipmentScore(int $itemLevel, int $quality, int $slot, int $nSockets = 0) : float { + if ($itemLevel < 0) // can this even happen? + $itemLevel = 0; + $score = $itemLevel; // quality mod @@ -1447,14 +837,14 @@ abstract class Util $score -= $nSockets * self::GEM_SCORE_BASE_BC; } - return round(max(0.0, $score), 4); + return round($score, 4); } - public static function getGemScore($itemLevel, $quality, $profSpec = false, $itemId = 0) + public static function getGemScore(int $itemLevel, int $quality, bool $profSpec = false, int $itemId = 0) : float { // prepare score-lookup if (empty(self::$perfectGems)) - self::$perfectGems = DB::World()->selectCol('SELECT perfectItemType FROM skill_perfect_item_template WHERE requiredSpecialization = ?d', 55534); + self::$perfectGems = DB::World()->selectCol('SELECT perfectItemType FROM skill_perfect_item_template WHERE requiredSpecialization = %i', 55534); // epic - WotLK - increased stats / profession specific (Dragon's Eyes) if ($profSpec) @@ -1494,8 +884,11 @@ abstract class Util return 0.0; } - public static function getEnchantmentScore($itemLevel, $quality, $profSpec = false, $idOverride = 0) + public static function getEnchantmentScore(int $itemLevel, int $quality, bool $profSpec = false, int $idOverride = 0) : float { + if ($itemLevel < 0) // can this even happen? + $itemLevel = 0; + // some hardcoded values, that defy lookups (cheaper but not skillbound profession versions of spell threads, leg armor) if (in_array($idOverride, [3327, 3328, 3872, 3873])) return 20.0; @@ -1504,28 +897,30 @@ abstract class Util return 40.0; // other than the constraints (0 - 20 points; 40 for profession perks), everything in here is guesswork - $score = max(min($itemLevel, 80), 0); + $score = min($itemLevel, 80); switch ($quality) { case ITEM_QUALITY_HEIRLOOM: // because i say so! - $score = 80.0; + $score = 20.0; break; case ITEM_QUALITY_RARE: - $score /= 1.2; + $score /= 4.8; break; case ITEM_QUALITY_UNCOMMON: - $score /= 1.6; + $score /= 6.4; break; case ITEM_QUALITY_NORMAL: - $score /= 2.5; + $score /= 10.0; break; + default: + $score /= 4.0; } - return round(max(0.0, $score / 4), 4); + return round($score, 4); } - public static function fixWeaponScores($class, $talents, $mainHand, $offHand) + public static function fixWeaponScores(int $class, array $talents, array $mainHand, array $offHand) : array { $mh = 1; $oh = 1; @@ -1569,39 +964,13 @@ abstract class Util } return array( - round($mainHand['gearscore'] * $mh), - round($offHand['gearscore'] * $oh) + round(($mainHand['gearscore'] ?? 0) * $mh), + round(($offHand['gearscore'] ?? 0) * $oh) ); } - static function createReport($mode, $reason, $subject, $desc, $userAgent = null, $appName = null, $url = null, $relUrl = null, $email = null) - { - $update = array( - 'userId' => User::$id, - 'createDate' => time(), - 'mode' => $mode, - 'reason' => $reason, - 'subject' => $subject ?: 0, // not set for utility, tools and misc pages - 'ip' => User::$ip, - 'description' => $desc, - 'userAgent' => $userAgent ?: $_SERVER['HTTP_USER_AGENT'], - 'appName' => $appName ?: (get_browser(null, true)['browser'] ?: '') - ); - - if ($url) - $update['url'] = $url; - - if ($relUrl) - $update['relatedurl'] = $relUrl; - - if ($email) - $update['email'] = $email; - - return DB::Aowow()->query('INSERT INTO ?_reports (?#) VALUES (?a)', array_keys($update), array_values($update)); - } - // orientation is 2*M_PI for a full circle, increasing counterclockwise - static function O2Deg($o) + public static function O2Deg($o) { // orientation values can exceed boundaries (for whatever reason) while ($o < 0) @@ -1634,7 +1003,7 @@ abstract class Util return [(int)$deg, $desc]; } - static function mask2bits($bitmask, $offset = 0) + public static function mask2bits(int $bitmask, int $offset = 0) : array { $bits = []; $i = 0; @@ -1650,224 +1019,147 @@ abstract class Util return $bits; } -} -abstract class Type -{ - public const NPC = 1; - public const OBJECT = 2; - public const ITEM = 3; - public const ITEMSET = 4; - public const QUEST = 5; - public const SPELL = 6; - public const ZONE = 7; - public const FACTION = 8; - public const PET = 9; - public const ACHIEVEMENT = 10; - public const TITLE = 11; - public const WORLDEVENT = 12; - public const CHR_CLASS = 13; - public const CHR_RACE = 14; - public const SKILL = 15; - public const STATISTIC = 16; - public const CURRENCY = 17; - // PROJECT = 18; - public const SOUND = 19; - // BUILDING = 20; - // FOLLOWER = 21; - // MISSION_ABILITY = 22; - // MISSION = 23; - // SHIP = 25; - // THREAT = 26; - // RESOURCE = 27; - // CHAMPION = 28; - public const ICON = 29; - // ORDER_ADVANCEMENT = 30; - // FOLLOWER_ALLIANCE = 31; - // FOLLOWER_HORDE = 32; - // SHIP_ALLIANCE = 33; - // SHIP_HORDE = 34; - // CHAMPION_ALLIANCE = 35; - // CHAMPION_HORDE = 36; - // TRANSMOG_ITEM = 37; - // BFA_CHAMPION = 38; - // BFA_CHAMPION_ALLIANCE = 39; - // AFFIX = 40; - // BFA_CHAMPION_HORDE = 41; - // AZERITE_ESSENCE_POWER = 42; - // AZERITE_ESSENCE = 43; - // STORYLINE = 44; - // ADVENTURE_COMBATANT_ABILITY = 46; - // ENCOUNTER = 47; - // COVENANT = 48; - // SOULBIND = 49; - // DI_ITEM = 50; - // GATHERER_SCREENSHOT = 91; - // GATHERER_GUIDE_IMAGE = 98; - public const PROFILE = 100; - // our own things - public const GUILD = 101; - // TRANSMOG_SET = 101; // future conflict inc. - public const ARENA_TEAM = 102; - // OUTFIT = 110; - // GEAR_SET = 111; - // GATHERER_LISTVIEW = 158; - // GATHERER_SURVEY_COVENANTS = 161; - // NEWS_POST = 162; - // BATTLE_PET_ABILITY = 200; - public const GUIDE = 300; // should have been 100, but conflicts with old version of Profile/List - public const USER = 500; - public const EMOTE = 501; - public const ENCHANTMENT = 502; - public const AREATRIGGER = 503; - public const MAIL = 504; - // Blizzard API things - // MOUNT = -1000; - // RECIPE = -1001; - // BATTLE_PET = -1002; - - public const FLAG_NONE = 0x0; - public const FLAG_RANDOM_SEARCHABLE = 0x1; - /* public const FLAG_SEARCHABLE = 0x2 general search? */ - - public const IDX_LIST_OBJ = 0; - public const IDX_FILE_STR = 1; - public const IDX_JSG_TPL = 2; - public const IDX_FLAGS = 3; - - private static /* array */ $data = array( - self::NPC => ['CreatureList', 'npc', 'g_npcs', 0x1], - self::OBJECT => ['GameObjectList', 'object', 'g_objects', 0x1], - self::ITEM => ['ItemList', 'item', 'g_items', 0x1], - self::ITEMSET => ['ItemsetList', 'itemset', 'g_itemsets', 0x1], - self::QUEST => ['QuestList', 'quest', 'g_quests', 0x1], - self::SPELL => ['SpellList', 'spell', 'g_spells', 0x1], - self::ZONE => ['ZoneList', 'zone', 'g_gatheredzones', 0x1], - self::FACTION => ['FactionList', 'faction', 'g_factions', 0x1], - self::PET => ['PetList', 'pet', 'g_pets', 0x1], - self::ACHIEVEMENT => ['AchievementList', 'achievement', 'g_achievements', 0x1], - self::TITLE => ['TitleList', 'title', 'g_titles', 0x1], - self::WORLDEVENT => ['WorldEventList', 'event', 'g_holidays', 0x1], - self::CHR_CLASS => ['CharClassList', 'class', 'g_classes', 0x1], - self::CHR_RACE => ['CharRaceList', 'race', 'g_races', 0x1], - self::SKILL => ['SkillList', 'skill', 'g_skills', 0x1], - self::STATISTIC => ['AchievementList', 'achievement', 'g_achievements', 0x1], // alias for achievements; exists only for Markup - self::CURRENCY => ['CurrencyList', 'currency', 'g_gatheredcurrencies',0x1], - self::SOUND => ['SoundList', 'sound', 'g_sounds', 0x1], - self::ICON => ['IconList', 'icon', 'g_icons', 0x1], - self::GUIDE => ['GuideList', 'guide', '', 0x0], - self::PROFILE => ['ProfileList', '', '', 0x0], // x - not known in javascript - self::GUILD => ['GuildList', '', '', 0x0], // x - self::ARENA_TEAM => ['ArenaTeamList', '', '', 0x0], // x - self::USER => ['UserList', 'user', 'g_users', 0x0], // x - self::EMOTE => ['EmoteList', 'emote', 'g_emotes', 0x1], - self::ENCHANTMENT => ['EnchantmentList', 'enchantment', 'g_enchantments', 0x1], - self::AREATRIGGER => ['AreatriggerList', 'areatrigger', '', 0x0], - self::MAIL => ['MailList', 'mail', '', 0x1] - ); - - - /********************/ - /* Field Operations */ - /********************/ - - public static function newList(int $type, ?array $conditions = []) : ?BaseType + public static function indexBitBlob(string $bitBlob, int $blobSize = 32) : array { - if (!self::exists($type)) - return null; + $indizes = []; + $blocks = explode(' ', trim($bitBlob)); + for ($i = 0; $i < count($blocks); $i++) + for ($j = 0; $j < $blobSize; $j++) + if ($blocks[$i] & (1 << $j)) + $indizes[] = $j + ($i * $blobSize); - return new (self::$data[$type][self::IDX_LIST_OBJ])($conditions); + return $indizes; } - public static function getFileString(int $type) : string + public static function toString(mixed $var) : string { - if (!self::exists($type)) + if (is_array($var)) + return '[' . implode(', ', array_map(self::toString(...), $var)) . ']'; + + if (is_object($var)) + { + // hm, respect object stringability? + // if ($var instanceof Stringable) + // return (string)$var; + + $buff = []; + foreach ($var as $k => $v) + $buff[] = $k.':'.self::toString($v); + + return '{' . implode(', ', $buff) . '}'; + } + + return (string)$var; + } + + public static function nodeAttributes(?array $attribs) : string + { + if (!$attribs) return ''; - return self::$data[$type][self::IDX_FILE_STR]; + return array_reduce(array_keys($attribs), fn($carry, $name) => $carry . match(gettype($attribs[$name])) + { + 'boolean' => ' ' . $attribs[$name] ? $name : '', + 'integer', + 'double' => ' ' . $name . '="' . $attribs[$name] . '"', + 'string' => ' ' . $name . '="' . self::htmlEscape($attribs[$name]) . '"', + 'array' => ' ' . $name . '="' . implode(' ', self::htmlEscape($attribs[$name])) . '"', + default => '' + }, ''); } - public static function getJSGlobalString(int $type) : string + public static function buildPosFixMenu(int $mapId, float $posX, float $posY, int $type, int $guid, int $parentArea = 0, int $parentFloor = 0) : array { - if (!self::exists($type)) - return ''; - - return self::$data[$type][self::IDX_JSG_TPL]; - } - - public static function getJSGlobalTemplate(int $type) : array - { - if (!self::exists($type)) + $points = WorldPosition::toZonePos($mapId, $posX, $posY); + if (!$points || count($points) < 2) return []; - // [key, [data], [extraData]] - return [self::$data[$type][self::IDX_JSG_TPL], [], []]; + $floors = []; + $menu = [[null, "Move Location to..."]]; + foreach ($points as $p) + { + if ($p['multifloor']) + $floors[$p['areaId']][] = $p['floor']; + + if (isset($menu[$p['areaId']])) + continue; + else if ($p['areaId'] == $parentArea) + $menu[$p['areaId']] = [$p['areaId'], '$g_zones['.$p['areaId'].']', '', null, ['class' => 'checked q0']]; + else + $menu[$p['areaId']] = [$p['areaId'], '$g_zones['.$p['areaId'].']', '$spawnposfix.bind(null, '.$type.', '.$guid.', '.$p['areaId'].', 0)', null, null]; + } + + foreach ($floors as $area => $f) + { + $menu[$area][MENU_IDX_URL] = null; + $menu[$area][MENU_IDX_SUB] = []; + if ($menu[$area][MENU_IDX_OPT]) + $menu[$area][MENU_IDX_OPT]['class'] = 'checked'; + + foreach ($f as $n) + { + if ($n == $parentFloor) + $menu[$area][MENU_IDX_SUB][] = [$n, '$g_zone_areas['.$area.']['.($n - 1).']', '', null, ['class' => 'checked q0']]; + else + $menu[$area][MENU_IDX_SUB][] = [$n, '$g_zone_areas['.$area.']['.($n - 1).']', '$spawnposfix.bind(null, '.$type.', '.$guid.', '.$area.', '.$n.')']; + } + } + + return array_values($menu); } - public static function checkClassAttrib(int $type, string $attr, ?int $attrVal = null) : bool + public static function sendMail(string $email, string $tplFile, array $vars = [], int $expiration = 0) : bool { - if (!self::exists($type)) + if (!self::validateEmail($email)) return false; - return isset((self::$data[$type][self::IDX_LIST_OBJ])::$$attr) && ($attrVal === null || ((self::$data[$type][self::IDX_LIST_OBJ])::$$attr & $attrVal)); - } + $template = ''; + if (file_exists('template/mails/'.$tplFile.'_'.User::$preferedLoc->value.'.tpl')) + $template = file_get_contents('template/mails/'.$tplFile.'_'.User::$preferedLoc->value.'.tpl'); + else + { + foreach (Locale::cases() as $l) + { + if (!$l->validate() || !file_exists('template/mails/'.$tplFile.'_'.$l->value.'.tpl')) + continue; - public static function getClassAttrib(int $type, string $attr) : mixed - { - if (!self::exists($type)) - return null; + $template = file_get_contents('template/mails/'.$tplFile.'_'.$l->value.'.tpl'); + break; + } + } - return (self::$data[$type][self::IDX_LIST_OBJ])::$$attr ?? null; - } + if (!$template) + { + trigger_error('Util::SendMail() - mail template not found: '.$tplFile, E_USER_ERROR); + return false; + } - public static function exists(int $type) : bool - { - return !empty(self::$data[$type]); - } + [, $subject, $body] = explode("\n", $template, 3); - public static function getIndexFrom(int $idx, string $match) : int - { - $i = array_search($match, array_column(self::$data, $idx)); - if ($i === false) - return 0; + $body = Util::defStatic($body); - return array_keys(self::$data)[$i]; - } + if ($expiration) + { + $vars += array_fill(0, 9, null); // vsprintf requires all unused indizes to also be set... + $vars[9] = DateTime::formatTimeElapsed($expiration * 1000, 0); + } + if ($vars) + $body = vsprintf($body, $vars); - /*********************/ - /* Column Operations */ - /*********************/ + $subject = Cfg::get('NAME_SHORT').Lang::main('colon') . $subject; + $header = 'From: ' . Cfg::get('CONTACT_EMAIL') . "\n" . + 'Reply-To: ' . Cfg::get('CONTACT_EMAIL') . "\n" . + 'X-Mailer: PHP/' . phpversion(); - public static function getClassesFor(int $flags = 0x0, string $attr = '', ?int $attrVal = null) : array - { - $x = []; - foreach (self::$data as $k => [$o, , , $f]) - if ($o && (!$flags || $flags & $f)) - if (!$attr || self::checkClassAttrib($attr, $attrVal)) - $x[$k] = $o; + if (Cfg::get('DEBUG') >= LOG_LEVEL_INFO) + { + Util::addNote("Redirected from Util::sendMail:\n\nTo: " . $email . "\n\nSubject: " . $subject . "\n\n" . $body, U_GROUP_NONE, LOG_LEVEL_INFO); + return true; + } - return $x; - } - - public static function getFileStringsFor(int $flags = 0x0) : array - { - $x = []; - foreach (self::$data as $k => [, $s, , $f]) - if ($s && (!$flags || $flags & $f)) - $x[$k] = $s; - - return $x; - } - - public static function getJSGTemplatesFor(int $flags = 0x0) : array - { - $x = []; - foreach (self::$data as $k => [, , $a, $f]) - if ($a && (!$flags || $flags & $f)) - $x[$k] = $a; - - return $x; + return mail($email, $subject, $body, $header); } } diff --git a/index.php b/index.php index c119ce0c..6be334f8 100644 --- a/index.php +++ b/index.php @@ -1,167 +1,96 @@ maintenance(); - - -$altClass = ''; -switch ($pageCall) +$pageCall = 'home'; // default to Homepage unless specified otherwise +$pageParam = ''; +parse_str(parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY), $query); +foreach ($query as $page => $param) { - /* called by user */ - case '': // no parameter given -> MainPage - $altClass = 'home'; - case 'home': - case 'admin': - case 'account': // account management [nyi] - case 'achievement': - case 'achievements': - case 'areatrigger': - case 'areatriggers': - case 'arena-team': - case 'arena-teams': - case 'class': - case 'classes': - case 'currency': - case 'currencies': - case 'compare': // tool: item comparison - case 'emote': - case 'emotes': - case 'enchantment': - case 'enchantments': - case 'event': - case 'events': - case 'faction': - case 'factions': - case 'guide': - case 'guides': - case 'guild': - case 'guilds': - case 'icon': - case 'icons': - case 'item': - case 'items': - case 'itemset': - case 'itemsets': - case 'maps': // tool: map listing - case 'mail': - case 'mails': - case 'my-guides': - if ($pageCall == 'my-guides') - $altClass = 'guides'; - case 'npc': - case 'npcs': - case 'object': - case 'objects': - case 'pet': - case 'pets': - case 'petcalc': // tool: pet talent calculator - if ($pageCall == 'petcalc') - $altClass = 'talent'; - case 'profile': // character profiler [nyi] - case 'profiles': // character profile listing [nyi] - case 'profiler': // character profiler main page - case 'quest': - case 'quests': - case 'race': - case 'races': - case 'screenshot': // prepare uploaded screenshots - case 'search': // tool: searches - case 'skill': - case 'skills': - case 'sound': - case 'sounds': - case 'spell': - case 'spells': - case 'talent': // tool: talent calculator - case 'title': - case 'titles': - case 'user': - case 'video': - case 'zone': - case 'zones': - /* called by script */ - case 'data': // tool: dataset-loader - case 'cookie': // lossless cookies and user settings - case 'contactus': - case 'comment': - case 'edit': // guide editor: targeted by QQ fileuploader, detail-page article editor - case 'get-description': // guide editor: shorten fulltext into description - case 'filter': // pre-evaluate filter POST-data; sanitize and forward as GET-data - case 'go-to-comment': // find page the comment is on and forward - case 'locale': // subdomain-workaround, change the language - $cleanName = str_replace(['-', '_'], '', ucFirst($altClass ?: $pageCall)); - try // can it be handled as ajax? - { - $out = ''; - $class = 'Ajax'.$cleanName; - $ajax = new $class(explode('.', $pageParam)); - - if ($ajax->handle($out)) - { - Util::sendNoCacheHeader(); - - if ($ajax->doRedirect) - header('Location: '.$out, true, 302); - else - { - header($ajax->getContentType()); - die($out); - } - } - else - throw new Exception('not handled as ajax'); - } - catch (Exception $e) // no, apparently not.. - { - $class = $cleanName.'Page'; - $classInstance = new $class($pageCall, $pageParam); - - if (is_callable([$classInstance, 'display'])) - $classInstance->display(); - else if (isset($_GET['power'])) - die('$WowheadPower.register(0, '.User::$localeId.', {})'); - else // in conjunction with a proper rewriteRule in .htaccess... - (new GenericPage($pageCall))->error(); - } - - break; - /* other pages */ - case 'whats-new': - case 'searchplugins': - case 'searchbox': - case 'tooltips': - case 'help': - case 'faq': - case 'aboutus': - case 'reputation': - case 'privilege': - case 'privileges': - case 'top-users': - (new MorePage($pageCall, $pageParam))->display(); - break; - case 'latest-additions': - case 'latest-comments': - case 'latest-screenshots': - case 'latest-videos': - case 'unrated-comments': - case 'missing-screenshots': - case 'most-comments': - case 'random': - (new UtilityPage($pageCall, $pageParam))->display(); - break; - default: // unk parameter given -> ErrorPage - if (isset($_GET['power'])) - die('$WowheadPower.register(0, '.User::$localeId.', {})'); - else // in conjunction with a proper rewriteRule in .htaccess... - (new GenericPage($pageCall))->error(); + // could be an array + if (!is_string($param)) + { + $pageCall = ''; // just .. fail break; + } + + // fix page calls - pages like search use the page call directly and expect it as lower case + if (preg_match('/[A-Z]/', $page)) + { + $url = explode('=', $_SERVER['REQUEST_URI'], 2); + $page = Util::lower(array_shift($url)).($url ? '=' . $url[0] : ''); + header('Location: '.$page, true, 302); + exit; + } + + $pageCall = preg_replace('/[^\w\-]/i', '', $page); + $pageParam = $param ?? ''; + break; // only use first k/v-pair to determine page +} + +[$classMod, $file] = match (true) +{ + // is search ajax + isset($_GET['json']) => ['Json', $pageCall . '_json' ], + isset($_GET['opensearch']) => ['Open', $pageCall . '_open' ], + // is powered tooltip + isset($_GET['power']) => ['Power', $pageCall . '_power' ], + // is item data xml dump + isset($_GET['xml']) => ['Xml', $pageCall . '_xml' ], + // is community content feed + isset($_GET['rss']) => ['Rss', $pageCall . '_rss' ], + // is sounds playlist + isset($_GET['playlist']) => ['Playlist', $pageCall . '_playlist'], + // pageParam can be sub page + (bool)preg_match('/^[a-z\-]+$/i', $pageParam) => [Util::ucFirst(strtr($pageParam, ['-' => ''])), Util::lower($pageParam)], + // no pageParam or PageParam is param for BasePage + default => ['Base', $pageCall ] +}; + +// admin=X pages are mixed html and ajax on the same endpoint .. meh +if ($pageCall == 'admin' && isset($_GET['action']) && preg_match('/^[a-z]+$/', $_GET['action'])) +{ + $classMod .= 'Action' . Util::ucFirst($_GET['action']); + $file .= '_' . Util::lower($_GET['action']); +} + +try { + $responder = new \StdClass; + + // 1. try specialized response + if (file_exists('endpoints/'.$pageCall.'/'.$file.'.php') && $pageCall != $file) + { + require_once 'endpoints/'.$pageCall.'/'.$file.'.php'; + + $class = __NAMESPACE__.'\\' . Util::ucFirst(strtr($pageCall, ['-' => ''])).$classMod.'Response'; + $responder = new $class($pageParam); + } + // 2. try generalized response + else if (file_exists('endpoints/'.$pageCall.'/'.$pageCall.'.php')) + { + require_once 'endpoints/'.$pageCall.'/'.$pageCall.'.php'; + + $class = __NAMESPACE__.'\\' . Util::ucFirst(strtr($pageCall, ['-' => ''])).'BaseResponse'; + $responder = new $class($pageParam); + } + // 3. throw .. your hands in the air and give up + if (!is_callable([$responder, 'process'])) + throw new \Exception('request handler '.$pageCall.'::'.$classMod.'('.$pageParam.') not found'); + + $responder->process(); +} +catch (\Exception $e) +{ + if (isset($_GET['json']) || isset($_GET['opensearch']) || isset($_GET['power']) || isset($_GET['xml']) || isset($_GET['rss'])) + (new TextResponse($pageParam))->generate404(); + else + (new TemplateResponse($pageParam))->generateError($pageCall); } ?> diff --git a/localization/datetime.class.php b/localization/datetime.class.php new file mode 100644 index 00000000..98d60c6e --- /dev/null +++ b/localization/datetime.class.php @@ -0,0 +1,222 @@ +getTimestamp() - $timestamp); + + $today = new DateTime(); + $eventDay = new DateTime(time() - $elapsed); + + $todayMidnight = $today->setTime(0, 0); + $eventDayMidnight = $eventDay->setTime(0, 0); + + $delta = $todayMidnight->diff($eventDayMidnight, true)->days; + + if ($elapsed >= 2592000) /* More than a month ago */ + $txt = Lang::main('date_on') . $eventDay->formatDateSimple($withTime); + else if ($delta > 1) + $txt = Lang::main('ddaysago', [$delta]); + else if ($elapsed >= 43200) + { + if ($today->format('j') == $eventDay->format('j')) + $txt = Lang::main('today'); + else + $txt = Lang::main('yesterday'); + + $txt = $eventDay->formatTimeSimple($txt); + } + else /* Less than 12 hours ago */ + $txt = Lang::main('date_ago', [self::formatTimeElapsed($elapsed * 1000)]); + + return $txt; + } + + /** + * Human-readable format of a date. Optionally append time of day. + * + * @param bool $withTime [optional] affixes day time + * @return string a formatted date string. + */ + public function formatDateSimple(bool $withTime = false) : string + { + $txt = ''; + $day = $this->format('d'); + $month = $this->format('m'); + $year = $this->format('Y'); + + if ($year <= 1970) + $txt .= Lang::main('unknowndate_stc'); + else + $txt .= Lang::main('date_simple', [$day, $month, $year]); + + if ($withTime) + $txt = $this->formatTimeSimple($txt); + + return $txt; + } + + /** + * Human-readable format of the time of day. + * + * @param string $txt [optional] text to affeix the day time to + * @param bool $noPrefix [optional] don't use " at " to affix time of day to $txt + * @return string a formatted time of day string. + */ + public function formatTimeSimple(string $txt = '', bool $noPrefix = false) : string + { + $hours = $this->format('G'); + $minutes = $this->format('i'); + + $txt .= ($noPrefix ? ' ' : Lang::main('date_at')); + + if ($hours == 12) + $txt .= Lang::main('noon'); + else if ($hours == 0) + $txt .= Lang::main('midnight'); + else if ($hours > 12) + $txt .= ($hours - 12) . ':' . $minutes . ' ' . Lang::main('pm'); + else + $txt .= $hours . ':' . $minutes . ' ' . Lang::main('am'); + + return $txt; + } + + /** + * Calculate component values from timestamp + * + * @param int $msec time in milliseconds to parse + * @return int[] [msec, sec, min, hr, day] + */ + public static function parse(int $msec) : array + { + $time = [0, 0, 0, 0, 0]; + $msec = abs($msec); + + for ($i = 3; $i < count(self::RANGE); ++$i) + { + if ($msec < self::RANGE[$i]) + continue; + + $time[7 - $i] = intVal($msec / self::RANGE[$i]); + $msec %= self::RANGE[$i]; + } + + return $time; + } + + /** + * Human-readable longform format of a timespan. + * + * @param int $delay time in milliseconds to format + * @return string a formatted time string. If an error occured "n/a" (localized) is returned + */ + public static function formatTimeElapsedFloat(int $delay) : string + { + $delay = abs($delay); + $nbsp = Lang::getLocale()->isLogographic() ? '' : self::NBSP; + + for ($i = 0; $i < count(self::RANGE); ++$i) + { + if ($delay < self::RANGE[$i]) + continue; + + $v = round($delay / self::RANGE[$i], 2); + return $v . $nbsp . Lang::timeUnits($v === 1.0 ? 'sg' : 'pl', $i); + } + + return '0' . $nbsp . Lang::timeUnits('pl', 6); // 0 seconds + } + + /** + * Human-readable format of a timespan. + * + * @param int $delay time in milliseconds to format + * @param int $maxRange [optional] time unit index - 0 (year) ... 7 (milliseconds) + * @return string a formatted time string. If an error occured "n/a" (localized) is returned + */ + public static function formatTimeElapsed(int $delay, int $maxRange = 3) : string + { + if ($maxRange > 7 || $maxRange < 0) + $maxRange = 3; // default: days + + $subunit = [1, 3, 3, -1, 5, -1, 7, -1]; + $delay = max($delay, 1); + + for ($i = $maxRange; $i < count(self::RANGE); ++$i) + { + if ($delay >= self::RANGE[$i]) + { + $i1 = $i; + $v1 = floor($delay / self::RANGE[$i1]); + + if ($subunit[$i1] != -1) + { + $i2 = $subunit[$i1]; + $delay %= self::RANGE[$i1]; + $v2 = floor($delay / self::RANGE[$i2]); + $nbsp = Lang::getLocale()->isLogographic() ? '' : self::NBSP; + + if ($v2 > 0) + return self::OMG($v1, $i1, true) . $nbsp . self::OMG($v2, $i2, true); + } + + return self::OMG($v1, $i1, false); + } + } + + return Lang::main('n_a'); + } + + /** + * internal number formatter + * + * @param int $value unit value + * @param int $unit time unit index 0 (year) ... 7 (milliseconds) + * @param bool $abbrv use abbreviation + * @return string value + unit + */ + private static function OMG(int $value, int $unit, bool $abbrv) : string + { + if ($abbrv && !Lang::timeUnits('ab', $unit)) + $abbrv = false; + + $nbsp = Lang::getLocale()->isLogographic() ? '' : self::NBSP; + + return $value .= $nbsp . match(true) + { + $abbrv => Lang::timeUnits('ab', $unit), + $value == 1 => Lang::timeUnits('sg', $unit), + default => Lang::timeUnits('pl', $unit) + }; + } +} + +?> diff --git a/localization/lang.class.php b/localization/lang.class.php index ffe38855..7579c1de 100644 --- a/localization/lang.class.php +++ b/localization/lang.class.php @@ -1,8 +1,14 @@ 'English', - LOCALE_FR => 'Français', - LOCALE_DE => 'Deutsch', - LOCALE_CN => '简体中文', - LOCALE_ES => 'Español', - LOCALE_RU => 'Русский' - ); + private static ?Locale $locale = null; - public static function load($loc) + public const FMT_RAW = 0; + public const FMT_HTML = 1; + public const FMT_MARKUP = 2; + + public const CONCAT_NONE = 0; + public const CONCAT_AND = 1; + public const CONCAT_OR = 2; + + public static function load(Locale $loc) : void { - if (!file_exists('localization/locale_'.$loc.'.php')) - die('File for localization '.strToUpper($loc).' not found.'); + if (self::$locale == $loc) + return; + + if (!file_exists('localization/locale_'.$loc->json().'.php')) + die('File for locale '.$loc->name.' not found.'); else - require 'localization/locale_'.$loc.'.php'; + require 'localization/locale_'.$loc->json().'.php'; foreach ($lang as $k => $v) self::$$k = $v; // *cough* .. reuse-hacks (because copy-pastaing text for 5 locales sucks) - self::$item['cat'][2] = [self::$item['cat'][2], self::$spell['weaponSubClass']]; + self::$item['cat'][2][1] = self::$spell['weaponSubClass']; self::$item['cat'][2][1][14] .= ' ('.self::$item['cat'][2][0].')'; self::$main['moreTitles']['privilege'] = self::$privileges['_privileges']; + + self::$locale = $loc; } - public static function __callStatic($prop, $args) + public static function getLocale() : Locale + { + return self::$locale; + } + + public static function __callStatic(string $prop, ?array $args = []) : string|array|null + { + $vspfArgs = []; + foreach ($args as $i => $arg) + { + if (!is_array($arg)) + continue; + + $vspfArgs = $arg; + unset($args[$i]); + } + + if (($x = self::exist($prop, ...$args)) !== null) + return self::vspf($x, $vspfArgs); + + $dbt = debug_backtrace()[0]; + $file = explode(DIRECTORY_SEPARATOR, $dbt['file']); + trigger_error('Lang - undefined property Lang::$'.$prop.'[\''.implode('\'][\'', $args).'\'], called in '.array_pop($file).':'.$dbt['line'], E_USER_WARNING); + + return null; + } + + public static function exist(string $prop, string ...$args) : string|array|null { if (!isset(self::$$prop)) - { - $dbt = debug_backtrace()[0]; - $file = explode(DIRECTORY_SEPARATOR, $dbt['file']); - trigger_error('Lang - tried to use undefined property Lang::$'.$prop.', called in '.array_pop($file).':'.$dbt['line'], E_USER_WARNING); return null; - } - $vspfArgs = []; - - $var = self::$$prop; - foreach ($args as $arg) + $ref = self::$$prop; + foreach ($args as $a) { - if (is_array($arg)) - { - $vspfArgs = $arg; - continue; - } - else if (!isset($var[$arg])) - { - $dbt = debug_backtrace()[0]; - $file = explode(DIRECTORY_SEPARATOR, $dbt['file']); - trigger_error('Lang - undefined property Lang::$'.$prop.'[\''.implode('\'][\'', $args).'\'], called in '.array_pop($file).':'.$dbt['line'], E_USER_WARNING); + if (!isset($ref[$a])) return null; - } - $var = $var[$arg]; + $ref = $ref[$a]; } - // meh :x - if ($var === null && $prop == 'spell' && count($args) == 1) - { - if ($args[0] == 'effects') - $var = self::$$prop['unkEffect']; - else if ($args[0] == 'auras') - $var = self::$$prop['unkAura']; - } - - return self::vspf($var, $vspfArgs); + return $ref; } - public static function concat($args, $useAnd = true, $callback = null) + public static function concat(array $args, int $concat = self::CONCAT_AND, ?callable $callback = null) : string { - $b = ''; - $i = 0; - $n = count($args); - foreach ($args as $k => $arg) + $buff = ''; + $callback ??= fn($x) => $x; + + reset($args); + + if (count($args) < 2) + return $callback(current($args), key($args)); + + do { - if (is_callable($callback)) - $b .= $callback($arg, $k); + $item = $callback(current($args), key($args)); + $arg = next($args); + + if ($arg !== false || $concat == self::CONCAT_NONE) + $buff .= ', '.$item; + else if ($concat == self::CONCAT_AND) + $buff .= self::main('and').$item; else - $b .= $arg; - - if ($n > 1 && $i < ($n - 2)) - $b .= ', '; - else if ($n > 1 && $i == $n - 2) - $b .= Lang::main($useAnd ? 'and' : 'or'); - - $i++; + $buff .= self::main('or').$item; } + while ($arg !== false); - return $b; + return substr($buff, 2); } // truncate string after X chars. If X is inside a word truncate behind it. @@ -140,26 +158,24 @@ class Lang // limit whitespaces to one at a time $text = preg_replace('/\s+/', ' ', trim($text)); - if ($len > 0 && mb_strlen($text) > $len) - { - $n = 0; - $b = []; - $parts = explode(' ', $text); - while ($n < $len && $parts) - { - $_ = array_shift($parts); - $n += mb_strlen($_); - $b[] = $_; - } + if ($len <= 0 || mb_strlen($text) <= $len) + return $text; - $text = implode(' ', $b).'…'; + $n = 0; + $b = []; + $parts = explode(' ', $text); + while ($n < $len && $parts) + { + $_ = array_shift($parts); + $n += mb_strlen($_); + $b[] = $_; } - return $text; + return implode(' ', $b).'…'; } // add line breaks to string after X chars. If X is inside a word break behind it. - public static function breakTextClean(string $text, int $len = 30, bool $asHTML = true) : string + public static function breakTextClean(string $text, int $len = 30, int $fmt = self::FMT_HTML) : string { // remove line breaks $text = strtr($text, ["\n" => ' ', "\r" => ' ']); @@ -167,74 +183,81 @@ class Lang // limit whitespaces to one at a time $text = preg_replace('/\s+/', ' ', trim($text)); + if ($len <= 0 || mb_strlen($text) <= $len) + return $text; + $row = []; - if ($len > 0 && mb_strlen($text) > $len) + $i = 0; + $n = 0; + foreach (explode(' ', $text) as $p) { - $i = 0; + $row[$i][] = $p; + $n += (mb_strlen($p) + 1); + + if ($n < $len) + continue; + $n = 0; - $parts = explode(' ', $text); - foreach ($parts as $p) - { - $row[$i][] = $p; - $n += (mb_strlen($p) + 1); - - if ($n < $len) - continue; - - $n = 0; - $i++; - } - foreach ($row as &$r) - $r = implode(' ', $r); + $i++; } + foreach ($row as &$r) + $r = implode(' ', $r); - return implode($asHTML ? '
' : '[br]', $row); + $separator = match ($fmt) + { + self::FMT_HTML => '
', + self::FMT_MARKUP => '[br]', + self::FMT_RAW => "\n", + default => "\n" + }; + + return implode($separator, $row); } - public static function sort($prop, $group, $method = SORT_NATURAL) + public static function sort(string $prop, string $group, int $method = SORT_NATURAL) : void { if (!isset(self::$$prop)) { trigger_error('Lang::sort - tried to use undefined property Lang::$'.$prop, E_USER_WARNING); - return null; + return; } $var = &self::$$prop; if (!isset($var[$group])) { trigger_error('Lang::sort - tried to use undefined property Lang::$'.$prop.'[\''.$group.'\']', E_USER_WARNING); - return null; + return; } asort($var[$group], $method); } // todo: expand - public static function getInfoBoxForFlags($flags) + public static function getInfoBoxForFlags(int $cuFlags) : array { $tmp = []; - if ($flags & CUSTOM_DISABLED) - $tmp[] = '[tooltip name=disabledHint]'.Util::jsEscape(self::main('disabledHint')).'[/tooltip][span class=tip tooltip=disabledHint]'.Util::jsEscape(self::main('disabled')).'[/span]'; + if ($cuFlags & CUSTOM_DISABLED) + $tmp[] = '[tooltip name=disabledHint]'.self::main('disabledHint').'[/tooltip][span class=tip tooltip=disabledHint]'.self::main('disabled').'[/span]'; - if ($flags & CUSTOM_SERVERSIDE) - $tmp[] = '[tooltip name=serversideHint]'.Util::jsEscape(self::main('serversideHint')).'[/tooltip][span class=tip tooltip=serversideHint]'.Util::jsEscape(self::main('serverside')).'[/span]'; + if ($cuFlags & CUSTOM_SERVERSIDE) + $tmp[] = '[tooltip name=serversideHint]'.self::main('serversideHint').'[/tooltip][span class=tip tooltip=serversideHint]'.self::main('serverside').'[/span]'; - if ($flags & CUSTOM_UNAVAILABLE) + if ($cuFlags & CUSTOM_UNAVAILABLE) $tmp[] = self::main('unavailable'); - if ($flags & CUSTOM_EXCLUDE_FOR_LISTVIEW && User::isInGroup(U_GROUP_STAFF)) + if ($cuFlags & CUSTOM_EXCLUDE_FOR_LISTVIEW && User::isInGroup(U_GROUP_STAFF)) $tmp[] = '[tooltip name=excludedHint]This entry is excluded from lists and is not searchable.[/tooltip][span tooltip=excludedHint class="tip q10"]Hidden[/span]'; return $tmp; } - public static function getLocks(int $lockId, ?array &$ids = [], bool $interactive = false, bool $asHTML = false) : array + public static function getLocks(int $lockId, ?array &$ids = [], bool $interactive = false, int $fmt = self::FMT_HTML) : array { $locks = []; $ids = []; - $lock = DB::Aowow()->selectRow('SELECT * FROM ?_lock WHERE id = ?d', $lockId); + $lock = DB::Aowow()->selectRow('SELECT * FROM ::lock WHERE `id` = %i', $lockId); if (!$lock) return $locks; @@ -244,69 +267,87 @@ class Lang $rank = $lock['reqSkill'.$i]; $name = ''; - if ($lock['type'.$i] == LOCK_TYPE_ITEM) + switch ($lock['type'.$i]) { - $name = ItemList::getName($prop); - if (!$name) - continue; + case LOCK_TYPE_ITEM: + $name = ItemList::getName($prop); + if (!$name) + continue 2; - if ($interactive && $asHTML) - $name = ''.$name.''; - else if ($interactive && !$asHTML) - { - $name = '[item='.$prop.']'; - $ids[Type::ITEM][] = $prop; - } - } - else if ($lock['type'.$i] == LOCK_TYPE_SKILL) - { - $name = self::spell('lockType', $prop); - if (!$name) - continue; - - // skills - if (in_array($prop, [1, 2, 3, 20])) - { - $skills = array( - 1 => SKILL_LOCKPICKING, - 2 => SKILL_HERBALISM, - 3 => SKILL_MINING, - 20 => SKILL_INSCRIPTION - ); - - if ($interactive && $asHTML) - $name = ''.$name.''; - else if ($interactive && !$asHTML) + if ($fmt == self::FMT_HTML) + $name = $interactive ? ''.$name.'' : ''.$name.''; + else if ($interactive && $fmt == self::FMT_MARKUP) { - $name = '[skill='.$skills[$prop].']'; - $ids[Type::SKILL][] = $skills[$prop]; + $name = '[item='.$prop.']'; + $ids[Type::ITEM][] = $prop; } - if ($rank > 0) - $name .= ' ('.$rank.')'; - } - // Lockpicking - else if ($prop == 4) - { - if ($interactive && $asHTML) - $name = ''.$name.''; - else if ($interactive && !$asHTML) + break; + case LOCK_TYPE_SKILL: + $name = self::spell('lockType', $prop); + if (!$name) + continue 2; + + // skills + if (in_array($prop, [1, 2, 3, 20])) { - $name = '[spell=1842]'; - $ids[Type::SPELL][] = 1842; + $skills = array( + 1 => SKILL_LOCKPICKING, + 2 => SKILL_HERBALISM, + 3 => SKILL_MINING, + 20 => SKILL_INSCRIPTION + ); + + if ($fmt == self::FMT_HTML) + $name = $interactive ? ''.$name.'' : ''.$name.''; + else if ($interactive && $fmt == self::FMT_MARKUP) + { + $name = '[skill='.$skills[$prop].']'; + $ids[Type::SKILL][] = $skills[$prop]; + } + else + $name = SkillList::getName($prop); + + if ($rank > 0) + $name .= ' ('.$rank.')'; } - } - // exclude unusual stuff - else if (User::isInGroup(U_GROUP_STAFF)) - { - if ($rank > 0) - $name .= ' ('.$rank.')'; - } - else - continue; + // Lockpicking + else if ($prop == 4) + { + if ($fmt == self::FMT_HTML) + $name = $interactive ? ''.$name.'' : ''.$name.''; + else if ($interactive && $fmt == self::FMT_MARKUP) + { + $name = '[spell=1842]'; + $ids[Type::SPELL][] = 1842; + } + } + // exclude unusual stuff + else if (User::isInGroup(U_GROUP_STAFF)) + { + if ($rank > 0) + $name .= ' ('.$rank.')'; + } + else + continue 2; + break; + case LOCK_TYPE_SPELL: + $name = SpellList::getName($prop); + if (!$name) + continue 2; + + if ($fmt == self::FMT_HTML) + $name = $interactive ? ''.$name.'' : ''.$name.''; + else if ($interactive && $fmt == self::FMT_MARKUP) + { + $name = '[spell='.$prop.']'; + $ids[Type::SPELL][] = $prop; + } + + break; + default: + continue 2; } - else - continue; $locks[$lock['type'.$i] == LOCK_TYPE_ITEM ? $prop : -$prop] = $name; } @@ -314,14 +355,12 @@ class Lang return $locks; } - public static function getReputationLevelForPoints($pts) + public static function getReputationLevelForPoints(int $pts) : string { - $_ = Game::getReputationLevelForPoints($pts); - - return self::game('rep', $_); + return self::game('rep', Game::getReputationLevelForPoints($pts)); } - public static function getRequiredItems($class, $mask, $short = true) + public static function getRequiredItems(int $class, int $mask, bool $short = true) : string { if (!in_array($class, [ITEM_CLASS_MISC, ITEM_CLASS_ARMOR, ITEM_CLASS_WEAPON])) return ''; @@ -363,7 +402,7 @@ class Lang return implode(', ', $tmp); } - public static function getStances($stanceMask) + public static function getStances(int $stanceMask) : string { $stanceMask &= 0xFF37F6FF; // clamp to available stances/forms.. @@ -383,12 +422,18 @@ class Lang return implode(', ', $tmp); } - public static function getMagicSchools($schoolMask) + public static function getMagicSchools(int $schoolMask, bool $short = false) : string { $schoolMask &= SPELL_ALL_SCHOOLS; // clamp to available schools.. $tmp = []; $i = 0; + if ($short && $schoolMask == SPELL_ALL_SCHOOLS) + return self::main('all'); + + if ($short && $schoolMask == SPELL_MAGIC_SCHOOLS) + return self::main('all').' ('.self::game('dt', 1).')'; + while ($schoolMask) { if ($schoolMask & (1 << $i)) @@ -402,93 +447,82 @@ class Lang return implode(', ', $tmp); } - public static function getClassString(int $classMask, array &$ids = [], bool $asHTML = true) : string + public static function getClassString(int $classMask, array &$ids = [], int $fmt = self::FMT_HTML) : string { - $classMask &= CLASS_MASK_ALL; // clamp to available classes.. + $classMask &= ChrClass::MASK_ALL; // clamp to available classes.. - if ($classMask == CLASS_MASK_ALL) // available to all classes - return false; + if (!$classMask || $classMask == ChrClass::MASK_ALL)// available to all classes + return ''; - $tmp = []; - $i = 1; - $base = $asHTML ? '%2$s' : '[class=%d]'; - $br = $asHTML ? '' : '[br]'; - - while ($classMask) + [$base, $br] = match ($fmt) { - if ($classMask & (1 << ($i - 1))) - { - $tmp[$i] = (!fMod(count($tmp) + 1, 3) ? $br : null).sprintf($base, $i, self::game('cl', $i)); - $classMask &= ~(1 << ($i - 1)); - } - $i++; - } + self::FMT_HTML => ['%2$s', ''], + self::FMT_MARKUP => ['[class=%1$d]', '[br]'], + self::FMT_RAW => ['%2$s', ''], + default => ['%2$s', ''] + }; + + $tmp = []; + foreach (ChrClass::fromMask($classMask) as $c) + $tmp[$c] = (!fMod(count($tmp) + 1, 3) ? $br : '').sprintf($base, $c, self::game('cl', $c)); $ids = array_keys($tmp); return implode(', ', $tmp); } - public static function getRaceString(int $raceMask, array &$ids = [], bool $asHTML = true) : string + public static function getRaceString(int $raceMask, array &$ids = [], int $fmt = self::FMT_HTML) : string { - $raceMask &= RACE_MASK_ALL; // clamp to available races.. + $raceMask &= ChrRace::MASK_ALL; // clamp to available races.. - if ($raceMask == RACE_MASK_ALL) // available to all races (we don't display 'both factions') - return false; + if (!$raceMask || $raceMask == ChrRace::MASK_ALL) // available to all races (we don't display 'both factions') + return ''; - if (!$raceMask) - return false; - - $tmp = []; - $i = 1; - $base = $asHTML ? '%s' : '[race=%d]'; - $br = $asHTML ? '' : '[br]'; - - if ($raceMask == RACE_MASK_HORDE) + if ($raceMask == ChrRace::MASK_HORDE) return self::game('ra', -2); - if ($raceMask == RACE_MASK_ALLIANCE) + if ($raceMask == ChrRace::MASK_ALLIANCE) return self::game('ra', -1); - while ($raceMask) + [$base, $br] = match ($fmt) { - if ($raceMask & (1 << ($i - 1))) - { - $tmp[$i] = (!fMod(count($tmp) + 1, 3) ? $br : null).sprintf($base, $i, self::game('ra', $i)); - $raceMask &= ~(1 << ($i - 1)); - } - $i++; - } + self::FMT_HTML => ['%2$s', ''], + self::FMT_MARKUP => ['[race=%1$d]', '[br]'], + self::FMT_RAW => ['%2$s', ''], + default => ['%2$s', ''] + }; + + $tmp = []; + foreach (ChrRace::fromMask($raceMask) as $r) + $tmp[$r] = (!fMod(count($tmp) + 1, 3) ? $br : '').sprintf($base, $r, self::game('ra', $r)); $ids = array_keys($tmp); return implode(', ', $tmp); } - public static function formatSkillBreakpoints(array $bp, bool $html = false) : string + public static function formatSkillBreakpoints(array $bp, int $fmt = self::FMT_MARKUP) : string { - $tmp = Lang::game('difficulty').Lang::main('colon'); + $tmp = self::game('difficulty'); + + $base = match ($fmt) + { + self::FMT_HTML => '%2$s ', + self::FMT_MARKUP => '[color=r%1$d]%2$s[/color] ', + self::FMT_RAW => '%2$s ', + default => '%2$s ' + }; for ($i = 0; $i < 4; $i++) if (!empty($bp[$i])) - $tmp .= $html ? ''.$bp[$i].' ' : '[color=r'.($i + 1).']'.$bp[$i].'[/color] '; + $tmp .= sprintf($base, $i + 1, $bp[$i]); return trim($tmp); } - public static function nf($number, $decimals = 0, $no1k = false) + public static function nf(float $number, int $decimals = 0, bool $no1k = false) : string { - // [decimal, thousand] - $seps = array( - LOCALE_EN => [',', '.'], - LOCALE_FR => [' ', ','], - LOCALE_DE => ['.', ','], - LOCALE_CN => [',', '.'], - LOCALE_ES => ['.', ','], - LOCALE_RU => [' ', ','] - ); - - return number_format($number, $decimals, $seps[User::$localeId][1], $no1k ? '' : $seps[User::$localeId][0]); + return number_format($number, $decimals, self::main('nfSeparators', 1), $no1k ? '' : self::main('nfSeparators', 0)); } public static function typeName(int $type) : string @@ -496,99 +530,279 @@ class Lang return Util::ucFirst(self::game(Type::getFileString($type))); } + public static function formatTime(int $msec, string $prop = 'game', string $src = 'timeAbbrev', bool $concat = false) : string + { + if ($msec < 0) + $msec = 0; - private static function vspf($var, $args) + $time = DateTime::parse($msec); // [$ms, $s, $m, $h, $d] + $mult = [0, 1000, 60, 60, 24]; + $total = 0; + $ref = []; + $result = []; + + if (is_array(self::$$prop[$src])) + $ref = &self::$$prop[$src]; + else + { + trigger_error('Lang::formatTime - tried to access undefined property Lang::$'.$prop, E_USER_WARNING); + return ''; + } + + if (!$msec) + return self::vspf($ref[0], [0]); + + for ($i = 4; $i > 0; $i--) + { + $total += $time[$i]; + if (isset($ref[$i]) && ($total || ($i == 1 && !$result))) + { + if (!$concat) + return self::vspf($ref[$i], [$total + ($time[$i-1] ?? 0) / $mult[$i]]); + + $result[] = self::vspf($ref[$i], [$total]); + $total = 0; + } + else + $total *= $mult[$i]; + } + + return implode(', ', $result); + } + + private static function vspf(null|array|string $var, array $args = []) : null|array|string { if (is_array($var)) { foreach ($var as &$v) - $v == self::vspf($v, $args); + $v = self::vspf($v, $args); return $var; } + if (!$var) // may be null or empty. Handled differently depending on context + return $var; + + $var = Cfg::applyToString($var); + if ($args) $var = vsprintf($var, $args); - // line break - // |n - $var = str_replace('|n', '
', $var); + return self::unescapeUISequences($var); + } - // color - // |c|r - $var = preg_replace('/\|cff([a-f0-9]{6})(.+?)\|r/i', '$2', $var); + /* Quoted from WoWWiki - UI Escape Sequences (https://wowwiki-archive.fandom.com/wiki/UI_escape_sequences) + * number |1singular;plural; + Will choose a word depending on whether the digit preceding it is 0/1 or not (i.e. 1,11,21 return the first string, as will 0,10,40). Note that unlike |4 singular and plural forms are separated by semi-colon. - // icon - // |T:0:0:0:-1|t - not used, skip if found - $var = preg_replace('/\|T[^\|]+\|t/', '', $var); + * |2text + Before vowels outputs d' (with apostrophe) and removes any leading spaces from text, otherwise outputs de (with trailing space) - // hyperlink - // |H|h|h - not used, truncate structure if found - $var = preg_replace('/\|H[^\|]+\|h([^\|]+)\|h/', '$1', $var); + * |3-formid(text) + Displays text declined to the specified form (index ranges from 1 to GetNumDeclensionSets()). - // french preposition : de - // |2 - $var = preg_replace_callback('/\|2\s(\w)/i', function ($m) { - if (in_array(strtolower($m[1]), ['a', 'e', 'h', 'i', 'o', 'u'])) - return "d'".$m[1]; - else - return 'de '.$m[1]; - }, $var); + * number |4singular:plural; -or- number |4singular:plural1:plural2; + Will choose a form based on the number preceding it. More than two forms (separated by colons) may be required by locale 8 (ruRU). + **/ - // russian word cunjugation thingy - // |3-() - $var = preg_replace_callback('/\|3-(\d)\(([^\)]+)\)/i', function ($m) { - switch ($m[0]) + public static function unescapeUISequences(?string $var, int $fmt = -1) : string + { + if (!$var) + return ''; + + if (strpos($var, '|') === false) + return $var; + + // line break |n + $var = preg_replace_callback('/\|n/i', function ($m) use ($fmt) { - case 1: // seen cases - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - default: // passthrough .. unk case - return $m[1]; - } - - }, $var); - - // numeric switch - // |4:[:]; - $var = preg_replace_callback('/([\d\.\,]+)([^\d]*)\|4([^:]*):([^;]*);/i', function ($m) { - $plurals = explode(':', $m[4]); - $result = ''; - - if (count($plurals) == 2) // special case: ruRU - { - switch (substr($m[1], -1)) // check last digit of number + switch ($fmt) { - case 1: - // but not 11 (teen number) - if (!in_array($m[1], [11])) - { - $result = $m[3]; - break; - } - case 2: - case 3: - case 4: - // but not 12, 13, 14 (teen number) [11 is passthrough] - if (!in_array($m[1], [11, 12, 13, 14])) - { - $result = $plurals[0]; - break; - } - break; + case -1: // default Lang::vspf case + case self::FMT_HTML: + return '
'; + case self::FMT_MARKUP: + return '[br]'; + case self::FMT_RAW: default: - $result = $plurals[1]; + return ''; } - } - else - $result = ($m[1] == 1 ? $m[3] : $plurals[0]); + }, $var); - return $m[1].$m[2].$result; - }, $var); + // color |c|r + $var = preg_replace_callback('/\|c([[:xdigit:]]{2})([[:xdigit:]]{6})(.+?)\|r/is', function ($m) use ($fmt) + { + [$_, $a, $rgb, $text] = $m; + + switch ($fmt) + { + case -1: // default Lang::vspf case + case self::FMT_HTML: + return sprintf('%3$s', $rgb, $a, $text); + case self::FMT_MARKUP: + return sprintf('[span color=#%1$s]%3$s[/span]', $rgb, $a, $text); // doesn't support alpha + case self::FMT_RAW: + default: + return $text; + } + }, $var); + + // icon |T:0:0:0:-1|t + $var = preg_replace_callback('/\|T([\w]+\\\)*([^\.:]+)(?:\.[bB][lL][pP])?:([^\|]+)\|t/', function ($m) use ($fmt) + { + /* iconParam - size1, size2, xoffset, yoffset + size1 == 0; size2 omitted: Width = Height = TextHeight (always square!) + size1 > 0; size2 omitted: Width = Height = size1 (always square!) + size1 == 0; size2 == 0 : Width = Height = TextHeight (always square!) + size1 > 0; size2 == 0 : Width = TextHeight; Height = size1 (size1 is height!!!) + size1 == 0; size2 > 0 : Width = size2 * TextHeight; Height = TextHeight (size2 is an aspect ratio and defines width!!!) + size1 > 0; size2 > 0 : Width = size1; Height = size2 + */ + + [$_, $iconPath, $iconName, $iconParam] = $m; + + switch ($fmt) + { + case self::FMT_HTML: + return ''; + case self::FMT_MARKUP: + return '[icon name='.Util::lower($iconName).']'; + case self::FMT_RAW: + default: + return ''; + } + }, $var); + + // hyperlink |H|h|h + $var = preg_replace_callback('/\|H([^:]+):([^\|]+)\|h([^\|]+)\|h/i', function ($m) use ($fmt) + { + /* type Params + |Hchannel channelName, channelname == CHANNEL ? channelNr : null + |Hachievement AchievementID, PlayerGUID, isComplete, Month, Day, Year, criteriaMask1, criteriaMask2, criteriaMask3, criteriaMask4 - 32bit masks of Achievement_criteria.dbc/UIOrder only for achievements that display a todo list + |Hquest QuestID, QuestLevel + |Hitem itemId enchantId gemId1 gemId2 gemId3 gemId4 suffixId uniqueId linkLevel + |Henchant SpellID (from craftwindow) + |Htalent TalentID, TalentRank + |Hspell SpellID, PlayerLevel? + |Htrade SpellID, curSkill, maxSkill, PlayerGUID, base64_encode(known recipes bitmask) + |Hplayer Name + |Hunit GUID ? - combatlog + |Hicon ? "source"|"dest" - combatlog + |Haction ? - combatlog + */ + + [$_, $linkType, $linkVars, $text] = $m; + + $linkVars = explode(':', $linkVars); + + $spfVars = ['', $linkVars[0], $text]; + + switch ($linkType) + { + case 'trade': + case 'enchant': + $linkType = 'spell'; + case 'achievement': // markdown COULD implement completed status + case 'quest': + case 'item': // markdown COULD implement enchantments/gems + case 'spell': + $spfVars[0] = $linkType; + break; + case 'talent': + if ($spell = DB::Aowow()->selectCell('SELECT `spell` FROM ::talents WHERE `id` = %i AND `rank` = %i', $linkVars[0], $linkVars[1])) + { + $spfVars[0] = 'spell'; + $spfVars[1] = $spell; + break; + } + default: + return ''; + } + + switch ($fmt) + { + case self::FMT_HTML: + return sprintf('%s', $spfVars); + case self::FMT_MARKUP: + return sprintf('[%s=%d]', $spfVars); + case self::FMT_RAW: + default: + return sprintf('(%s #%d) %s', $spfVars); + } + }, $var); + + // |1 - digit singular/plural |1; + $var = preg_replace_callback('/(\d+)\s*\|1([^;]+);([^;]+);/is', function ($m) + { + [$_, $num, $singular, $plural] = $m; + + switch ($num[-1]) + { + case 0: + case 1: + return $num . ' ' . $singular; + default: + return $num . ' ' . $plural; + } + }, $var); + + // |2 - frFR preposition: de |2 + $var = preg_replace_callback('/\|2\s?(.)/i', function ($m) + { + [$_, $char] = $m; + + switch (strtolower($char)) + { + case 'h': + if (self::$locale != Locale::FR) + return 'de ' . $char; + case 'a': + case 'e': + case 'i': + case 'o': + case 'u': + return "d'" . $char; + default: + return 'de ' . $char; + } + }, $var); + + // |3 - ruRU declinations |3-() + $var = preg_replace_callback('/\|3-(\d+)\(([^\)]+)\)/iu', function ($m) + { + [$_, $caseIdx, $word] = $m; + + if ($caseIdx > 11 || $caseIdx < 1) // max caseIdx seen in DeclinedWordCases.dbc + return $word; + + if (preg_match('/\P{Cyrillic}/iu', $word)) // not in cyrillic script + return $word; + + if ($declWord = DB::Aowow()->selectCell('SELECT dwc.`word` FROM ::declinedwordcases dwc JOIN ::declinedword dc ON dwc.`wordId` = dc.`id` WHERE dwc.`caseIdx` = %i AND dc.`word` = %s', $caseIdx, $word)) + return $declWord; + + return $word; + }, $var); + + // |4 - numeric switch |4:[:]; + $var = preg_replace_callback('/([\d\.\,]+)([^\d]*)\|4([^:]*):([^:;]+)(?::([^;]+))?;/is', function ($m) + { + [$_, $num, $pad, $singular, $plural1, $plural2] = array_pad($m, 6, null); + + if (self::$locale != Locale::RU || !$plural2) + return $num . $pad . ($num == 1 ? $singular : $plural1); + + // singular - ends in 1, but not teen number + if ($num[-1] == 1 && $num != 11) + return $num . $pad . $singular; + + // genitive singular - ends in 2, 3, 4, but not teen number + if (($num[-1] == 2 && $num != 12) || ($num[-1] == 3 && $num != 13) || ($num[-1] == 4 && $num != 14)) + return $num . $pad . $plural1; + + // genitive plural - everything else + return $num . $pad . $plural2; + }, $var); return $var; } diff --git a/localization/locale_dede.php b/localization/locale_dede.php index e767f205..1ca688b4 100644 --- a/localization/locale_dede.php +++ b/localization/locale_dede.php @@ -1,12 +1,13 @@ \World of Warcraft\Data\deDE\patch-deDE-3.MPQ\Interface\FrameXML\GlobalStrings.lua - like: ITEM_MOD_*, POWER_TYPE_*, ITEM_BIND_*, PVP_RANK_* */ $lang = array( @@ -16,6 +17,7 @@ $lang = array( 'pl' => ["Jahre", "Monate", "Wochen", "Tage", "Stunden", "Minuten", "Sekunden", "Millisekunden"], 'ab' => ["J.", "M.", "W.", "Tag", "Std.", "Min.", "Sek.", "Ms."] ), + 'lang' => ['Englisch', null, 'Französisch', 'Deutsch', 'Chinesisch', null, 'Spanisch', null, 'Russisch'], 'main' => array( 'name' => "Name", 'link' => "Link", @@ -23,45 +25,48 @@ $lang = array( 'jsError' => "Stelle bitte sicher, dass JavaScript aktiviert ist.", 'language' => "Sprache", 'feedback' => "Rückmeldung", - 'numSQL' => "Anzahl an MySQL-Queries", - 'timeSQL' => "Zeit für MySQL-Queries", + 'numSQL' => "Anzahl an SQL-Queries", + 'timeSQL' => "Zeit für SQL-Queries", 'noJScript' => 'Diese Seite macht ausgiebigen Gebrauch von JavaScript.
Bitte aktiviert JavaScript in Eurem Browser.', - 'userProfiles' => "Deine Charaktere", + // 'userProfiles' => "Deine Charaktere", 'pageNotFound' => "Dies %s existiert nicht.", 'gender' => "Geschlecht", 'sex' => [null, "Mann", "Frau"], 'players' => "Spieler", + 'thePlayer' => "Der Spieler", 'quickFacts' => "Kurzübersicht", 'screenshots' => "Screenshots", 'videos' => "Videos", - 'side' => "Seite", + 'side' => "Seite: ", 'related' => "Weiterführende Informationen", 'contribute' => "Beitragen", - // 'replyingTo' => "Antwort zu einem Kommentar von", + // 'replyingTo' => "Antwort zu einem Kommentar von", 'submit' => "Absenden", + 'save' => 'Speichern', 'cancel' => "Abbrechen", 'rewards' => "Belohnungen", 'gains' => "Belohnungen", - 'login' => "Login", + // 'login' => "Login", 'forum' => "Forum", - 'n_a' => "n. v.", - 'siteRep' => "Ruf", + 'siteRep' => "Ruf: ", 'yourRepHistory'=> "Dein Ruf-Verlauf", 'aboutUs' => "Über Aowow", 'and' => " und ", 'or' => " oder ", 'back' => "Zurück", 'reputationTip' => "Rufpunkte", - 'byUser' => 'Von %1$s ', + 'byUser' => 'Von %1$s ', 'help' => "Hilfe", 'status' => "Status", 'yes' => "Ja", 'no' => "Nein", + 'any' => "Beliebig", + 'all' => "Alle", // filter 'extSearch' => "Erweiterte Suche", 'addFilter' => "Weiteren Filter hinzufügen", - 'match' => "Verwendete Filter", + 'match' => "Verwendete Filter: ", 'allFilter' => "Alle Filter", 'oneFilter' => "Mindestens einer", 'applyFilter' => "Filter anwenden", @@ -69,7 +74,7 @@ $lang = array( 'refineSearch' => 'Tipp: Präzisiere deine Suche mit Durchsuchen einer Unterkategorie.', 'clear' => "leeren", 'exactMatch' => "Exakt passend", - '_reqLevel' => "Mindeststufe", + '_reqLevel' => "Mindeststufe: ", // infobox 'unavailable' => "Nicht für Spieler verfügbar", @@ -99,23 +104,23 @@ $lang = array( ), // article & infobox - 'englishOnly' => "Diese Seite ist nur in Englisch verfügbar.", + 'langOnly' => "Diese Seite ist nur in %s verfügbar.", // calculators - 'preset' => "Vorlage", + 'preset' => "Vorlage: ", 'addWeight' => "Weitere Gewichtung hinzufügen", 'createWS' => "Gewichtungsverteilung erstellen", 'jcGemsOnly' => "JS-exklusive
Edelsteine einschließen", 'cappedHint' => 'Tipp: Entfernt Gewichtungen für gedeckte Werte wie Trefferwertung.', - 'groupBy' => "Ordnen nach", + 'groupBy' => "Ordnen nach: ", 'gb' => array( ["Nichts", "none"], ["Platz", "slot"], ["Stufe", "level"], ["Quelle", "source"] ), 'compareTool' => "Gegenstandsvergleichswerkzeug", 'talentCalc' => "Talentrechner", 'petCalc' => "Begleiterrechner", - 'chooseClass' => "Wählt eine Klasse", - 'chooseFamily' => "Wählt eine Tierart", + 'chooseClass' => "Wählt eine Klasse:", + 'chooseFamily' => "Wählt eine Tierart:", // search 'search' => "Suche", @@ -128,7 +133,26 @@ $lang = array( 'colon' => ': ', 'dateFmtShort' => "d.m.Y", 'dateFmtLong' => "d.m.Y \u\m H:i", - 'timeAgo' => 'vor %s', + 'dateFmtIntl' => "d. MMMM y", + 'nfSeparators' => ['.', ','], + 'n_a' => "n. v.", + + // date time + 'date' => "Datum", + 'date_colon' => "Datum: ", + 'date_on' => "am ", + 'date_ago' => "vor %s", + 'date_at' => " um ", + 'date_to' => " bis ", + 'date_simple' => '%1$d.%2$d.%3$d', + 'unknowndate' => "Unbekanntes Datum", + 'ddaysago' => "vor %d Tagen", + 'today' => "heute", + 'yesterday' => "gestern", + 'noon' => "Mittag", + 'midnight' => "Mitternacht", + 'am' => "vormittags", + 'pm' => "nachmittags", // error 'intError' => "Ein interner Fehler ist aufgetreten.", @@ -159,25 +183,23 @@ $lang = array( ) ), 'guide' => array( - 'guide' => "Leitfaden", - 'guides' => "Leitfäden", 'myGuides' => "Meine Leitfäden", 'editTitle' => "Eigenen Leitfaden bearbeiten", 'newTitle' => "Leitfaden erstellen", - 'author' => "Autor", - 'spec' => "Spezialisierung", + 'author' => "Autor: ", + 'spec' => "Spezialisierung: ", 'sticky' => "Angeheftet", - 'views' => "Ansichten", + 'views' => "Ansichten: ", 'patch' => "Patch", - 'added' => "Hinzugefügt", - 'rating' => "Wertung", - 'votes' => "[span id=guiderating-value]%d[/span]/5 ([span id=guiderating-votes][n5=%d][/span] Bewertungen) [span id=guiderating][/span]", + 'added' => "Hinzugefügt: ", + 'rating' => "Wertung: ", + 'votes' => "[span id=guiderating-value]%.2g[/span]/5 ([span id=guiderating-votes][n5=%d][/span] Bewertungen) [span id=guiderating][/span]", 'noVotes' => "nicht genug Bewertungen [span id=guiderating][/span]", 'byAuthor' => "Von %s", 'notFound' => "Dieser Leitfaden existiert nicht.", 'clTitle' => 'Änderungsprotokoll für "%2$s"', - 'clStatusSet' => 'Status gesetzt auf %s', - 'clCreated' => 'Erstellt', + 'clStatusSet' => 'Status gesetzt auf %s: ', + 'clCreated' => 'Erstellt: ', 'clMinorEdit' => 'Kleinere Bearbeitung', 'editor' => array( 'fullTitle' => 'Ganze Überschrift', @@ -185,7 +207,7 @@ $lang = array( 'name' => 'Name', 'nameTip' => 'Dies sollte ein einfacher und klarer Name für den Leitfaden sein, der an Orten wie Menüs und Leitfadenlisten verwendet werden kann.', 'description' => 'Beschreibung', - 'descriptionTip' => 'Beschreibung, die für Suchmaschinen verwendet wird.<br /><br />Wenn leer, wird es automatisch generiert.', + 'descriptionTip' => 'Beschreibung, die für Suchmaschinen verwendet wird.

Wenn leer, wird es automatisch generiert.', // 'commentEmail' => 'Emailbenachrichtigung', // 'commentEmailTip' => 'Soll der Autor darüber benachrichtigt werden, dass Nutzer diesen Guide kommentieren?', 'changelog' => 'Änderungsprotokoll für diese Änderung', @@ -200,11 +222,11 @@ $lang = array( 'testGuide' => 'Sehen Sie, wie Ihr Leitfaden aussehen wird', 'images' => 'Bilder', 'statusTip' => array( - GUIDE_STATUS_DRAFT => 'Ihr Leitfaden ist im "Entwurfs"-Status und Sie sind der einzige der ihn sehen kann. Bearbeiten Sie ihn so lange Sie wollen und wenn Sie fertig sind reichen Sie ihn zur Überprüfung ein.', - GUIDE_STATUS_REVIEW => 'Ihr Leitfaden wird überprüft.', - GUIDE_STATUS_APPROVED => 'Ihr Leitfaden wurde veröffentlicht.', - GUIDE_STATUS_REJECTED => 'Ihr Leitfaden wurde abgewiesen. Nachdem die Mängel behoben wurde kann er erneut zur Überprüfung eingereicht werden.', - GUIDE_STATUS_ARCHIVED => 'Ihr Leitfaden ist veraltet und wurde archiviert. Er wird nicht mehr in der Übersicht gelistet und ist kann nicht mehr bearbeitet werden.]', + GuideMgr::STATUS_DRAFT => 'Ihr Leitfaden ist im "Entwurfs"-Status und Sie sind der einzige der ihn sehen kann. Bearbeiten Sie ihn so lange Sie wollen und wenn Sie fertig sind reichen Sie ihn zur Überprüfung ein.', + GuideMgr::STATUS_REVIEW => 'Ihr Leitfaden wird überprüft.', + GuideMgr::STATUS_APPROVED => 'Ihr Leitfaden wurde veröffentlicht.', + GuideMgr::STATUS_REJECTED => 'Ihr Leitfaden wurde abgewiesen. Nachdem die Mängel behoben wurde kann er erneut zur Überprüfung eingereicht werden.', + GuideMgr::STATUS_ARCHIVED => 'Ihr Leitfaden ist veraltet und wurde archiviert. Er wird nicht mehr in der Übersicht gelistet und ist kann nicht mehr bearbeitet werden.]', ) ), 'category' => array( @@ -229,11 +251,10 @@ $lang = array( 'guildRoster' => "Gildenliste für <%s>", 'arenaRoster' => "Arena-Teamliste für <%s>", 'atCaptain' => "Teamkapitän", - + 'atSize' => "Größe: ", 'profiler' => "Charakter-Profiler", - 'arenaTeams' => "Arena Teams", - 'guilds' => "Gilden", - + 'completion' => "Vervollständigung: ", + 'attainedBy' => "Erlangt von %d%% der Profile", 'notFound' => array( 'guild' => "Diese Gilde existiert nicht oder wurde noch nicht in die Datenbank übernommen.", 'arenateam' => "Dieses Arena Team existiert nicht oder wurde noch nicht in die Datenbank übernommen.", @@ -244,7 +265,8 @@ $lang = array( 'eu' => "Europa", 'kr' => "Korea", 'tw' => "Taiwan", - 'cn' => "China" + 'cn' => "China", + 'dev' => "Entwicklung" ), 'encounterNames'=> array( 243 => "Die Sieben", @@ -269,93 +291,127 @@ $lang = array( ), 'error' => array( 'unkFormat' => "Unbekanntes Bildformat.", - 'tooSmall' => "Euer Screenshot ist viel zu klein. (< ".CFG_SCREENSHOT_MIN_SIZE."x".CFG_SCREENSHOT_MIN_SIZE.").", + 'tooSmall' => "Euer Screenshot ist viel zu klein. (< CFG_SCREENSHOT_MIN_SIZE x CFG_SCREENSHOT_MIN_SIZE).", 'selectSS' => "Wählt bitte den Screenshot aus, den Ihr hochladen möchtet.", 'notAllowed' => "Es ist euch nicht erlaubt einen Screenshot hochzuladen!", ) ), + 'video' => array( + 'submission' => "Video-Einsendung", + 'thanks' => array( + 'contrib' => "Vielen Dank für Euren Beitrag!", + 'goBack' => 'Klickt hier, um zu der vorherigen Seite zurückzukehren.', + 'note' => "Hinweis: Euer Video muss zunächst zugelassen werden, bevor es auf der Seite erscheint. Dies kann bis zu 72 Stunden dauern." + ), + 'error' => array( + 'isPrivate' => "Das vorgeschlagene Video ist privat.", + 'noExist' => "An der eingereichten Url existiert kein Video.", + 'selectVI' => "Bitte gebt gültige Videoinformationen ein.", + 'notAllowed' => "Es ist euch nicht erlaubt Videos vorzuschlagen!" + ) + ), 'game' => array( - 'achievement' => "Erfolg", + // type strings + 'npc' => "NPC", // 1 + 'npcs' => "NPCs", + 'object' => "Objekt", // 2 + 'objects' => "Objekte", + 'item' => "Gegenstand", // 3 + 'items' => "Gegenstände", + 'itemset' => "Ausrüstungsset", // 4 + 'itemsets' => "Ausrüstungssets", + 'quest' => "Quest", // 5 + 'quests' => "Quests", + 'spell' => "Zauber", // 6 + 'spells' => "Zauber", + 'zone' => "Zone", // 7 + 'zones' => "Gebiete", + 'faction' => "Fraktion", // 8 + 'factions' => "Fraktionen", + 'pet' => "Begleiter", // 9 + 'pets' => "Begleiter", + 'achievement' => "Erfolg", // 10 'achievements' => "Erfolge", - 'areatrigger' => "Areatrigger", - 'areatriggers' => "Areatrigger", - 'class' => "Klasse", + 'title' => "Titel", // 11 + 'titles' => "Titel", + 'event' => "Weltereignis", // 12 + 'events' => "Weltereignisse", + 'class' => "Klasse", // 13 'classes' => "Klassen", - 'currency' => "Währung", + 'race' => "Volk", // 14 + 'races' => "Völker", + 'skill' => "Fertigkeit", // 15 + 'skills' => "Fertigkeiten", + 'currency' => "Währung", // 17 'currencies' => "Währungen", - 'difficulty' => "Modus", + 'sound' => "Klang", // 19 + 'sounds' => "Klänge", + 'icon' => "Icon", // 29 + 'icons' => "Icons", + 'profile' => "Profil", // 100 + 'profiles' => "Profile", + 'guild' => "Gilde", // 101 + 'guilds' => "Gilden", + 'arenateam' => "Arena Team", // 102 + 'arenateams' => "Arena Teams", + 'guide' => "Leitfaden", // 300 + 'guides' => "Leitfäden", + 'emote' => "Emote", // 501 + 'emotes' => "Emotes", + 'enchantment' => "Verzauberung", // 502 + 'enchantments' => "Verzauberungen", + 'areatrigger' => "Areatrigger", // 503 + 'areatriggers' => "Areatrigger", + 'mail' => "Brief", // 504 + 'mails' => "Briefe", + + 'cooldown' => "%s Abklingzeit", + 'difficulty' => "Modus: ", 'dispelType' => "Bannart", 'duration' => "Dauer", - 'emote' => "Emote", - 'emotes' => "Emotes", - 'enchantment' => "Verzauberung", - 'enchantments' => "Verzauberungen", + 'eventShort' => "Ereignis: %s", 'flags' => "Flags", - 'object' => "Objekt", - 'objects' => "Objekte", - 'glyphType' => "Glyphenart", - 'race' => "Volk", - 'races' => "Völker", - 'title' => "Titel", - 'titles' => "Titel", - 'eventShort' => "Ereignis", - 'event' => "Weltereigniss", - 'events' => "Weltereignisse", - 'faction' => "Fraktion", - 'factions' => "Fraktionen", - 'cooldown' => "%s Abklingzeit", - 'icon' => "Icon", - 'icons' => "Icons", - 'item' => "Gegenstand", - 'items' => "Gegenstände", - 'itemset' => "Ausrüstungsset", - 'itemsets' => "Ausrüstungssets", - 'mail' => "Brief", - 'mails' => "Briefe", + 'glyphType' => "Glyphenart: ", + 'level' => "Stufe", 'mechanic' => "Auswirkung", - 'mechAbbr' => "Ausw.", - 'meetingStone' => "Versammlungsstein", - 'npc' => "NPC", - 'npcs' => "NPCs", - 'pet' => "Begleiter", - 'pets' => "Begleiter", - 'profile' => "Profil", - 'profiles' => "Profile", - 'quest' => "Quest", - 'quests' => "Quests", + 'mechAbbr' => "Ausw.: ", + 'meetingStone' => "Versammlungsstein: ", 'requires' => "Benötigt %s", 'requires2' => "Benötigt", 'reqLevel' => "Benötigt Stufe %s", - 'reqSkillLevel' => "Benötigte Fertigkeitsstufe", - 'level' => "Stufe", + 'reqSkillLevel' => "Benötigte Fertigkeitsstufe: ", 'school' => "Magieart", - 'skill' => "Fertigkeit", - 'skills' => "Fertigkeiten", - 'sound' => "Klang", - 'sounds' => "Klänge", - 'spell' => "Zauber", - 'spells' => "Zauber", - 'type' => "Art", + 'type' => "Art: ", 'valueDelim' => " - ", // " bis " - 'zone' => "Zone", - 'zones' => "Gebiete", + 'target' => "", 'pvp' => "PvP", 'honorPoints' => "Ehrenpunkte", 'arenaPoints' => "Arenapunkte", 'heroClass' => "Heldenklasse", - 'resource' => "Ressource", - 'resources' => "Ressourcen", - 'role' => "Rolle", - 'roles' => "Rollen", - 'specs' => "Spezialisierungen", + 'resource' => "Ressource: ", + 'resources' => "Ressourcen: ", + 'role' => "Rolle: ", + 'roles' => "Rollen: ", + 'specs' => "Spezialisierungen: ", '_roles' => ["Heiler", "Nahkampf-DPS", "Distanz-DPS", "Tank"], 'phases' => "Phasen", - 'mode' => "Modus", - 'modes' => [-1 => "Beliebig", "Normal / Normal 10", "Heroisch / Normal 25", "Heroisch 10", "Heroisch 25"], + 'mode' => "Modus: ", + 'modes' => array( + [-1 => "Beliebig", "Normal / Normal 10", "Heroisch / Normal 25", "Heroisch 10", "Heroisch 25"], + ["Normal", "Heroisch"], + ["Normal 10", "Normal 25", "Heroisch 10", "Heroisch 25"] + ), 'expansions' => ["Classic", "The Burning Crusade", "Wrath of the Lich King"], 'stats' => ["Stärke", "Beweglichkeit", "Ausdauer", "Intelligenz", "Willenskraft"], + 'timeAbbrev' => array( + '', + "%d |4Sek.:Sek.;", + "%d |4Min.:Min.;", + "%d |4Std.:Std.;", + "%d |4Tag:Tage;" + ), 'sources' => array( "Unbekannt", "Hergestellt", "Drop", "PvP", "Quest", "Händler", "Lehrer", "Entdeckung", "Einlösung", "Talent", "Startausrüstung", "Ereignis", @@ -364,8 +420,8 @@ $lang = array( "In-Game-Store" ), 'pvpSources' => array( - null, "Arenasaison 1", "Arenasaison 2", "Arenasaison 3", "Arenasaison 4", - "Arenasaison 5", "Arenasaison 6", "Arenasaison 7", "Arenasaison 8", "2009 Arena-Turnier" + 42 => "Arenasaison 1", 52 => "Arenasaison 2", 71 => "Arenasaison 3", 80 => "Arenasaison 4", 157 => "Arenasaison 5", + 163 => "Arenasaison 6", 167 => "Arenasaison 7", 169 => "Arenasaison 8", 177 => "2009 Arena-Turnier" ), 'languages' => array( 1 => "Orcisch", 2 => "Darnassisch", 3 => "Taurisch", 6 => "Zwergisch", 7 => "Gemeinsprache", 8 => "Dämonisch", @@ -376,7 +432,7 @@ $lang = array( 'si' => [1 => "Allianz", -1 => "Nur für Allianz", 2 => "Horde", -2 => "Nur für Horde", 3 => "Beide"], 'resistances' => [null, 'Heiligwiderstand', 'Feuerwiderstand', 'Naturwiderstand', 'Frostwiderstand', 'Schattenwiderstand', 'Arkanwiderstand'], 'sc' => ["Körperlich", "Heilig", "Feuer", "Natur", "Frost", "Schatten", "Arkan"], - 'dt' => [null, "Magie", "Fluch", "Krankheit", "Gift", "Verstohlenheit", "Unsichtbarkeit", null, null, "Wut"], + 'dt' => [null, "Magie", "Fluch", "Krankheit", "Gift", "Verstohlenheit", "Unsichtbarkeit", "Magie, Fluch, Krankheit, Gift", "Zauber (NSC)", "Wut"], 'cl' => [null, "Krieger", "Paladin", "Jäger", "Schurke", "Priester", "Todesritter", "Schamane", "Magier", "Hexenmeister", null, "Druide"], 'ra' => [-2 => "Horde", -1 => "Allianz", null, "Mensch", "Orc", "Zwerg", "Nachtelf", "Untoter", "Tauren", "Gnom", "Troll", null, "Blutelf", "Draenei"], 'rep' => ["Hasserfüllt", "Feindselig", "Unfreundlich", "Neutral", "Freundlich", "Wohlwollend", "Respektvoll", "Ehrfürchtig"], @@ -423,12 +479,12 @@ $lang = array( 9 => ['Gebrechen', 'Dämonologie', 'Zerstörung' ], 1 => ['Waffen', 'Furor', 'Schutz' ] ), - 'pvpRank' => array( - null, "Gefreiter / Späher", "Fußknecht / Grunzer", - "Landsknecht / Waffenträger", "Feldwebel / Schlachtrufer", "Fähnrich / Rottenmeister", - "Leutnant / Steingardist", "Hauptmann / Blutgardist", "Kürassier / Zornbringer", - "Ritter der Allianz / Klinge der Horde", "Feldkomandant / Feldherr", "Rittmeister / Sturmreiter", - "Marschall / Kriegsherr", "Feldmarschall / Kriegsfürst", "Großmarschall / Oberster Kriegsfürst" + 'pvpRank' => array( // PVP_RANK_* + null, ["Späher", "Gefreiter"], ["Grunzer", "Fußknecht"], + ["Waffenträger", "Landsknecht"], ["Schlachtrufer", "Feldwebel"], ["Rottenmeister", "Fähnrich"], + ["Steingardist", "Leutnant"], ["Blutgardist", "Hauptmann"], ["Zornbringer", "Kürassier"], + ["Klinge der Horde", "Ritter der Allianz"], ["Feldherr", "Feldkommandant"], ["Sturmreiter", "Rittmeister"], + ["Kriegsherr", "Marschall"], ["Kriegsfürst", "Feldmarschall"], ["Oberster Kriegsfürst", "Großmarschall"] ), 'orientation' => ['Nord', 'Nordost', 'Ost', 'Südost', 'Süd', 'Südwest', 'West', 'Nordwest'] ), @@ -446,7 +502,7 @@ $lang = array( UNIT_FLAG_IMMUNE_TO_NPC => 'Immun gegen Kreaturen', UNIT_FLAG_LOOTING => 'Lootanimation', UNIT_FLAG_PET_IN_COMBAT => 'Pet im Kampf', - UNIT_FLAG_PVP => 'PvP', + UNIT_FLAG_PVP_ENABLING => 'PvP', UNIT_FLAG_SILENCED => 'Zum Schweigen gebracht', UNIT_FLAG_CANNOT_SWIM => 'Kann nicht schwimmen', UNIT_FLAG_UNK_15 => 'UNK-15 (kann nur schwimmen)', @@ -498,324 +554,369 @@ $lang = array( UNIT_DYNFLAG_TAPPED_BY_ALL_THREAT_LIST => 'Getappt durch ganze Aggro-Liste' ), 'bytes1' => array( -/*idx:0*/ ['Stehend', 'Am Boden sitzend', 'Auf Stuhl sitzend', 'Schlafend', 'Auf niedrigem Stuhl sitzend', 'Auf mittlerem Stuhl sitzend', 'Auf hohem Stuhl sitzend', 'Tot', 'Kniehend', 'Untergetaucht'], // STAND_STATE_* +/*idx:0*/ array( + UNIT_STAND_STATE_STAND => 'Stehend', + UNIT_STAND_STATE_SIT => 'Am Boden sitzend', + UNIT_STAND_STATE_SIT_CHAIR => 'Auf Stuhl sitzend', + UNIT_STAND_STATE_SLEEP => 'Schlafend', + UNIT_STAND_STATE_SIT_LOW_CHAIR => 'Auf niedrigem Stuhl sitzend', + UNIT_STAND_STATE_SIT_MEDIUM_CHAIR => 'Auf mittlerem Stuhl sitzend', + UNIT_STAND_STATE_SIT_HIGH_CHAIR => 'Auf hohem Stuhl sitzend', + UNIT_STAND_STATE_DEAD => 'Tot', + UNIT_STAND_STATE_KNEEL => 'Kniehend', + UNIT_STAND_STATE_SUBMERGED => 'Untergetaucht' + ), null, /*idx:2*/ array( - UNIT_STAND_FLAGS_UNK1 => 'UNK-1', - UNIT_STAND_FLAGS_CREEP => 'Creep', - UNIT_STAND_FLAGS_UNTRACKABLE => 'Unangreifbar', - UNIT_STAND_FLAGS_UNK4 => 'UNK-4', - UNIT_STAND_FLAGS_UNK5 => 'UNK-5' + UNIT_VIS_FLAGS_UNK1 => 'UNK-1', + UNIT_VIS_FLAGS_CREEP => 'Creep', + UNIT_VIS_FLAGS_UNTRACKABLE => 'Unaufspürbar', + UNIT_VIS_FLAGS_UNK4 => 'UNK-4', + UNIT_VIS_FLAGS_UNK5 => 'UNK-5' ), /*idx:3*/ array( - UNIT_BYTE1_FLAG_ALWAYS_STAND => 'Immer stehend', - UNIT_BYTE1_FLAG_HOVER => 'Schwebend', - UNIT_BYTE1_FLAG_UNK_3 => 'UNK-3' + UNIT_ANIM_TIER_GROUND => 'Bodenanimationen', + UNIT_ANIM_TIER_SWIM => 'Schwimmanimationen', + UNIT_ANIM_TIER_HOVER => 'Schwebeanimationen', + UNIT_ANIM_TIER_FLY => 'Fluganimationen', + UNIT_ANIM_TIER_SUMBERGED => 'abgetauchte Animationen' ), + 'bytesIdx' => ['StandState', null, 'VisFlags', 'AnimTier'], 'valueUNK' => '[span class=q10]unbenutzter Wert [b class=q1]%d[/b] übergeben an UnitFieldBytes1 auf Offset [b class=q1]%d[/b][/span]', 'idxUNK' => '[span class=q10]unbenutzter Offset [b class=q1]%d[/b] übergeben an UnitFieldBytes1[/span]' ) ), 'smartAI' => array( 'eventUNK' => '[span class=q10]Unbenkanntes Event #[b class=q1]%d[/b] in Benutzung.[/span]', - 'eventTT' => '[b class=q1]EventType %d[/b][br][table][tr][td]PhaseMask[/td][td=header]0x%04X[/td][/tr][tr][td]Chance[/td][td=header]%d%%%%[/td][/tr][tr][td]Flags[/td][td=header]0x%04X[/td][/tr][tr][td]Param1[/td][td=header]%d[/td][/tr][tr][td]Param2[/td][td=header]%d[/td][/tr][tr][td]Param3[/td][td=header]%d[/td][/tr][tr][td]Param4[/td][td=header]%d[/td][/tr][tr][td]Param5[/td][td=header]%d[/td][/tr][/table]', + 'eventTT' => '[b class=q1]EventType %d[/b][br][table][tr][td]PhaseMask[/td][td=header]0x%04X[/td][/tr][tr][td]Chance[/td][td=header]%d%%[/td][/tr][tr][td]Flags[/td][td=header]0x%04X[/td][/tr][tr][td]Param1[/td][td=header]%d[/td][/tr][tr][td]Param2[/td][td=header]%d[/td][/tr][tr][td]Param3[/td][td=header]%d[/td][/tr][tr][td]Param4[/td][td=header]%d[/td][/tr][tr][td]Param5[/td][td=header]%d[/td][/tr][/table]', 'events' => array( - SAI_EVENT_UPDATE_IC => ['(%12$d)?:Im Kampf, ;(%11$s)?Nach %11$s:Sofort;', 'Wiederhole alle %s'], - SAI_EVENT_UPDATE_OOC => ['(%12$d)?:Nicht im Kampf, ;(%11$s)?Nach %11$s:Sofort;', 'Wiederhole alle %s'], - SAI_EVENT_HEALTH_PCT => ['Ab %11$s%% Gesundheit', 'Wiederhole alle %s'], - SAI_EVENT_MANA_PCT => ['Ab %11$s%% Mana', 'Wiederhole alle %s'], - SAI_EVENT_AGGRO => ['Bei Aggro', null], - SAI_EVENT_KILL => ['Beim Töten von (%3$d)?einem Spieler:;(%4$d)?[npc=%4$d]:einer Kreatur;', 'Abklingzeit: %s'], - SAI_EVENT_DEATH => ['Im Tod', null], - SAI_EVENT_EVADE => ['Beim Entkommen', null], - SAI_EVENT_SPELLHIT => ['Von (%11$s)?%11$s-:;(%1$d)?[spell=%1$d]:Zauber; getroffen', 'Abklingzeit: %s'], - SAI_EVENT_RANGE => ['Ziel innerhalb von %11$sm', 'Wiederhole alle %s'], -/* 10*/ SAI_EVENT_OOC_LOS => ['(%1$d)?Freundlicher:Feindlicher; (%5$d)?Spieler:NPC; kommt ausserhalb des Kampfes innerhalb von %2$dm ins Sichtfeld', 'Abklingzeit: %s'], - SAI_EVENT_RESPAWN => ['Beim Wiedereinstieg', null], - SAI_EVENT_TARGET_HEALTH_PCT => ['Ziel hat %11$s%% Gesundheit', 'Wiederhole alle %s'], - SAI_EVENT_VICTIM_CASTING => ['Aktuelles Ziel wirkt (%3$d)?[spell=%3$d]:beliebigen Zauber;', 'Wiederhole alle %s'], - SAI_EVENT_FRIENDLY_HEALTH => ['Freundlicher NPC innerhalb von %2$dm hat %1$d Gesundheit', 'Wiederhole alle %s'], - SAI_EVENT_FRIENDLY_IS_CC => ['Freundlicher NPC innerhalb von %1$dm ist beeinflusst von \'Crowd Control\'', 'Wiederhole alle %s'], - SAI_EVENT_FRIENDLY_MISSING_BUFF => ['Freundlichem NPC innerhalb von %2$dm fehlt [spell=%1$d]', 'Wiederhole alle %s'], - SAI_EVENT_SUMMONED_UNIT => ['(%1$d)?[npc=%1$d]:Beliebige Kreatur; wurde gerade beschworen', 'Abklingzeit: %s'], - SAI_EVENT_TARGET_MANA_PCT => ['Ziel hat %11$s%% Mana', 'Wiederhole alle %s'], - SAI_EVENT_ACCEPTED_QUEST => ['Gebe (%1$d)?[quest=%1$d]:beliebiges Quest;', 'Abklingzeit: %s'], -/* 20*/ SAI_EVENT_REWARD_QUEST => ['Belohne (%1$d)?[quest=%1$d]:beliebiges Quest;', 'Abklingzeit: %s'], - SAI_EVENT_REACHED_HOME => ['Erreiche \'Heimat\'-Koordinaten', null], - SAI_EVENT_RECEIVE_EMOTE => ['Das Ziel von [emote=%1$d] seiend', 'Abklingzeit: %s'], - SAI_EVENT_HAS_AURA => ['(%2$d)?Habe %2$d Aufladungen von [spell=%1$d]:Aura von [spell=%1$d] fehlt; ', 'Wiederhole alle %s'], - SAI_EVENT_TARGET_BUFFED => ['#target# hat (%2$d)?%2$d Aufladungen:Aura; von [spell=%1$d]', 'Wiederhole alle %s'], - SAI_EVENT_RESET => ['Beim Reset', null], - SAI_EVENT_IC_LOS => ['(%1$d)?Freundlicher:Feindlicher; (%5$d)?Spieler:NPC; kommt im Kampf innerhalb von %2$dm ins Sichtfeld', 'Abklingzeit: %s'], - SAI_EVENT_PASSENGER_BOARDED => ['Ein Passagier steigt zu', 'Abklingzeit: %s'], - SAI_EVENT_PASSENGER_REMOVED => ['Ein Passagier steigt ab', 'Abklingzeit: %s'], - SAI_EVENT_CHARMED => ['(%1$d)?Bezaubert werden:Bezauberung läuft ab;', null], -/* 30*/ SAI_EVENT_CHARMED_TARGET => ['Beim Bezaubern von #target#', null], - SAI_EVENT_SPELLHIT_TARGET => ['#target# wird von (%11$s)?%11$s :;(%1$d)?[spell=%1$d]:Zauber; getroffen', 'Abklingzeit: %s'], - SAI_EVENT_DAMAGED => ['Nehme %11$s Punkte Schaden', 'Wiederhole alle %s'], - SAI_EVENT_DAMAGED_TARGET => ['#target# nahm %11$s Punkte Schaden', 'Wiederhole alle %s'], - SAI_EVENT_MOVEMENTINFORM => ['Beginne Bewegung zu Punkt #[b]%2$d[/b](%1$d)? mit MotionType #[b]%1$d[/b]:;', null], - SAI_EVENT_SUMMON_DESPAWNED => ['Beschworener NPC [npc=%1$d] verschwindet', 'Abklingzeit: %s'], - SAI_EVENT_CORPSE_REMOVED => ['Leiche verschwindet', null], - SAI_EVENT_AI_INIT => ['KI initialisiert', null], - SAI_EVENT_DATA_SET => ['Datenfeld #[b]%1$d[/b] auf [b]%2$d[/b] gesetzt', 'Abklingzeit: %s'], - SAI_EVENT_WAYPOINT_START => ['Beginne Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigem Wegpunkt;', null], -/* 40*/ SAI_EVENT_WAYPOINT_REACHED => ['Erreiche (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigen Wegpunkt;(%2$d)? auf Pfad #[b]%2$d[/b]:;', null], - null, - null, - null, - null, - null, - SAI_EVENT_AREATRIGGER_ONTRIGGER => ['Bei Aktivierung', null], - null, - null, - null, -/* 50*/ null, - null, - SAI_EVENT_TEXT_OVER => ['(%2$d)?[npc=%2$d]:Beliebige Kreatur; ist fertig mit der Wiedergabe von Textgruppe #[b]%1$d[/b]', null], - SAI_EVENT_RECEIVE_HEAL => ['Erhalte %11$s Punkte Heilung', 'Abklingzeit: %s'], - SAI_EVENT_JUST_SUMMONED => ['Wurde gerade beschworen', null], - SAI_EVENT_WAYPOINT_PAUSED => ['Pausiere Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigem Wegpunkt;', null], - SAI_EVENT_WAYPOINT_RESUMED => ['Setze Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigem Wegpunkt; fort', null], - SAI_EVENT_WAYPOINT_STOPPED => ['Halte Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Waypoint #[b]%1$d[/b]:beliebigem Wegpunkt; an', null], - SAI_EVENT_WAYPOINT_ENDED => ['Beende aktuellen Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Waypoint #[b]%1$d[/b]:beliebigem Wegpunkt;', null], - SAI_EVENT_TIMED_EVENT_TRIGGERED => ['Geplanted Ereignis #[b]%1$d[/b] löst aus', null], -/* 60*/ SAI_EVENT_UPDATE => ['(%11$s)?Nach %11$s:Sofort;', 'Wiederhole alle %s'], - SAI_EVENT_LINK => ['Nach Ereignis %11$s', null], - SAI_EVENT_GOSSIP_SELECT => ['Wähle Gossip:[br](%11$s)?[span class=q1]%11$s[/span]:Menü #[b]%1$d[/b] - Option #[b]%2$d[/b];', null], - SAI_EVENT_JUST_CREATED => ['Beim ersten Erscheinen in der Welt', null], - SAI_EVENT_GOSSIP_HELLO => ['Öffne Gossip', '(%1$d)?onGossipHello:;(%2$d)?onReportUse:;'], - SAI_EVENT_FOLLOW_COMPLETED => ['Ist fertig mit folgen', null], - SAI_EVENT_EVENT_PHASE_CHANGE => ['Ereignisphase wurde geändert und passt auf %11$s', null], - SAI_EVENT_IS_BEHIND_TARGET => ['Stehe hinter #target#', 'Abklingzeit: %s'], - SAI_EVENT_GAME_EVENT_START => ['[event=%1$d] beginnt', null], - SAI_EVENT_GAME_EVENT_END => ['[event=%1$d] endet', null], -/* 70*/ SAI_EVENT_GO_STATE_CHANGED => ['Zustand wurde geändert', null], - SAI_EVENT_GO_EVENT_INFORM => ['Taxi-Pfad Ereignis #[b]%1$d[/b] wurde ausgelöst', null], - SAI_EVENT_ACTION_DONE => ['Script-Aktion #[b]%1$d[/b] ausgeführt', null], - SAI_EVENT_ON_SPELLCLICK => ['Zauber-Klick wurde ausgelöst', null], - SAI_EVENT_FRIENDLY_HEALTH_PCT => ['Gesundheit von #target# ist %11$s%%', 'Wiederhole alle %s'], - SAI_EVENT_DISTANCE_CREATURE => ['[npc=%11$d](%1$d)? mit GUID #%1$d:; nähert sich auf %2$dm', 'Wiederhole alle %s'], - SAI_EVENT_DISTANCE_GAMEOBJECT => ['[object=%11$d](%1$d)? mit GUID #%1$d:; nähert sich auf %2$dm', 'Wiederhole alle %s'], - SAI_EVENT_COUNTER_SET => ['Zähler #[b]%1$d[/b] ist gleich [b]%2$d[/b]', null], + SmartEvent::EVENT_UPDATE_IC => ['(%12$d)?:Im Kampf, ;(%11$s)?Nach %11$s:Sofort;', 'Wiederhole alle %s'], + SmartEvent::EVENT_UPDATE_OOC => ['(%12$d)?:Nicht im Kampf, ;(%11$s)?Nach %11$s:Sofort;', 'Wiederhole alle %s'], + SmartEvent::EVENT_HEALTH_PCT => ['Ab %11$s%% Gesundheit', 'Wiederhole alle %s'], + SmartEvent::EVENT_MANA_PCT => ['Ab %11$s%% Mana', 'Wiederhole alle %s'], + SmartEvent::EVENT_AGGRO => ['Bei Aggro', ''], + SmartEvent::EVENT_KILL => ['Beim Töten (%3$d)?eines Spielers:(%4$d)?von [npc=%4$d]:einer Kreatur;;', 'Abklingzeit: %s'], + SmartEvent::EVENT_DEATH => ['Im Tod', ''], + SmartEvent::EVENT_EVADE => ['Beim Entkommen', ''], + SmartEvent::EVENT_SPELLHIT => ['Von (%11$s)?%11$s-:;(%1$d)?[spell=%1$d]:Zauber; getroffen', 'Abklingzeit: %s'], + SmartEvent::EVENT_RANGE => ['#target# innerhalb von %11$sm', 'Wiederhole alle %s'], +/* 10*/ SmartEvent::EVENT_OOC_LOS => ['(%5$d)?Spieler:Einheit;(%11$s)? (%11$s):; kommt ausserhalb des Kampfes innerhalb von %2$dm ins Sichtfeld', 'Abklingzeit: %s'], + SmartEvent::EVENT_RESPAWN => ['Beim Erscheinen(%11$s)? in %11$s:;(%12$d)? in [zone=%12$d]:;', ''], + SmartEvent::EVENT_TARGET_HEALTH_PCT => ['#target# hat %11$s%% Gesundheit', 'Wiederhole alle %s'], + SmartEvent::EVENT_VICTIM_CASTING => ['#target# wirkt (%3$d)?[spell=%3$d]:beliebigen Zauber;', 'Wiederhole alle %s'], + SmartEvent::EVENT_FRIENDLY_HEALTH => ['Freundlicher NPC innerhalb von %2$dm hat %1$d Gesundheit', 'Wiederhole alle %s'], + SmartEvent::EVENT_FRIENDLY_IS_CC => ['Freundlicher NPC innerhalb von %1$dm ist beeinflusst von \'Crowd Control\'', 'Wiederhole alle %s'], + SmartEvent::EVENT_FRIENDLY_MISSING_BUFF => ['Freundlichem NPC innerhalb von %2$dm fehlt [spell=%1$d]', 'Wiederhole alle %s'], + SmartEvent::EVENT_SUMMONED_UNIT => ['(%1$d)?[npc=%1$d]:Beliebige Kreatur; wurde gerade beschworen', 'Abklingzeit: %s'], + SmartEvent::EVENT_TARGET_MANA_PCT => ['#target# hat %11$s%% Mana', 'Wiederhole alle %s'], + SmartEvent::EVENT_ACCEPTED_QUEST => ['Gebe (%1$d)?[quest=%1$d]:beliebiges Quest;', 'Abklingzeit: %s'], +/* 20*/ SmartEvent::EVENT_REWARD_QUEST => ['Belohne (%1$d)?[quest=%1$d]:beliebiges Quest;', 'Abklingzeit: %s'], + SmartEvent::EVENT_REACHED_HOME => ['Erreiche \'Heimat\'-Koordinaten', ''], + SmartEvent::EVENT_RECEIVE_EMOTE => ['Das Ziel von [emote=%1$d] seiend', 'Abklingzeit: %s'], + SmartEvent::EVENT_HAS_AURA => ['(%2$d)?Habe %2$d Aufladungen von [spell=%1$d]:Aura von [spell=%1$d] fehlt; ', 'Wiederhole alle %s'], + SmartEvent::EVENT_TARGET_BUFFED => ['#target# hat (%2$d)?%2$d Aufladungen:Aura; von [spell=%1$d]', 'Wiederhole alle %s'], + SmartEvent::EVENT_RESET => ['Beim Reset', ''], + SmartEvent::EVENT_IC_LOS => ['(%5$d)?Spieler:Einheit;(%11$s)? (%11$s):; kommt im Kampf innerhalb von %2$dm ins Sichtfeld', 'Abklingzeit: %s'], + SmartEvent::EVENT_PASSENGER_BOARDED => ['Ein Passagier steigt zu', 'Abklingzeit: %s'], + SmartEvent::EVENT_PASSENGER_REMOVED => ['Ein Passagier steigt ab', 'Abklingzeit: %s'], + SmartEvent::EVENT_CHARMED => ['(%1$d)?Bezaubert werden:Bezauberung läuft ab;', ''], +/* 30*/ SmartEvent::EVENT_CHARMED_TARGET => ['Beim Bezaubern von #target#', ''], + SmartEvent::EVENT_SPELLHIT_TARGET => ['#target# wird von (%11$s)?%11$s :;(%1$d)?[spell=%1$d]:Zauber; getroffen', 'Abklingzeit: %s'], + SmartEvent::EVENT_DAMAGED => ['Nehme %11$s Punkte Schaden', 'Wiederhole alle %s'], + SmartEvent::EVENT_DAMAGED_TARGET => ['#target# nahm %11$s Punkte Schaden', 'Wiederhole alle %s'], + SmartEvent::EVENT_MOVEMENTINFORM => ['Beende (%1$d)?%11$s:Bewegung; an Punkt #[b]%2$d[/b]', ''], + SmartEvent::EVENT_SUMMON_DESPAWNED => ['Beschworener NPC(%1$d)? [npc=%1$d]:; verschwindet', 'Abklingzeit: %s'], + SmartEvent::EVENT_CORPSE_REMOVED => ['Leiche verschwindet', ''], + SmartEvent::EVENT_AI_INIT => ['KI initialisiert', ''], + SmartEvent::EVENT_DATA_SET => ['Datenfeld #[b]%1$d[/b] auf [b]%2$d[/b] gesetzt', 'Abklingzeit: %s'], + SmartEvent::EVENT_WAYPOINT_START => ['Beginne Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigem Wegpunkt;', ''], +/* 40*/ SmartEvent::EVENT_WAYPOINT_REACHED => ['Erreiche (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigen Wegpunkt;(%2$d)? auf Pfad #[b]%2$d[/b]:;', ''], + SmartEvent::EVENT_TRANSPORT_ADDPLAYER => null, + SmartEvent::EVENT_TRANSPORT_ADDCREATURE => null, + SmartEvent::EVENT_TRANSPORT_REMOVE_PLAYER => null, + SmartEvent::EVENT_TRANSPORT_RELOCATE => null, + SmartEvent::EVENT_INSTANCE_PLAYER_ENTER => null, + SmartEvent::EVENT_AREATRIGGER_ONTRIGGER => ['Bei Aktivierung', ''], + SmartEvent::EVENT_QUEST_ACCEPTED => null, + SmartEvent::EVENT_QUEST_OBJ_COMPLETION => null, + SmartEvent::EVENT_QUEST_COMPLETION => null, +/* 50*/ SmartEvent::EVENT_QUEST_REWARDED => null, + SmartEvent::EVENT_QUEST_FAIL => null, + SmartEvent::EVENT_TEXT_OVER => ['(%2$d)?[npc=%2$d]:Beliebige Kreatur; ist fertig mit der Wiedergabe von Textgruppe #[b]%1$d[/b]', ''], + SmartEvent::EVENT_RECEIVE_HEAL => ['Erhalte %11$s Punkte Heilung', 'Abklingzeit: %s'], + SmartEvent::EVENT_JUST_SUMMONED => ['Wurde gerade beschworen', ''], + SmartEvent::EVENT_WAYPOINT_PAUSED => ['Pausiere Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigem Wegpunkt;', ''], + SmartEvent::EVENT_WAYPOINT_RESUMED => ['Setze Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Wegpunkt #[b]%1$d[/b]:beliebigem Wegpunkt; fort', ''], + SmartEvent::EVENT_WAYPOINT_STOPPED => ['Halte Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Waypoint #[b]%1$d[/b]:beliebigem Wegpunkt; an', ''], + SmartEvent::EVENT_WAYPOINT_ENDED => ['Beende aktuellen Pfad(%2$d)? #[b]%2$d[/b]:; an (%1$d)?Waypoint #[b]%1$d[/b]:beliebigem Wegpunkt;', ''], + SmartEvent::EVENT_TIMED_EVENT_TRIGGERED => ['Geplanted Ereignis #[b]%1$d[/b] löst aus', ''], +/* 60*/ SmartEvent::EVENT_UPDATE => ['(%11$s)?Nach %11$s:Sofort;', 'Wiederhole alle %s'], + SmartEvent::EVENT_LINK => ['Nach Ereignis %11$s', ''], + SmartEvent::EVENT_GOSSIP_SELECT => ['Wähle Gossip:[br](%11$s)?[span class=q1]%11$s[/span]:Menü #[b]%1$d[/b] - Option #[b]%2$d[/b];', ''], + SmartEvent::EVENT_JUST_CREATED => ['Beim ersten Erscheinen in der Welt', ''], + SmartEvent::EVENT_GOSSIP_HELLO => ['Öffne Gossip', '(%1$d)?onGossipHello:;(%2$d)?onReportUse:;'], + SmartEvent::EVENT_FOLLOW_COMPLETED => ['Ist fertig mit folgen', ''], + SmartEvent::EVENT_EVENT_PHASE_CHANGE => ['Ereignisphase wurde geändert und passt auf %11$s', ''], + SmartEvent::EVENT_IS_BEHIND_TARGET => ['Stehe hinter #target#', 'Abklingzeit: %s'], + SmartEvent::EVENT_GAME_EVENT_START => ['[event=%1$d] beginnt', ''], + SmartEvent::EVENT_GAME_EVENT_END => ['[event=%1$d] endet', ''], +/* 70*/ SmartEvent::EVENT_GO_LOOT_STATE_CHANGED => ['Zustand geändert auf: %11$s', ''], + SmartEvent::EVENT_GO_EVENT_INFORM => ['Im Template definiertes Ereignis #[b]%1$d[/b] wurde ausgelöst', ''], + SmartEvent::EVENT_ACTION_DONE => ['Aktion #[b]%1$d[/b] angefordert von anderem Skript', ''], + SmartEvent::EVENT_ON_SPELLCLICK => ['Ein \'SpellClick\' wurde ausgelöst', ''], + SmartEvent::EVENT_FRIENDLY_HEALTH_PCT => ['Gesundheit von #target# ist %11$s%%', 'Wiederhole alle %s'], + SmartEvent::EVENT_DISTANCE_CREATURE => ['[npc=%11$d](%1$d)? [small class=q0](GUID %1$d)[/small]:; ist in einem %3$dm Umkreis', 'Wiederhole alle %s'], + SmartEvent::EVENT_DISTANCE_GAMEOBJECT => ['[object=%11$d](%1$d)? [small class=q0](GUID %1$d)[/small]:; ist in einem %3$dm Umkreis', 'Wiederhole alle %s'], + SmartEvent::EVENT_COUNTER_SET => ['Zähler #[b]%1$d[/b] ist gleich [b]%2$d[/b]', 'Abklingzeit: %s'], + SmartEvent::EVENT_SCENE_START => null, + SmartEvent::EVENT_SCENE_TRIGGER => null, +/* 80*/ SmartEvent::EVENT_SCENE_CANCEL => null, + SmartEvent::EVENT_SCENE_COMPLETE => null, + SmartEvent::EVENT_SUMMONED_UNIT_DIES => ['Durch mich beschworener (%1$d)?[npc=%1$d]:NPC; stirbt', 'Abklingzeit: %s'], + SmartEvent::EVENT_ON_SPELL_CAST => ['Bei \'cast success\' von [spell=%1$d]', 'Abklingzeit: %s'], + SmartEvent::EVENT_ON_SPELL_FAILED => ['Bei \'cast failed\' von [spell=%1$d]', 'Abklingzeit: %s'], + SmartEvent::EVENT_ON_SPELL_START => ['Bei \'cast start\' von [spell=%1$d]', 'Abklingzeit: %s'], + SmartEvent::EVENT_ON_DESPAWN => ['Beim Verschwinden', ''], + SmartEvent::EVENT_SEND_EVENT_TRIGGER => null, + SmartEvent::EVENT_AREATRIGGER_EXIT => null, + SmartEvent::EVENT_ON_AURA_APPLIED => ['Wenn Aura [spell=%1$d] angewendet wird', 'Abklingzeit: %s'], + SmartEvent::EVENT_ON_AURA_REMOVED => ['Wenn Aura [spell=%1$d] endet', 'Abklingzeit: %s'] ), 'eventFlags' => array( - SAI_EVENT_FLAG_NO_REPEAT => 'Nicht wiederholbar', - SAI_EVENT_FLAG_DIFFICULTY_0 => 'Normaler Dungeon', - SAI_EVENT_FLAG_DIFFICULTY_1 => 'Heroischer Dungeon', - SAI_EVENT_FLAG_DIFFICULTY_2 => 'Normaler Schlachtzug', - SAI_EVENT_FLAG_DIFFICULTY_3 => 'Heroischer Schlachtzug', - SAI_EVENT_FLAG_NO_RESET => 'Nicht resetbar', - SAI_EVENT_FLAG_WHILE_CHARMED => 'Auch wenn bezaubert' + SmartEvent::FLAG_NO_REPEAT => 'Nicht wiederholbar', + SmartEvent::FLAG_DIFFICULTY_0 => '5N Dungeon / 10N Schlachtzug', + SmartEvent::FLAG_DIFFICULTY_1 => '5H Dungeon / 25N Schlachtzug', + SmartEvent::FLAG_DIFFICULTY_2 => '10H Schlachtzug', + SmartEvent::FLAG_DIFFICULTY_3 => '25H Schlachtzug', + SmartEvent::FLAG_DEBUG_ONLY => null, // only occurs in debug build; do not output + SmartEvent::FLAG_NO_RESET => 'Nicht resetbar', + SmartEvent::FLAG_WHILE_CHARMED => 'Auch wenn bezaubert' ), 'actionUNK' => '[span class=q10]Unbekannte Action #[b class=q1]%d[/b] in Benutzung.[/span]', 'actionTT' => '[b class=q1]ActionType %d[/b][br][table][tr][td]Param1[/td][td=header]%d[/td][/tr][tr][td]Param2[/td][td=header]%d[/td][/tr][tr][td]Param3[/td][td=header]%d[/td][/tr][tr][td]Param4[/td][td=header]%d[/td][/tr][tr][td]Param5[/td][td=header]%d[/td][/tr][tr][td]Param6[/td][td=header]%d[/td][/tr][/table]', 'actions' => array( // [body, footer] null, - SAI_ACTION_TALK => ['(%3$d)?Gib:#target# gibt; (%7$d)?TextGroup:[span class=q10]unbekannten Text[/span]; #[b]%1$d[/b] für #target# wieder%8$s', 'Dauer: %s'], - SAI_ACTION_SET_FACTION => ['Setze Fraktion von #target# (%1$d)?auf [faction=%7$d]:zurück;.', null], - SAI_ACTION_MORPH_TO_ENTRY_OR_MODEL => ['(%7$d)?Setze Aussehen zurück.:Nimm folgendes Erscheinungsbild an:;(%1$d)? [npc=%1$d]:;(%2$d)?[model npc=%2$d border=1 float=right][/model]:;', null], - SAI_ACTION_SOUND => ['Spiele Audio(%2$d)? für auslösenden Spieler:;:[div float=right width=270px][sound=%1$d][/div]', 'Abgespielt durch Welt.'], - SAI_ACTION_PLAY_EMOTE => ['Emote [emote=%1$d] zu #target#.', null], - SAI_ACTION_FAIL_QUEST => ['[quest=%1$d] von #target# schlägt fehl.', null], - SAI_ACTION_OFFER_QUEST => ['(%2$d)?Füge [quest=%1$d] dem Log von #target# hinzu:Biete [quest=%1$d] #target# an;.', null], - SAI_ACTION_SET_REACT_STATE => ['#target# wird %7$s.', null], - SAI_ACTION_ACTIVATE_GOBJECT => ['#target# wird aktiviert.', null], -/* 10*/ SAI_ACTION_RANDOM_EMOTE => ['Emote %7$s zu #target#.', null], - SAI_ACTION_CAST => ['Wirke [spell=%1$d] auf #target#.', null], - SAI_ACTION_SUMMON_CREATURE => ['Beschwöre [npc=%1$d](%4$d)?, den Auslöser angreifend:;(%3$d)? für %7$s:.;', null], - SAI_ACTION_THREAT_SINGLE_PCT => ['Ändere Bedrohung von #target# um %7$d%%.', null], - SAI_ACTION_THREAT_ALL_PCT => ['Ändere Bedrohung aller Ziele um %7$d%%.', null], - SAI_ACTION_CALL_AREAEXPLOREDOREVENTHAPPENS => ['Erfülle Entdeckungsereignis von [quest=%1$d] für #target#.', null], - SAI_ACTION_SET_EMOTE_STATE => ['Emote [emote=%1$d] kontinuierlich zu #target#.', null], - SAI_ACTION_SET_UNIT_FLAG => ['Setze (%2$d)?UnitFlags2:UnitFlags; %7$s.', null], - SAI_ACTION_REMOVE_UNIT_FLAG => ['Setze (%2$d)?UnitFlags2:UnitFlags; %7$s zurück.', null], -/* 20*/ SAI_ACTION_AUTO_ATTACK => ['(%1$d)?Beginne:Beende; automatische Angriffe gegen #target#.', null], - SAI_ACTION_ALLOW_COMBAT_MOVEMENT => ['(%1$d)?Erlaube:Verbiete; Bewegung im Kampf.', null], - SAI_ACTION_SET_EVENT_PHASE => ['Setze Ereignisphase von #target# auf [b]%1$d[/b].', null], - SAI_ACTION_INC_EVENT_PHASE => ['(%1$d)?Inkrementiere:Dekrementiere; Ereignisphase von #target#.', null], - SAI_ACTION_EVADE => ['#target# entkommt (%1$d)?zu letzten gespeicherten Position:zum Respawnpunkt;.', null], - SAI_ACTION_FLEE_FOR_ASSIST => ['Fliehe nach Hilfe.', 'Benutze Standard Flucht-Emote'], - SAI_ACTION_CALL_GROUPEVENTHAPPENS => ['Erfülle Ziel von [quest=%1$d] für #target#.', null], - SAI_ACTION_COMBAT_STOP => ['Beende aktuellen Kampf.', null], - SAI_ACTION_REMOVEAURASFROMSPELL => ['Entferne (%1$d)?alle Auren:Aura [spell=%1$d]; von #target#.', 'Nur eigene Auren'], - SAI_ACTION_FOLLOW => ['Folge #target#(%1$d)? mit %1$dm Abstand:;(%3$d)? bis zum Erreichen von [npc=%3$d]:;.', '(%7$d)?Winkel: %7$.2f°:;(%8$d)? Eine Form von Questziel wird erfüllt:;'], -/* 30*/ SAI_ACTION_RANDOM_PHASE => ['Wähle zufällige Ereignisphase aus %7$s.', null], - SAI_ACTION_RANDOM_PHASE_RANGE => ['Wähle zufällige Ereignisphase zwischen %1$d und %2$d.', null], - SAI_ACTION_RESET_GOBJECT => ['Setze #target# zurück.', null], - SAI_ACTION_CALL_KILLEDMONSTER => ['Ein Tod von [npc=%1$d] wird #target# zugeschrieben.', null], - SAI_ACTION_SET_INST_DATA => ['Setze Instanz (%3$d)?Boss State:Datenfeld; #[b]%1$d[/b] auf [b]%2$d[/b].', null], - null, // SMART_ACTION_SET_INST_DATA64 = 35 - SAI_ACTION_UPDATE_TEMPLATE => ['Transformiere zu [npc=%1$d](%2$d)? mit Stufe [b]%2$d[/b]:;.', null], - SAI_ACTION_DIE => ['Stirb!', null], - SAI_ACTION_SET_IN_COMBAT_WITH_ZONE => ['Beginne Kampf mit allen Einheiten in der Zone.', null], - SAI_ACTION_CALL_FOR_HELP => ['Rufe nach Hilfe.', 'Benutze Standard Hilfe-Emote'], -/* 40*/ SAI_ACTION_SET_SHEATH => ['Stecke %7$s -waffen ein.', null], - SAI_ACTION_FORCE_DESPAWN => ['Entferne #target#(%1$d)? nach %7$s:;(%2$d)? und setze es nach %8$s wieder ein.:;', null], - SAI_ACTION_SET_INVINCIBILITY_HP_LEVEL => ['Werde unverwundbar mit weniger als (%2$d)?%2$d%%:%1$d; Gesundheit.', null], - SAI_ACTION_MOUNT_TO_ENTRY_OR_MODEL => ['Sitze (%7$d)?auf:ab; (%1$d)?[npc=%1$d].:;(%2$d)?[model npc=%2$d border=1 float=right][/model]:;', null], - SAI_ACTION_SET_INGAME_PHASE_MASK => ['Setze Sichtbarkeit von #target# auf Phase %7$s.', null], - SAI_ACTION_SET_DATA => ['[b]%2$d[/b] wird in Datenfeld #[b]%1$d[/b] von #target# abgelegt.', null], - SAI_ACTION_ATTACK_STOP => ['Beende Angriff.', null], - SAI_ACTION_SET_VISIBILITY => ['#target# wird (%1$d)?sichtbar:unsichtbar;.', null], - SAI_ACTION_SET_ACTIVE => ['#target# kann(%1$d)?: keine; Grids aktivieren.', null], - SAI_ACTION_ATTACK_START => ['Greife #target# an.', null], -/* 50*/ SAI_ACTION_SUMMON_GO => ['Beschwöre [object=%1$d] bei #target#(%2$d)? für %7$s:.;', 'Verschwinden an Beschwörer geknüpft'], - SAI_ACTION_KILL_UNIT => ['#target# stirbt!', null], - SAI_ACTION_ACTIVATE_TAXI => ['Fliege von [span class=q1]%7$s[/span] nach [span class=q1]%8$s[/span]', null], - SAI_ACTION_WP_START => ['(%1$d)?Gehe:Renne; auf Pfad #[b]%2$d[/b].(%4$d)? Verknüpft mit [quest=%4$d].:; Reagiere auf dem Pfad %8$s.(%5$d)? Verschwinde nach %7$s:;', 'Wiederholbar'], - SAI_ACTION_WP_PAUSE => ['Pausiere Pfad für %7$s.', null], - SAI_ACTION_WP_STOP => ['Beende Pfad(%1$d)? und verschwinde nach %7$s:.;(%8$d)? [quest=%2$d] schlägt fehl.:;(%9$d)? [quest=%2$d] wird abgeschlossen.:;', null], - SAI_ACTION_ADD_ITEM => ['Gib #target# %2$d [item=%1$d].', null], - SAI_ACTION_REMOVE_ITEM => ['Nimm %2$d [item=%1$d] von #target#.', null], - SAI_ACTION_INSTALL_AI_TEMPLATE => ['Verhalten als %7$s.', null], - SAI_ACTION_SET_RUN => ['(%1$d)?Renne:Gehe;.', null], -/* 60*/ SAI_ACTION_SET_DISABLE_GRAVITY => ['(%1$d)?Ignoriere:Berücksichtige; Scherkraft!', null], - SAI_ACTION_SET_SWIM => ['Kann(%1$d)?: nicht; schwimmen.', null], - SAI_ACTION_TELEPORT => ['#target# wird nach [zone=%7$d] teleportiert.', null], - SAI_ACTION_SET_COUNTER => ['(%3$d)?Setze:Erhöhe; Zähler #[b]%1$d[/b] von #target# (%3$d)?zurück:um [b]%2$d[/b];.', null], - SAI_ACTION_STORE_TARGET_LIST => ['Speichere #target# als Ziel in #[b]%1$d[/b].', null], - SAI_ACTION_WP_RESUME => ['Setze Pfad fort.', null], - SAI_ACTION_SET_ORIENTATION => ['Richte nach (%7$s)?%7$s:\'Heimat\'-Position; aus.', null], - SAI_ACTION_CREATE_TIMED_EVENT => ['(%8$d)?%6$d%% Chance:Löse; Verzögertes Ereignis #[b]%1$d[/b](%7$s)? nach %7$s:; (%8$d)?auszulösen:aus;.', 'Wiederhole alle %s'], - SAI_ACTION_PLAYMOVIE => ['Spiele Video #[b]%1$d[/b] für #target# ab.', null], - SAI_ACTION_MOVE_TO_POS => ['Bewege (%4$d)?innerhalb von %4$dm von:nach; Punkt #[b]%1$d[/b] bei #target#(%2$d)? auf einerm Transporter:;.', 'ohne Wegfindung'], -/* 70*/ SAI_ACTION_ENABLE_TEMP_GOBJ => ['#target# wird für %7$s wieder eingesetzt.', null], - SAI_ACTION_EQUIP => ['(%8$d)?Lege nicht-standard Ausrüstung ab:Rüste %7$s;(%1$d)? vom Ausrüstungs-Template #[b]%1$d[/b]:; an #target#(%8$d)?: aus;.', 'Hinweis: Gegenstände für Kreaturen haben nicht zwingend ein Gegenstands-Template'], - SAI_ACTION_CLOSE_GOSSIP => ['Schließe Gossip-Fenster.', null], - SAI_ACTION_TRIGGER_TIMED_EVENT => ['Löse Verzögertes Ereignis #[b]%1$d[/b] aus.', null], - SAI_ACTION_REMOVE_TIMED_EVENT => ['Lösche Verzögertes Ereignis #[b]%1$d[/b].', null], - SAI_ACTION_ADD_AURA => ['Wende Aura von [spell=%1$d] auf #target# an.', null], - SAI_ACTION_OVERRIDE_SCRIPT_BASE_OBJECT => ['Benutze #target# als Basis für alle weiteren SmartAI-Ereignisse.', null], - SAI_ACTION_RESET_SCRIPT_BASE_OBJECT => ['Setze Basis für SmartAI-Ereignisse zurück.', null], - SAI_ACTION_CALL_SCRIPT_RESET => ['Setze aktuelles SmartAI zurück.', null], - SAI_ACTION_SET_RANGED_MOVEMENT => ['Setze Abstand für Fernkampf auf [b]%1$d[/b]m(%2$d)?, %2$d°:;.', null], -/* 80*/ SAI_ACTION_CALL_TIMED_ACTIONLIST => ['Rufe [html]Timed Actionlist #%1$d[/html] auf. Läuft %7$s ab.', null], - SAI_ACTION_SET_NPC_FLAG => ['Setze NpcFlags von #target# auf %7$s.', null], - SAI_ACTION_ADD_NPC_FLAG => ['Füge NpcFlags %7$s #target# hinzu.', null], - SAI_ACTION_REMOVE_NPC_FLAG => ['Entferne NpcFlags %7$s von #target#.', null], - SAI_ACTION_SIMPLE_TALK => ['#target# gibt (%7$s)?TextGroup:[span class=q10]unbekannten Text[/span]; #[b]%1$d[/b] für #target# wieder%7$s', null], - SAI_ACTION_SELF_CAST => ['Selbst wirkt [spell=%1$d] auf #target#.', null], - SAI_ACTION_CROSS_CAST => ['%7$s wirkt [spell=%1$d] auf #target#.', null], - SAI_ACTION_CALL_RANDOM_TIMED_ACTIONLIST => ['Rufe zufällige Timed Actionlist auf: [html]%7$s[/html]', null], - SAI_ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST => ['Rufe zufällige Timed Actionlist aus Zahlenbreich auf: [html]%7$s[/html]', null], - SAI_ACTION_RANDOM_MOVE => ['Bewege #target# zu zufälligem Punkt innerhalb von %1$dm.', null], -/* 90*/ SAI_ACTION_SET_UNIT_FIELD_BYTES_1 => ['Setze UnitFieldBytes1 von #target# auf %7$s.', null], - SAI_ACTION_REMOVE_UNIT_FIELD_BYTES_1 => ['Entferne UnitFieldBytes1 %7$s von #target#.', null], - SAI_ACTION_INTERRUPT_SPELL => ['Unterbreche Wirken (%2$d)?von [spell=%2$d]:vom aktuellen Zauber;.', '(%1$d)?Sofort:Verzögert;'], - SAI_ACTION_SEND_GO_CUSTOM_ANIM => ['Setze Animationsfortschritt auf [b]%1$d[/b].', null], - SAI_ACTION_SET_DYNAMIC_FLAG => ['Setze Dynamic Flag von #target# auf %7$s.', null], - SAI_ACTION_ADD_DYNAMIC_FLAG => ['Füge Dynamic Flag %7$s #target# hinzu.', null], - SAI_ACTION_REMOVE_DYNAMIC_FLAG => ['Entferne Dynamic Flag %7$s von #target#.', null], - SAI_ACTION_JUMP_TO_POS => ['Springe auf feste Position — [b]X: %7$.2f, Y: %8$.2f, Z: %9$.2f, [i]v[/i][sub]xy[/sub]: %1$.2f [i]v[/i][sub]z[/sub]: %2$.2f[/b]', null], - SAI_ACTION_SEND_GOSSIP_MENU => ['Zeige Gossip #[b]%1$d[/b] / TextID #[b]%2$d[/b].', null], - SAI_ACTION_GO_SET_LOOT_STATE => ['Setze Plündern-Status von #target# auf %7$s.', null], -/*100*/ SAI_ACTION_SEND_TARGET_TO_TARGET => ['Sende gespeicherte Ziele aus #[b]%1$d[/b] an #target#.', null], - SAI_ACTION_SET_HOME_POS => ['Setze Heimat-Position auf (%10$d)?aktuelle Position.:feste Position — [b]X: %7$.2f, Y: %8$.2f, Z: %9$.2f[/b];', null], - SAI_ACTION_SET_HEALTH_REGEN => ['(%1$d)?Lasse:Verhindere; Gesundheitsregeneration von #target#(%1$d)? zu:;.', null], - SAI_ACTION_SET_ROOT => ['(%1$d)?Lasse:Verhindere; Bewegung im Kampf von #target#(%1$d)? zu:;.', null], - SAI_ACTION_SET_GO_FLAG => ['Setze Gameobject Flags von #target# auf %7$s.', null], - SAI_ACTION_ADD_GO_FLAG => ['Füge Gameobject Flag %7$s #target# hinzu.', null], - SAI_ACTION_REMOVE_GO_FLAG => ['Entferne Gameobject Flag %7$s von #target#.', null], - SAI_ACTION_SUMMON_CREATURE_GROUP => ['Beschwöre Kreaturengruppe #[b]%1$d[/b](%2$d)?, den Auslöser angreifend:;.[br](%7$s)?[span class=breadcrumb-arrow] [/span]%7$s:[span class=q0][/span];', null], - SAI_ACTION_SET_POWER => ['Setze %7$s von #target# auf [b]%2$d[/b].', null], - SAI_ACTION_ADD_POWER => ['Gib #target# [b]%2$d[/b] %7$s.', null], -/*110*/ SAI_ACTION_REMOVE_POWER => ['Entferne [b]%2$d[/b] %7$s von #target#.', null], - SAI_ACTION_GAME_EVENT_STOP => ['Beende [event=%1$d].', null], - SAI_ACTION_GAME_EVENT_START => ['Starte [event=%1$d].', null], - SAI_ACTION_START_CLOSEST_WAYPOINT => ['#target# beginnt Pfad. Betritt den Pfad am nächstliegenden dieser Punkte: %7$s.', null], - SAI_ACTION_MOVE_OFFSET => ['Bewege zu relativer Position — [b]X: %7$.2f, Y: %8$.2f, Z: %9$.2f[/b]', null], - SAI_ACTION_RANDOM_SOUND => ['Spiele zufälliges Audio(%5$d)? an auslösenden Spieler:;:[div float=right width=270px]%7$s[/div]', 'Abgespielt durch Welt.'], - SAI_ACTION_SET_CORPSE_DELAY => ['Setze Verzögerung für das Verschwindne der Leiche von #target# auf %7$s.', null], - SAI_ACTION_DISABLE_EVADE => ['(%1$d)?Verhindere:Lasse; Entkommen(%1$d)?: zu;.', null], - SAI_ACTION_GO_SET_GO_STATE => ['Setze Gameobject Status auf %7$s.'. null], - SAI_ACTION_SET_CAN_FLY => ['(%1$d)?Lasse:Verhindere; Fliegen(%1$d)? zu:;.', null], -/*120*/ SAI_ACTION_REMOVE_AURAS_BY_TYPE => ['Entferne alle Auren mit [b]%7$s[/b] von #target#.', null], - SAI_ACTION_SET_SIGHT_DIST => ['Setze Sichtweite von #target# auf %1$dm.', null], - SAI_ACTION_FLEE => ['#target# flieht für %7$s und sucht Hilfe.', null], - SAI_ACTION_ADD_THREAT => ['Ändere Bedrohung von #target# um %7$d Punkte.', null], - SAI_ACTION_LOAD_EQUIPMENT => ['(%2$d)?Lege nicht-standart Ausrüstung ab:Rüste %7$s; von Ausrüstungs-Template #[b]%1$d[/b] an #target#(%8$d)?: aus;.', 'Hinweis: Gegenstände für Kreaturen haben nicht zwingend ein Gegenstands-Template'], - SAI_ACTION_TRIGGER_RANDOM_TIMED_EVENT => ['Löse definiertes verzögertes Ereignis innerhalb von %7$s aus.', null], - SAI_ACTION_REMOVE_ALL_GAMEOBJECTS => ['Entferne alle Gameobjects von #target#.', null], - SAI_ACTION_PAUSE_MOVEMENT => ['Pausiere Bewegung aus Slot #[b]%1$d[/b] für %7$s.', 'Erzwungen'], - null, // SAI_ACTION_PLAY_ANIMKIT = 128, // don't use on 3.3.5a - null, // SAI_ACTION_SCENE_PLAY = 129, // don't use on 3.3.5a -/*130*/ null, // SAI_ACTION_SCENE_CANCEL = 130, // don't use on 3.3.5a - SAI_ACTION_SPAWN_SPAWNGROUP => ['Spawne SpawnGroup [b]%7$s[/b] SpawnFlags: %8$s %9$s', 'Abklingzeit: %s'], // Group ID, min secs, max secs, spawnflags - SAI_ACTION_DESPAWN_SPAWNGROUP => ['Despawne SpawnGroup [b]%7$s[/b] SpawnFlags: %8$s %9$s', 'Abklingzeit: %s'], // Group ID, min secs, max secs, spawnflags - SAI_ACTION_RESPAWN_BY_SPAWNID => ['Respawne %7$s [small class=q0](GUID: %2$d)[/small]', null], // spawnType, spawnId - SAI_ACTION_INVOKER_CAST => ['Auslöser wirkt [spell=%1$d] auf #target#.', null], // spellID, castFlags - SAI_ACTION_PLAY_CINEMATIC => ['Gebe Film #[b]%1$d[/b] für #target# wieder.', null], // cinematic - SAI_ACTION_SET_MOVEMENT_SPEED => ['Setze Geschwindigkeit von MotionType #[b]%1$d[/b] auf [b]%7$.2f[/b]', null], // movementType, speedInteger, speedFraction - null, // SAI_ACTION_PLAY_SPELL_VISUAL_KIT', // spellVisualKitId (RESERVED, PENDING CHERRYPICK) - SAI_ACTION_OVERRIDE_LIGHT => ['Ändere Skybox in [zone=%1$d] auf #[b]%2$d[/b].', 'Übergang: %s'], // zoneId, overrideLightID, transitionMilliseconds - SAI_ACTION_OVERRIDE_WEATHER => ['Ändere Wetter in [zone=%1$d] zu %7$s mit %3$d%% Stärke.', null] // zoneId, weatherId, intensity + SmartAction::ACTION_TALK => ['(%3$d)?Gib:#target# gibt; (%11$s)?TextGroup:[span class=q10]unbekannten Text[/span]; #[b]%1$d[/b] (3$d)?für #target#:für den Auslöser; wieder%11$s', 'Dauer: %s'], + SmartAction::ACTION_SET_FACTION => ['Setze Fraktion von #target# (%1$d)?auf [faction=%11$d]:zurück;.', ''], + SmartAction::ACTION_MORPH_TO_ENTRY_OR_MODEL => ['(%11$d)?Setze Aussehen zurück.:Nimm Erscheinungsbild an:;(%1$d)? [npc=%1$d]:;(%2$d)?[model npc=%2$d border=1 float=right][/model]:;', ''], + SmartAction::ACTION_SOUND => ['Spiele Audio für (%2$d)?auslösenden Spieler:alle Spieler im Sichtfeld;:[div][sound=%1$d][/div]', 'Wiedergabe durch Umwelt'], + SmartAction::ACTION_PLAY_EMOTE => ['(%1$d)?Emote [emote=%1$d] zu #target#.:Beende fortlaufenden Emote;', ''], + SmartAction::ACTION_FAIL_QUEST => ['[quest=%1$d] von #target# schlägt fehl.', ''], + SmartAction::ACTION_OFFER_QUEST => ['(%2$d)?Füge [quest=%1$d] dem Log von #target# hinzu:Biete [quest=%1$d] #target# an;.', ''], + SmartAction::ACTION_SET_REACT_STATE => ['#target# wird %11$s.', ''], + SmartAction::ACTION_ACTIVATE_GOBJECT => ['#target# wird aktiviert.', ''], +/* 10*/ SmartAction::ACTION_RANDOM_EMOTE => ['Emote %11$s zu #target#.', ''], + SmartAction::ACTION_CAST => ['Wirke [spell=%1$d] auf #target#.', '%1$s'], + SmartAction::ACTION_SUMMON_CREATURE => ['Beschwöre [npc=%1$d](%4$d)?, den Auslöser angreifend:;(%3$d)? für %11$s:.;', '%1$s'], + SmartAction::ACTION_THREAT_SINGLE_PCT => ['Ändere Bedrohung von #target# um %11$+d%%.', ''], + SmartAction::ACTION_THREAT_ALL_PCT => ['Ändere Bedrohung aller Gegner um %11$+d%%.', ''], + SmartAction::ACTION_CALL_AREAEXPLOREDOREVENTHAPPENS => ['Erfülle Entdeckungsereignis von [quest=%1$d] für #target#.', ''], + SmartAction::ACTION_SET_INGAME_PHASE_ID => null, + SmartAction::ACTION_SET_EMOTE_STATE => ['(%1$d)?Emote [emote=%1$d] kontinuierlich zu #target#:Beende fortlaufenden Emote.;', ''], + SmartAction::ACTION_SET_UNIT_FLAG => ['Setze (%2$d)?UnitFlags2:UnitFlags; %11$s.', ''], + SmartAction::ACTION_REMOVE_UNIT_FLAG => ['Setze (%2$d)?UnitFlags2:UnitFlags; %11$s zurück.', ''], +/* 20*/ SmartAction::ACTION_AUTO_ATTACK => ['(%1$d)?Beginne:Beende; automatische Angriffe gegen #target#.', ''], + SmartAction::ACTION_ALLOW_COMBAT_MOVEMENT => ['(%1$d)?Erlaube:Verbiete; Bewegung im Kampf.', ''], + SmartAction::ACTION_SET_EVENT_PHASE => ['Setze Ereignisphase von #target# auf [b]%1$d[/b].', ''], + SmartAction::ACTION_INC_EVENT_PHASE => ['(%1$d)?Inkrementiere:Dekrementiere; Ereignisphase von #target#.', ''], + SmartAction::ACTION_EVADE => ['#target# entkommt (%1$d)?zur letzten gespeicherten Position:zum Spawnpunkt;.', ''], + SmartAction::ACTION_FLEE_FOR_ASSIST => ['Fliehe nach Hilfe.', 'Benutze Standard Flucht-Emote'], + SmartAction::ACTION_CALL_GROUPEVENTHAPPENS => ['Erfülle Entdeckungsereignis von [quest=%1$d] für Gruppe von #target#.', ''], + SmartAction::ACTION_COMBAT_STOP => ['Beende aktuellen Kampf.', ''], + SmartAction::ACTION_REMOVEAURASFROMSPELL => ['Entferne(%2$d)? %2$d Aufladungen von:;(%1$d)? alle Auren:Aura [spell=%1$d]; von #target#.', 'Nur eigene Auren'], + SmartAction::ACTION_FOLLOW => ['Folge #target#(%1$d)? mit %1$dm Abstand:;(%3$d)? bis zum Erreichen von [npc=%3$d]:;.(%12$d)? Am Ende wird ein Entdeckungsereignis für [quest=%4$d] erfüllt.:;(%13$d)? Am Ende wird ein Tod von [npc=%4$d] gutgeschrieben.:;', '(%11$d)?Folgt im Winkel von %11$.2f°:;'], +/* 30*/ SmartAction::ACTION_RANDOM_PHASE => ['Wähle zufällige Ereignisphase aus %11$s.', ''], + SmartAction::ACTION_RANDOM_PHASE_RANGE => ['Wähle zufällige Ereignisphase zwischen %1$d und %2$d.', ''], + SmartAction::ACTION_RESET_GOBJECT => ['Setze #target# zurück.', ''], + SmartAction::ACTION_CALL_KILLEDMONSTER => ['Ein Tod von [npc=%1$d] wird (%11$s)?%11$s:#target#; gutgeschrieben.', ''], + SmartAction::ACTION_SET_INST_DATA => ['Setze Instanz (%3$d)?Boss State:Datenfeld; #[b]%1$d[/b] auf [b]%2$d[/b].', ''], + SmartAction::ACTION_SET_INST_DATA64 => ['Speichere GUID von #target# in Datenfeld #[b]%1$d[/b] der Instanz', ''], + SmartAction::ACTION_UPDATE_TEMPLATE => ['Transformiere zu [npc=%1$d].', 'Übernehme Stufe von [npc=%1$d]'], + SmartAction::ACTION_DIE => ['Stirb!', ''], + SmartAction::ACTION_SET_IN_COMBAT_WITH_ZONE => ['Beginne Kampf mit allen Einheiten in der Zone.', ''], + SmartAction::ACTION_CALL_FOR_HELP => ['Rufe im Umkreis von %1$dm nach Hilfe.', 'Benutze Standard Hilfe-Emote'], +/* 40*/ SmartAction::ACTION_SET_SHEATH => ['Stecke %11$s -waffen ein.', ''], + SmartAction::ACTION_FORCE_DESPAWN => ['Entferne #target#(%1$d)? nach %11$s:;(%2$d)? und setze es nach %12$s wieder ein.:;', ''], + SmartAction::ACTION_SET_INVINCIBILITY_HP_LEVEL => ['Werde unverwundbar mit weniger als (%2$d)?%2$d%%:%1$d; Gesundheit.', ''], + SmartAction::ACTION_MOUNT_TO_ENTRY_OR_MODEL => ['Sitze (%11$d)?ab:auf; (%1$d)?[npc=%1$d].:;(%2$d)?[model npc=%2$d border=1 float=right][/model]:;', ''], + SmartAction::ACTION_SET_INGAME_PHASE_MASK => ['Setze Sichtbarkeit von #target# auf Phase %11$s.', ''], + SmartAction::ACTION_SET_DATA => ['[b]%2$d[/b] wird in Datenfeld #[b]%1$d[/b] von #target# abgelegt.', ''], + SmartAction::ACTION_ATTACK_STOP => ['Beende Angriff.', ''], + SmartAction::ACTION_SET_VISIBILITY => ['#target# wird (%1$d)?sichtbar:unsichtbar;.', ''], + SmartAction::ACTION_SET_ACTIVE => ['#target# kann(%1$d)?: keine; Grids aktivieren.', ''], + SmartAction::ACTION_ATTACK_START => ['Greife #target# an.', ''], +/* 50*/ SmartAction::ACTION_SUMMON_GO => ['Beschwöre [object=%1$d] bei #target#(%2$d)? für %11$s:.;', 'Verschwinden an Beschwörer geknüpft'], + SmartAction::ACTION_KILL_UNIT => ['#target# stirbt!', ''], + SmartAction::ACTION_ACTIVATE_TAXI => ['Fliege von [span class=q1]%11$s[/span] nach [span class=q1]%12$s[/span]', ''], + SmartAction::ACTION_WP_START => ['(%1$d)?Renne:Gehe; auf Pfad #[b]%2$d[/b].(%4$d)? Verknüpft mit [quest=%4$d].:;(%5$d)? Verschwinde nach %11$s:;', 'Wiederholbar(%12$s)? [DEPRECATED] Reagiere auf dem Pfad %12$s:;'], + SmartAction::ACTION_WP_PAUSE => ['Pausiere Pfad für %11$s.', ''], + SmartAction::ACTION_WP_STOP => ['Beende Pfad(%1$d)? und verschwinde nach %11$s:;. (%2$d)?[quest=%2$d]:Quest vom Pfadanfang; (%3$d)?schlägt fehl:wird abgeschlossen;.', ''], + SmartAction::ACTION_ADD_ITEM => ['Gib #target# %2$d [item=%1$d].', ''], + SmartAction::ACTION_REMOVE_ITEM => ['Nimm %2$d [item=%1$d] von #target#.', ''], + SmartAction::ACTION_INSTALL_AI_TEMPLATE => ['Verhalten als %11$s.', ''], + SmartAction::ACTION_SET_RUN => ['Wähle Bewegung (%1$d)?Rennen:Gehen;.', ''], +/* 60*/ SmartAction::ACTION_SET_DISABLE_GRAVITY => ['(%1$d)?Ignoriere:Berücksichtige; Scherkraft!', ''], + SmartAction::ACTION_SET_SWIM => ['Kann(%1$d)?: nicht; schwimmen.', ''], + SmartAction::ACTION_TELEPORT => ['#target# wird zu [lightbox=map zone=%11$d(%12$s)? pins=%12$s:;]Werltkoordinaten[/lightbox] teleportiert.', ''], + SmartAction::ACTION_SET_COUNTER => ['(%3$d)?Setze:Erhöhe; Zähler #[b]%1$d[/b] von #target# (%3$d)?auf:um; [b]%2$d[/b].', ''], + SmartAction::ACTION_STORE_TARGET_LIST => ['Speichere #target# als Ziel in #[b]%1$d[/b].', ''], + SmartAction::ACTION_WP_RESUME => ['Setze Pfad fort.', ''], + SmartAction::ACTION_SET_ORIENTATION => ['Richte nach (%11$s)?%11$s:\'Heimat\'-Position; aus.', ''], + SmartAction::ACTION_CREATE_TIMED_EVENT => ['(%6$d)?%6$d%% Chance:Löse; Verzögertes Ereignis #[b]%1$d[/b](%11$s)? nach %11$s:; (%6$d)?auszulösen:aus;.', 'Wiederhole alle %s'], + SmartAction::ACTION_PLAYMOVIE => ['Spiele Video #[b]%1$d[/b] für #target# ab.', ''], + SmartAction::ACTION_MOVE_TO_POS => ['Bewege (%4$d)?innerhalb von %4$dm von:nach; Punkt #[b]%1$d[/b] bei #target#(%2$d)? auf einerm Transporter:;.', 'ohne Wegfindung'], +/* 70*/ SmartAction::ACTION_ENABLE_TEMP_GOBJ => ['#target# erscheint für %11$s.', ''], + SmartAction::ACTION_EQUIP => ['(%11$s)?Rüste %11$s:Lege nicht-standard Ausrüstung ab;(%1$d)? vom Ausrüstungs-Template #[b]%1$d[/b]:; an #target#(%12$d)?: aus;.', 'Hinweis: Gegenstände für Kreaturen haben nicht zwingend ein Gegenstands-Template'], + SmartAction::ACTION_CLOSE_GOSSIP => ['Schließe Gossip-Fenster.', ''], + SmartAction::ACTION_TRIGGER_TIMED_EVENT => ['Löse Verzögertes Ereignis #[b]%1$d[/b] aus.', ''], + SmartAction::ACTION_REMOVE_TIMED_EVENT => ['Lösche Verzögertes Ereignis #[b]%1$d[/b].', ''], + SmartAction::ACTION_ADD_AURA => ['Wende Aura von [spell=%1$d] auf #target# an.', ''], + SmartAction::ACTION_OVERRIDE_SCRIPT_BASE_OBJECT => ['Benutze #target# als Basis für alle weiteren SmartAction-Ereignisse.', ''], + SmartAction::ACTION_RESET_SCRIPT_BASE_OBJECT => ['Setze Basis für SmartAction-Ereignisse zurück.', ''], + SmartAction::ACTION_CALL_SCRIPT_RESET => ['Setze aktuelle SmartAI zurück.', ''], + SmartAction::ACTION_SET_RANGED_MOVEMENT => ['Setze Abstand für Fernkampf auf [b]%1$d[/b]m(%2$d)?, %2$d°:;.', ''], +/* 80*/ SmartAction::ACTION_CALL_TIMED_ACTIONLIST => ['Rufe Timed Actionlist [url=#sai-actionlist-%1$d onclick=TalTabClick(%1$d)]#%1$d[/url] auf. Läuft %11$s ab.', ''], + SmartAction::ACTION_SET_NPC_FLAG => ['Setze NpcFlags von #target# auf %11$s.', ''], + SmartAction::ACTION_ADD_NPC_FLAG => ['Füge NpcFlags %11$s #target# hinzu.', ''], + SmartAction::ACTION_REMOVE_NPC_FLAG => ['Entferne NpcFlags %11$s von #target#.', ''], + SmartAction::ACTION_SIMPLE_TALK => ['#target# gibt (%11$s)?TextGroup:[span class=q10]unbekannten Text[/span]; #[b]%1$d[/b] für #target# wieder%11$s', ''], + SmartAction::ACTION_SELF_CAST => ['#target# wirkt [spell=%1$d] auf #target#.(%4$d)? (max. %4$d |4Ziel:Ziele;):;', '%1$s'], + SmartAction::ACTION_CROSS_CAST => ['%11$s wirkt [spell=%1$d] auf #target#.', '%1$s'], + SmartAction::ACTION_CALL_RANDOM_TIMED_ACTIONLIST => ['Rufe zufällige Timed Actionlist auf: %11$s', ''], + SmartAction::ACTION_CALL_RANDOM_RANGE_TIMED_ACTIONLIST => ['Rufe zufällige Timed Actionlist aus Zahlenbreich auf: %11$s', ''], + SmartAction::ACTION_RANDOM_MOVE => ['(%1$d)?Bewege #target# zu zufälligem Punkt innerhalb von %1$dm:#target# beendet zufälllige Bewegung.;', ''], +/* 90*/ SmartAction::ACTION_SET_UNIT_FIELD_BYTES_1 => ['Setze UnitFieldBytes1 von #target# auf %11$s.', ''], + SmartAction::ACTION_REMOVE_UNIT_FIELD_BYTES_1 => ['Entferne UnitFieldBytes1 %11$s von #target#.', ''], + SmartAction::ACTION_INTERRUPT_SPELL => ['Unterbreche Wirken (%2$d)?von [spell=%2$d]:vom aktuellen Zauber;.', '(%1$d)?Inklusive Spontanzauber:; (%3$d)? Inklusive verzögerter Zauber:;'], + SmartAction::ACTION_SEND_GO_CUSTOM_ANIM => ['Setze Animationsfortschritt auf [b]%1$d[/b].', ''], + SmartAction::ACTION_SET_DYNAMIC_FLAG => ['Setze Dynamic Flag von #target# auf %11$s.', ''], + SmartAction::ACTION_ADD_DYNAMIC_FLAG => ['Füge Dynamic Flag %11$s #target# hinzu.', ''], + SmartAction::ACTION_REMOVE_DYNAMIC_FLAG => ['Entferne Dynamic Flag %11$s von #target#.', ''], + SmartAction::ACTION_JUMP_TO_POS => ['Springe auf feste Position — [b]X: %11$.2f, Y: %12$.2f, Z: %13$.2f, [i]v[/i][sub]xy[/sub]: %1$d [i]v[/i][sub]z[/sub]: %2$d[/b]', ''], + SmartAction::ACTION_SEND_GOSSIP_MENU => ['Zeige Gossip #[b]%1$d[/b] / TextID #[b]%2$d[/b].', ''], + SmartAction::ACTION_GO_SET_LOOT_STATE => ['Setze Plündern-Status von #target# auf %11$s.', ''], +/*100*/ SmartAction::ACTION_SEND_TARGET_TO_TARGET => ['Sende gespeicherte Ziele aus #[b]%1$d[/b] an #target#.', ''], + SmartAction::ACTION_SET_HOME_POS => ['Setze Heimat-Position auf (%11$d)?aktuelle Position.:feste Position — [b]X: %11$.2f, Y: %12$.2f, Z: %13$.2f[/b];', ''], + SmartAction::ACTION_SET_HEALTH_REGEN => ['(%1$d)?Lasse:Verhindere; Gesundheitsregeneration von #target#(%1$d)? zu:;.', ''], + SmartAction::ACTION_SET_ROOT => ['(%1$d)?Lasse:Verhindere; Bewegung im Kampf von #target#(%1$d)? zu:;.', ''], + SmartAction::ACTION_SET_GO_FLAG => ['Setze Gameobject Flags von #target# auf %11$s.', ''], + SmartAction::ACTION_ADD_GO_FLAG => ['Füge Gameobject Flag %11$s #target# hinzu.', ''], + SmartAction::ACTION_REMOVE_GO_FLAG => ['Entferne Gameobject Flag %11$s von #target#.', ''], + SmartAction::ACTION_SUMMON_CREATURE_GROUP => ['Beschwöre Kreaturengruppe #[b]%1$d[/b](%2$d)?, den Auslöser angreifend:;.[br](%11$s)?[span class=breadcrumb-arrow] [/span]%11$s:[span class=q0][/span];', ''], + SmartAction::ACTION_SET_POWER => ['Setze %11$s von #target# auf [b]%2$d[/b].', ''], + SmartAction::ACTION_ADD_POWER => ['Gib #target# [b]%2$d[/b] %11$s.', ''], +/*110*/ SmartAction::ACTION_REMOVE_POWER => ['Entferne [b]%2$d[/b] %11$s von #target#.', ''], + SmartAction::ACTION_GAME_EVENT_STOP => ['Beende [event=%1$d].', ''], + SmartAction::ACTION_GAME_EVENT_START => ['Starte [event=%1$d].', ''], + SmartAction::ACTION_START_CLOSEST_WAYPOINT => ['#target# beginnt Pfad. Betritt den Pfad am nächstliegenden dieser Punkte: %11$s.', ''], + SmartAction::ACTION_MOVE_OFFSET => ['Bewege zu relativer Position — [b]X: %12$.2f, Y: %13$.2f, Z: %14$.2f[/b]', ''], + SmartAction::ACTION_RANDOM_SOUND => ['Spiele zufälliges Audio für (%5$d)?auslösenden Spieler:alle Spieler im Sichtfeld;:%11$s', 'Wiedergabe durch Umwelt'], + SmartAction::ACTION_SET_CORPSE_DELAY => ['Setze Verzögerung für das Verschwindne der Leiche von #target# auf %11$s.', 'Zeitfaktor für geplünderte Leichen wird angewendet'], + SmartAction::ACTION_DISABLE_EVADE => ['(%1$d)?Verhindere:Lasse; Entkommen(%1$d)?: zu;.', ''], + SmartAction::ACTION_GO_SET_GO_STATE => ['Setze Gameobject Status auf %11$s.', ''], + SmartAction::ACTION_SET_CAN_FLY => ['(%1$d)?Lasse:Verhindere; Fliegen(%1$d)? zu:;.', ''], +/*120*/ SmartAction::ACTION_REMOVE_AURAS_BY_TYPE => ['Entferne alle Auren mit [b]%11$s[/b] von #target#.', ''], + SmartAction::ACTION_SET_SIGHT_DIST => ['Setze Sichtweite von #target# auf %1$dm.', ''], + SmartAction::ACTION_FLEE => ['#target# flieht für %11$s und sucht Hilfe.', ''], + SmartAction::ACTION_ADD_THREAT => ['Ändere Bedrohung von #target# um %11$+d Punkte.', ''], + SmartAction::ACTION_LOAD_EQUIPMENT => ['(%2$d)?Lege nicht-standard Ausrüstung ab:Rüste %11$s; von Ausrüstungs-Template #[b]%1$d[/b] an #target#(%12$d)?: aus;.', 'Hinweis: Gegenstände für Kreaturen haben nicht zwingend ein Gegenstands-Template'], + SmartAction::ACTION_TRIGGER_RANDOM_TIMED_EVENT => ['Löse definiertes verzögertes Ereignis innerhalb von %11$s aus.', ''], + SmartAction::ACTION_REMOVE_ALL_GAMEOBJECTS => ['Entferne alle Gameobjects von #target#.', ''], + SmartAction::ACTION_PAUSE_MOVEMENT => ['Pausiere Bewegung aus Slot #[b]%1$d[/b] für %11$s.', 'Erzwungen'], + SmartAction::ACTION_PLAY_ANIMKIT => null, + SmartAction::ACTION_SCENE_PLAY => null, +/*130*/ SmartAction::ACTION_SCENE_CANCEL => null, + SmartAction::ACTION_SPAWN_SPAWNGROUP => ['Spawne SpawnGroup [b]%11$s[/b](%12$s)? SpawnFlags %12$s:; %13$s', 'Abklingzeit: %s'], + SmartAction::ACTION_DESPAWN_SPAWNGROUP => ['Despawne SpawnGroup [b]%11$s[/b](%12$s)? SpawnFlags %12$s:; %13$s', 'Abklingzeit: %s'], + SmartAction::ACTION_RESPAWN_BY_SPAWNID => ['Respawne %11$s [small class=q0](GUID: %2$d)[/small]', ''], + SmartAction::ACTION_INVOKER_CAST => ['Auslöser wirkt [spell=%1$d] auf #target#.(%4$d)? (max. %4$d |4Ziel:Ziele;):;', '%1$s'], + SmartAction::ACTION_PLAY_CINEMATIC => ['Gebe Film #[b]%1$d[/b] für #target# wieder.', ''], + SmartAction::ACTION_SET_MOVEMENT_SPEED => ['Setze Geschwindigkeit von MotionType #[b]%1$d[/b] auf [b]%11$.2f[/b]', ''], + SmartAction::ACTION_PLAY_SPELL_VISUAL_KIT => null, + SmartAction::ACTION_OVERRIDE_LIGHT => ['(%3$d)?Ändere Skybox in [zone=%1$d] auf #[b]%3$d[/b]:Setze Skybox in [zone=%1$d] zurück;.', 'Übergang: %s'], + SmartAction::ACTION_OVERRIDE_WEATHER => ['Ändere Wetter in [zone=%1$d] zu %11$s mit %3$d%% Stärke.', ''], +/*140*/ SmartAction::ACTION_SET_AI_ANIM_KIT => null, + SmartAction::ACTION_SET_HOVER => ['(%1$d)?Aktiviere:Deaktiviere; Schweben.', ''], + SmartAction::ACTION_SET_HEALTH_PCT => ['Set health percentage of #target# to %1$d%%.', ''], + SmartAction::ACTION_CREATE_CONVERSATION => null, + SmartAction::ACTION_SET_IMMUNE_PC => ['(%1$d)?Aktiviere:Deaktiviere; #target# Immunität gegen Spieler.', ''], + SmartAction::ACTION_SET_IMMUNE_NPC => ['(%1$d)?Aktiviere:Deaktiviere; #target# Immunität gegen NPCs.', ''], + SmartAction::ACTION_SET_UNINTERACTIBLE => ['(%1$d)?Verhindere:Erlaube; Interaktion mit #target#.', ''], + SmartAction::ACTION_ACTIVATE_GAMEOBJECT => ['Aktiviere Gameobject (Methode: %1$d)', ''], + SmartAction::ACTION_ADD_TO_STORED_TARGET_LIST => ['Füge #target# zur Zieleliste #%1$d hinzu.', ''], + SmartAction::ACTION_BECOME_PERSONAL_CLONE_FOR_PLAYER => null, +/*150*/ SmartAction::ACTION_TRIGGER_GAME_EVENT => null, + SmartAction::ACTION_DO_ACTION => null ), 'targetUNK' => '[span class=q10]unbekanntes Ziel#[b class=q1]%d[/b][/span]', - 'targetTT' => '[b class=q1]TargetType %d[/b][br][table][tr][td]Param1[/td][td=header]%d[/td][/tr][tr][td]Param2[/td][td=header]%d[/td][/tr][tr][td]Param3[/td][td=header]%d[/td][/tr][tr][td]Param4[/td][td=header]%d[/td][/tr][tr][td]X[/td][td=header]%.2f[/td][/tr][tr][td]Y[/td][td=header]%.2f[/td][/tr][tr][td]Z[/td][td=header]%.2f[/td][/tr][tr][td]O[/td][td=header]%.2f[/td][/tr][/table]', + 'targetTT' => '[b class=q1]TargetType %d[/b][br][table][tr][td]Param1[/td][td=header]%d[/td][/tr][tr][td]Param2[/td][td=header]%d[/td][/tr][tr][td]Param3[/td][td=header]%d[/td][/tr][tr][td]Param4[/td][td=header]%d[/td][/tr][tr][td]X[/td][td=header]%17$.2f[/td][/tr][tr][td]Y[/td][td=header]%18$.2f[/td][/tr][tr][td]Z[/td][td=header]%19$.2f[/td][/tr][tr][td]O[/td][td=header]%20$.2f[/td][/tr][/table]', 'targets' => array( - null, - SAI_TARGET_SELF => 'selbst', - SAI_TARGET_VICTIM => 'aktuelles Ziel', - SAI_TARGET_HOSTILE_SECOND_AGGRO => '2. in Aggro', - SAI_TARGET_HOSTILE_LAST_AGGRO => 'letzter in Aggro', - SAI_TARGET_HOSTILE_RANDOM => 'zufälliges Ziel', - SAI_TARGET_HOSTILE_RANDOM_NOT_TOP => 'zufälliges nicht-Tank Ziel', - SAI_TARGET_ACTION_INVOKER => 'Auslöser', - SAI_TARGET_POSITION => 'Weltkoordinaten', - SAI_TARGET_CREATURE_RANGE => '(%1$d)?zufällige Instanz von [npc=%1$d]:beliebige Kreatur; innerhalb von %11$sm(%4$d)? (max. %4$d Ziele):;', -/*10*/ SAI_TARGET_CREATURE_GUID => '(%11$d)?[npc=%11$d]:NPC; mit GUID #%1$d', - SAI_TARGET_CREATURE_DISTANCE => '(%1$d)?zufällige Instanz von [npc=%1$d]:beliebige Kreatur; innerhalb von %11$sm(%3$d)? (max. %3$d Ziele):;', - SAI_TARGET_STORED => 'vorher gespeichertes Ziel', - SAI_TARGET_GAMEOBJECT_RANGE => '(%1$d)?zufällige Instanz von [object=%1$d]:beliebiges Objekt; innerhalb von %11$sm(%4$d)? (max. %4$d Ziele):;', - SAI_TARGET_GAMEOBJECT_GUID => '(%11$d)?[object=%11$d]:Gameobject; mit GUID #%1$d', - SAI_TARGET_GAMEOBJECT_DISTANCE => '(%1$d)?zufällige Instanz von [object=%1$d]:beliebiges Objekt; innerhalb von %11$sm(%3$d)? (max. %3$d Ziele):;', - SAI_TARGET_INVOKER_PARTY => 'Gruppe des Auslösenden', - SAI_TARGET_PLAYER_RANGE => 'zufälliger Spieler innerhalb von %11$sm', - SAI_TARGET_PLAYER_DISTANCE => 'zufälliger Spieler innerhalb von %11$sm', - SAI_TARGET_CLOSEST_CREATURE => 'nächste (%3$d)?tote:lebendige; (%1$d)?[npc=%1$d]:beliebige Kreatur; innerhalb von %11$sm', -/*20*/ SAI_TARGET_CLOSEST_GAMEOBJECT => 'nächstes (%1$d)?[object=%1$d]:beliebiges Gameobject; innerhalb von %11$sm', - SAI_TARGET_CLOSEST_PLAYER => 'nächster Spieler innerhalb von %1$dm', - SAI_TARGET_ACTION_INVOKER_VEHICLE => 'Fahrzeug des Auslösenden', - SAI_TARGET_OWNER_OR_SUMMONER => 'Besitzer oder Beschwörer des Auslösenden', - SAI_TARGET_THREAT_LIST => 'alle Einheiten, die mit mir im Kampf sind', - SAI_TARGET_CLOSEST_ENEMY => 'nächster angreifbarer (%2$d)?Spieler:Gegner; innerhalb von %1$dm', - SAI_TARGET_CLOSEST_FRIENDLY => 'nächster (%2$d)?freundlicher Spieler:freundliche Kreatur; innerhalb von %1$dm', - SAI_TARGET_LOOT_RECIPIENTS => 'alle Spieler mit Lootberechtigung', - SAI_TARGET_FARTHEST => 'am weitesten (%2$d)?entferter, kämpfender Spieler:entferte, kämpfende Kreatur; innerhalb von %1$dm(%3$d)? und im Sichtfeld:;', - SAI_TARGET_VEHICLE_PASSENGER => 'Zusatz in (%1$d)?Sitz %11$s:allen Sitzen; des Fahrzeug des Auslösenden', -/*30*/ SAI_TARGET_CLOSEST_UNSPAWNED_GO => '(%1$d)?zufällige, ungespawnte Instanz von [object=%1$d]:beliebiges, ungespawntes Objekt; innerhalb von %11$sm(%3$d)' + SmartTarget::TARGET_NONE => '[span class=q0][/span]', + SmartTarget::TARGET_SELF => 'selbst', + SmartTarget::TARGET_VICTIM => 'Gegner', + SmartTarget::TARGET_HOSTILE_SECOND_AGGRO => '(%2$d)?Spieler:Einheit;(%11$s)? mit %11$s:;(%1$d)? innerhalb von %1$dm:; an 2. Stelle in Aggro', + SmartTarget::TARGET_HOSTILE_LAST_AGGRO => '(%2$d)?Spieler:Einheit;(%11$s)? mit %11$s:;(%1$d)? innerhalb von %1$dm:; an letzter Stelle in Aggro', + SmartTarget::TARGET_HOSTILE_RANDOM => '(%2$d)?zufälliger Spieler:zufällige Einheit;(%11$s)? mit %11$s:;(%1$d)? innerhalb von %1$dm:;', + SmartTarget::TARGET_HOSTILE_RANDOM_NOT_TOP => '(%2$d)?zufälliger nicht-Tank Spieler:zufällige nicht-Tank Einheit;(%11$s)? mit %11$s:;(%1$d)? innerhalb von %1$dm:;', + SmartTarget::TARGET_ACTION_INVOKER => 'Auslöser', + SmartTarget::TARGET_POSITION => 'Weltkoordinaten', + SmartTarget::TARGET_CREATURE_RANGE => '(%1$d)?Instanz von [npc=%1$d]:beliebige Kreatur; innerhalb von %11$sm(%4$d)? (max. %4$d |4Ziel:Ziele;):;', +/*10*/ SmartTarget::TARGET_CREATURE_GUID => '(%11$d)?[npc=%11$d]:NPC; [small class=q0](GUID: %1$d)[/small]', + SmartTarget::TARGET_CREATURE_DISTANCE => '(%1$d)?Instanz von [npc=%1$d]:beliebige Kreatur;(%2$d)? innerhalb von %2$dm:;(%3$d)? (max. %3$d |4Ziel:Ziele;):;', + SmartTarget::TARGET_STORED => 'vorher gespeichertes Ziel', + SmartTarget::TARGET_GAMEOBJECT_RANGE => '(%1$d)?Instanz von [object=%1$d]:beliebiges Objekt; innerhalb von %11$sm(%4$d)? (max. %4$d |4Ziel:Ziele;):;', + SmartTarget::TARGET_GAMEOBJECT_GUID => '(%11$d)?[object=%11$d]:Gameobject; [small class=q0](GUID: %1$d)[/small]', + SmartTarget::TARGET_GAMEOBJECT_DISTANCE => '(%1$d)?Instanz von [object=%1$d]:beliebiges Objekt;(%2$d)? innerhalb von %2$dm:;(%3$d)? (max. %3$d |4Ziel:Ziele;):;', + SmartTarget::TARGET_INVOKER_PARTY => 'Gruppe des Auslösenden', + SmartTarget::TARGET_PLAYER_RANGE => 'alle Spieler innerhalb von %11$sm', + SmartTarget::TARGET_PLAYER_DISTANCE => 'alle Spieler innerhalb von %1$dm', + SmartTarget::TARGET_CLOSEST_CREATURE => 'näheste (%3$d)?tote:lebendige; (%1$d)?[npc=%1$d]:beliebige Kreatur; innerhalb von (%2$d)?%2$d:100;m', +/*20*/ SmartTarget::TARGET_CLOSEST_GAMEOBJECT => 'nähestes (%1$d)?[object=%1$d]:beliebiges Gameobject; innerhalb von (%2$d)?%2$d:100;m', + SmartTarget::TARGET_CLOSEST_PLAYER => 'nähester Spieler innerhalb von %1$dm', + SmartTarget::TARGET_ACTION_INVOKER_VEHICLE => 'Fahrzeug des Auslösenden', + SmartTarget::TARGET_OWNER_OR_SUMMONER => 'Besitzer oder Beschwörer', + SmartTarget::TARGET_THREAT_LIST => 'alle Einheiten(%1$d)? innerhalb von %1$dm:;, die mit mir im Kampf sind', + SmartTarget::TARGET_CLOSEST_ENEMY => '(%2$d)?nähester angreifbarer Spieler:näheste angreifbare Einheit; innerhalb von %1$dm', + SmartTarget::TARGET_CLOSEST_FRIENDLY => '(%2$d)?nähester freundlicher Spieler:näheste freundliche Einheit; innerhalb von %1$dm', + SmartTarget::TARGET_LOOT_RECIPIENTS => 'alle Spieler mit Lootberechtigung', + SmartTarget::TARGET_FARTHEST => 'am weitesten (%2$d)?entferter, kämpfender Spieler:entferte, kämpfende Einheit; innerhalb von %1$dm(%3$d)? und im Sichtfeld:;', + SmartTarget::TARGET_VEHICLE_PASSENGER => 'Fahrzeugzusatz in (%1$d)?Sitz %11$s:allen Sitzen;', +/*30*/ SmartTarget::TARGET_CLOSEST_UNSPAWNED_GO => '(%1$d)?näheste ungespawnte Instanz von [object=%1$d]:nähestes ungespawntes Objekt; innerhalb von %11$sm(%3$d)' ), 'castFlags' => array( - SAI_CAST_FLAG_INTERRUPT_PREV => 'Unterbreche aktives wirken', - SAI_CAST_FLAG_TRIGGERED => 'Ausgelöst', - SAI_CAST_FLAG_AURA_MISSING => 'Aura fehlt', - SAI_CAST_FLAG_COMBAT_MOVE => 'Bewegung im Kampf' + SmartAI::CAST_FLAG_INTERRUPT_PREV => 'Unterbreche aktives wirken', + SmartAI::CAST_FLAG_TRIGGERED => 'Ausgelöst', + SmartAI::CAST_FLAG_AURA_MISSING => 'Aura fehlt', + SmartAI::CAST_FLAG_COMBAT_MOVE => 'Bewegung im Kampf' ), 'spawnFlags' => array( - SAI_SPAWN_FLAG_IGNORE_RESPAWN => 'Überschreibe und resette Respawnzeit', - SAI_SPAWN_FLAG_FORCE_SPAWN => 'Erzwinge spawn, wenn bereits gespawnt', - SAI_SPAWN_FLAG_NOSAVE_RESPAWN => 'Lösche Respawnzeit bei Despawn' + SmartAI::SPAWN_FLAG_IGNORE_RESPAWN => 'Überschreibe und resette Respawnzeit', + SmartAI::SPAWN_FLAG_FORCE_SPAWN => 'Erzwinge spawn, wenn bereits gespawnt', + SmartAI::SPAWN_FLAG_NOSAVE_RESPAWN => 'Lösche Respawnzeit bei Despawn' ), - 'GOStates' => ['aktiv', 'bereit', 'alternativ aktiv'], - 'summonTypes' => [null, 'Despawn nach Zeit oder wenn Leiche verschwindet', 'Despawn nach Zeit oder beim Sterben', 'Despawn nach Zeit', 'Despawn nach Zeit ausserhalb des Kampfes', 'Despawn beim Sterben', 'Despawn nach Zeit nach dem Tod', 'Despawn wenn Leiche verschwindet', 'Manueller despawn'], - 'aiTpl' => ['einfache KI', 'Zauberer', 'Geschütz', 'passive Kreatur', 'Käfig für Kreatur', 'Kreatur im Käfig'], - 'reactStates' => ['passiv', 'defensiv', 'aggressiv', 'helfend'], - 'sheaths' => ['alle', 'Nahkampf', 'Fernkampf'], - 'saiUpdate' => ['ausserhalb des Kampfes', 'im Kampf', 'immer'], - 'lootStates' => ['Nicht bereit', 'Bereit', 'Aktiviert', 'Gerade deaktiviert'], - 'weatherStates' => ['Schön', 'Nebel', 'Niesel', 'leichter Regen', 'Regen', 'starker Rain', 'leichter Schneefall', 'Schneefall', 'starker Schneefall', 22 => 'leichter Sandsturm', 41=> 'Sandsturm', 42 => 'starker Sandsturm', 86 => 'Gewitter', 90 => 'schwarzer Regen', 106 => 'schwarzer Schneefall'], + 'GOStates' => ['aktiv', 'bereit', 'zerstört'], + 'summonTypes' => [null, 'Despawn nach Zeit oder wenn Leiche verschwindet', 'Despawn nach Zeit oder beim Sterben', 'Despawn nach Zeit', 'Despawn nach Zeit ausserhalb des Kampfes', 'Despawn beim Sterben', 'Despawn nach Zeit nach dem Tod', 'Despawn wenn Leiche verschwindet', 'Manueller despawn'], + 'aiTpl' => ['einfache KI', 'Zauberer', 'Geschütz', 'passive Kreatur', 'Käfig für Kreatur', 'Kreatur im Käfig'], + 'reactStates' => ['passiv', 'defensiv', 'aggressiv', 'helfend'], + 'sheaths' => ['alle', 'Nahkampf', 'Fernkampf'], + 'saiUpdate' => ['ausserhalb des Kampfes', 'im Kampf', 'immer'], + 'lootStates' => ['Nicht bereit', 'Bereit', 'Aktiviert', 'Gerade deaktiviert'], + 'weatherStates' => ['Schön', 'Nebel', 'Niesel', 'leichter Regen', 'Regen', 'starker Rain', 'leichter Schneefall', 'Schneefall', 'starker Schneefall', 22 => 'leichter Sandsturm', 41=> 'Sandsturm', 42 => 'starker Sandsturm', 86 => 'Gewitter', 90 => 'schwarzer Regen', 106 => 'schwarzer Schneefall'], + 'hostilityModes' => ['feindlich', 'nicht-feindlich', ''/*any*/], + 'motionTypes' => ['IdleMotion', 'RandomMotion', 'WaypointMotion', null, 'ConfusedMotion', 'ChaseMotion', 'HomeMotion', 'FlightMotion', 'PointMotion', 'FleeingMotion', 'DistractMotion', 'AssistanceMotion', 'AssistanceDistractMotion', 'TimedFleeingMotion', 'FollowMotion', 'RotateMotion', 'EffectMotion', 'SplineChainMotion', 'FormationMotion'], - 'GOStateUNK' => '[span class=q10]unbekannter Gameobject-Status #[b class=q1]%d[/b][/span]', - 'summonTypeUNK' => '[span class=q10]unbekannter SummonType #[b class=q1]%d[/b][/span]', - 'aiTplUNK' => '[span class=q10]unbekanntes AI-Template #[b class=q1]%d[/b][/span]', - 'reactStateUNK' => '[span class=q10]unbekannter ReactState #[b class=q1]%d[/b][/span]', - 'sheathUNK' => '[span class=q10]unbekannter sheath #[b class=q1]%d[/b][/span]', - 'saiUpdateUNK' => '[span class=q10]unbekannte Updatebedingung #[b class=q1]%d[/b][/span]', - 'lootStateUNK' => '[span class=q10]unbekannter Plündern-Status #[b class=q1]%d[/b][/span]', - 'weatherStateUNK' => '[span class=q10]unbekannter Wetter-Zustand #[b class=q1]%d[/b][/span]', - - 'entityUNK' => '[b class=q10]unbekannte Entität[/b]', + 'GOStateUNK' => '[span class=q10]unbekannter Gameobject-Status #[b class=q1]%d[/b][/span]', + 'summonTypeUNK' => '[span class=q10]unbekannter SummonType #[b class=q1]%d[/b][/span]', + 'aiTplUNK' => '[span class=q10]unbekanntes AI-Template #[b class=q1]%d[/b][/span]', + 'reactStateUNK' => '[span class=q10]unbekannter ReactState #[b class=q1]%d[/b][/span]', + 'sheathUNK' => '[span class=q10]unbekannter sheath #[b class=q1]%d[/b][/span]', + 'saiUpdateUNK' => '[span class=q10]unbekannte Updatebedingung #[b class=q1]%d[/b][/span]', + 'lootStateUNK' => '[span class=q10]unbekannter Plündern-Status #[b class=q1]%d[/b][/span]', + 'weatherStateUNK' => '[span class=q10]unbekannter Wetter-Zustand #[b class=q1]%d[/b][/span]', + 'powerTypeUNK' => '[span class=q10]unbekannte Ressource #[b class=q1]%d[/b][/span]', + 'hostilityModeUNK' => '[span class=q10]unbekannte Gegner-Beziehung #[b class=q1]%d[/b][/span]', + 'motionTypeUNK' => '[span class=q10]unbekannter Bewegungstyp #[b class=q1]%d[/b][/span]', + 'entityUNK' => '[b class=q10]unbekannte Entität[/b]', 'empty' => '[span class=q0][/span]' ), @@ -829,7 +930,6 @@ $lang = array( "Screenshot-Verwalter", "Video-Verwalter", "API-Partner", "Ausstehend" ), // signIn - 'doSignIn' => "Mit Eurem AoWoW-Konto anmelden", 'signIn' => "Anmelden", 'user' => "Benutzername", 'pass' => "Kennwort", @@ -840,85 +940,201 @@ $lang = array( 'accCreate' => 'Noch kein Konto? Jetzt eins erstellen!', // recovery - 'recoverUser' => "Benutzernamenanfrage", - 'recoverPass' => "Kennwort zurücksetzen: Schritt %s von 2", - 'newPass' => "Neues Kennwort", + 'newPass' => "Neues Kennwort:", + 'confNewPass' => "Neues Kennwort bestätigen:", + 'passResetHint' => 'Wenn ihr euer Kennwort nicht mehr wisst, könnt ihr es auf dieser Seite zurücksetzen.', + // 'tokenExpires' => "Das Token wird in %s verfallen.", // creation - 'register' => "Registrierung: Schritt %s von 2", - 'passConfirm' => "Kennwort bestätigen", + 'passConfirm' => "Kennwort bestätigen:", // dashboard - 'ipAddress' => "IP-Adresse", - 'lastIP' => "Letzte bekannte IP", - 'myAccount' => "Mein Account", - 'editAccount' => "Benutze die folgenden Formulare um deine Account-Informationen zu aktualisieren", - 'viewPubDesc' => 'Die Beschreibung in deinem öffentlichen Profil ansehen', + 'ipAddress' => "IP-Adresse: ", + 'lastIP' => "Letzte bekannte IP: ", + // 'myAccount' => "Mein Account", + // 'editAccount' => "Benutze die folgenden Formulare um deine Account-Informationen zu aktualisieren", + // 'viewPubDesc' => 'Die Beschreibung in deinem öffentlichen Profil ansehen', // bans 'accBanned' => "Dieses Konto wurde geschlossen", - 'bannedBy' => "Gebannt durch", - 'ends' => "Endet am", + 'bannedBy' => "Gebannt durch: ", + 'reason' => "Grund: ", + 'ends' => "Endet am: ", 'permanent' => "Der Bann ist permanent", - 'reason' => "Grund", 'noReason' => "Es wurde kein Grund angegeben.", // form-text 'emailInvalid' => "Diese E-Mail-Adresse ist ungültig.", // message_emailnotvalid - 'emailNotFound' => "Die E-Mail-Adresse, die Ihr eingegeben habt, ist mit keinem Konto verbunden.

Falls Ihr die E-Mail-Adresse vergessen habt, mit der Ihr Euer Konto erstellt habt, kontaktiert Ihr bitte ".CFG_CONTACT_EMAIL." für Hilfestellung.", - 'createAccSent' => "Eine Nachricht wurde soeben an %s versandt. Folgt den Anweisungen um euer Konto zu erstellen.", - 'recovUserSent' => "Eine Nachricht wurde soeben an %s versandt. Folgt den Anweisungen um euren Benutzernamen zu erhalten.", - 'recovPassSent' => "Eine Nachricht wurde soeben an %s versandt. Folgt den Anweisungen um euer Kennwort zurückzusetzen.", - 'accActivated' => 'Euer Konto wurde soeben aktiviert.
Ihr könnt euch nun anmelden', 'userNotFound' => "Ein Konto mit diesem Namen existiert nicht.", 'wrongPass' => "Dieses Kennwort ist ungültig.", - // 'accInactive' => "Dieses Konto wurde bisher nicht aktiviert.", - 'loginExceeded' => "Die maximale Anzahl an Anmelde-Versuchen von dieser IP wurde überschritten. Bitte versucht es in %s erneut.", - 'signupExceeded'=> "Die maximale Anzahl an Regustrierungen von dieser IP wurde überschritten. Bitte versucht es in %s erneut.", + // 'accInactive' => "Dieses Konto wurde bisher nicht aktiviert.", 'errNameLength' => "Euer Benutzername muss mindestens 4 Zeichen lang sein.", // message_usernamemin 'errNameChars' => "Euer Benutzername kann nur aus Buchstaben und Zahlen bestehen.", // message_usernamenotvalid 'errPassLength' => "Euer Kennwort muss mindestens 6 Zeichen lang sein.", // message_passwordmin 'passMismatch' => "Die eingegebenen Kennworte stimmen nicht überein.", 'nameInUse' => "Es existiert bereits ein Konto mit diesem Namen.", 'mailInUse' => "Diese E-Mail-Adresse ist bereits mit einem Konto verbunden.", - 'isRecovering' => "Dieses Konto wird bereits wiederhergestellt. Folgt den Anweisungen in der Nachricht oder wartet %s bis das Token verfällt.", 'passCheckFail' => "Die Kennwörter stimmen nicht überein.", // message_passwordsdonotmatch - 'newPassDiff' => "Euer neues Kennwort muss sich von eurem alten Kennwort unterscheiden." // message_newpassdifferent + 'newPassDiff' => "Euer neues Kennwort muss sich von eurem alten Kennwort unterscheiden.", // message_newpassdifferent + 'newMailDiff' => "Eure neue E-Mail-Adresse muss sich von eurer alten E-Mail-Adresse unterscheiden.", // message_newemaildifferent + + // premium avatar manager + 'uploadAvatar' => "Neuen Avatar hochladen", + 'goToManager' => "Zur Avatarverwaltung gehen", + 'manageAvatars' => "Avatare verwalten", + 'avatarSlots' => '%1$d / %2$d Avatarplätze belegt', + 'manageBorders' => "Premium Rahmen verwalten", + 'selectAvatar' => "Wählt einen Avatar zum hochladen.", + 'errTooSmall' => "Euer Avatar muss wenigstens %dpx groß sein.", + 'cropAvatar' => "Ihr könnt Euren Avatar zuschneiden.", + 'avatarSubmit' => "Avatar-Einsendung", + 'reminder' => "Erinnerung", + 'avatarCoC' => "Dass Benutzen von Bildern, die gegen die Regeln verstoßen kann zum Verlust Eures Premium-Status führen.", + + // settings + 'settings' => "Kontoeinstellungen", + 'settingsNote' => "Du kannst einfach die unten stehenden Formulare ausfüllen, um deine Kontodaten zu aktualisieren.", + 'tabGeneral' => "Allgemein", + 'tabPersonal' => "Persönliches", + 'tabCommunity' => "Community", + 'tabPremium' => "Premium", + 'preferences' => "Voreinstellungen", + 'modelviewer' => "Modellviewer", + 'mvNote' => "Vorgegebenes Charaktermodell:", + 'lists' => "Listen", + 'listsNote' => "Zeigt IDs in unterstützten Listen", + 'announcements' => "Bekanntmachungen", + 'annNote' => "Entfernt die Daten von Bekanntmachungen, die du geschlossen hast, damit sie wieder sichtbar werden.", + 'purge' => "Löschen", + 'curPass' => "Derzeitiges Kennwort:", + 'globalLogout' => "Von allen Browsern/Geräten abmelden", + 'curEmail' => "Momentane E-Mail-Adresse:", + 'newEmail' => "Neue E-Mail-Adresse:", + 'userPage' => "Benutzerseite", + 'publicDesc' => "Öffentliche Beschreibung", + 'publicDescNote'=> 'Erzähl uns etwas über dich und deine WoW-Charaktere. Alles, was du hier eingibst, erscheint auf deiner Benutzerseite.', + 'forums' => "Foren", + 'signature' => "Signatur", + 'signatureNote' => "Deine Signatur erscheint unter all deinen Forenbeiträgen.", + 'usernameNote' => "Nutzernamen können nur einmal alle %s geändert werden und müssen 4-16 Zeichen lang sein. Sonderzeichen sind nicht erlaubt.", + 'curName' => "Aktueller Nutzername:", + 'newName' => "Neuer Nutzername:", + 'accDelete' => "Konto löschen", + 'accDeleteNote' => 'Wenn du dein Konto und alle persönlichen Daten vollständig löschen möchtest, dann geh zu unserer Kontolöschung.', + 'avatar' => "Avatar", + 'avatarNote' => "Dein Avatar wird neben all deinen Forenbeiträgen angezeigt.", + 'avWowIcon' => "World of Warcraft-Icon", + 'avWowIconNote' => 'z.B. INV_Axe_54
Tipp: Um den Namen eines Symbols herauszufinden, doppelklickt einfach auf das große Symbol, während ihr auf einer Gegenstands- oder Zauberseite seid. Kopiert den Text anschließend und fügt ihn oben ein.', + 'avIconName' => "Symbolname:", + 'none' => "Keins", + 'preview' => "Vorschau", + 'custom' => "Benutzerdefiniert", + 'premiumStatus' => "Premium Status", + 'status' => "Status", + 'active' => "Activ", + 'inactive' => "Inaktiv", + 'activeCD' => "Ihr müsst bis zum %s warten um euren Nutzernamen erneut zu ändern.", + 'updateMessage' => array( + 'general' => "Deine Einstellungen wurden aktualisiert.", + 'community' => "Eure öffentliche Beschreibung und Forensignatur wurden erfolgreich aktualisiert.", + 'personal' => "Eine Bestätigungsnachricht wurde an %s versandt.", + 'username' => 'Nutzername von %1$s zu %2$s geändert.', + 'avNotFound' => "Symbol nicht gefunden.", + 'avSuccess' => "Euer Avatar wurde erfolgreich aktualisiert.", + 'avNoChange' => "Es wurden keine Änderungen durchgeführt.", + 'av1stUser' => "Glückwunsch! Ihr habt eine einzigartige Auswahl getroffen! /jubeln", + 'avNthUser' => "Zur Eurer Information, Euer Symbol wird bereits von %d anderen Benutzer(n) benutzt." + ), + 'inputbox' => array( + 'head' => array( + 'success' => "Erfolg", + 'error' => "Hoppla!", + 'register' => "Registrierung: Schritt %s von 2", + 'recoverUser' => "Benutzernamenanfrage", + 'recoverPass' => "Kennwort zurücksetzen: Schritt %s von 2", + 'resendMail' => "Bestätigungsmail erneut senden", + 'signin' => "Mit Eurem Konto anmelden" + ), + 'message' => array( + 'accActivated' => 'Euer Konto wurde soeben aktiviert.
Ihr könnt euch nun anmelden', + 'resendMail' => "Wenn Sie sich registriert haben, aber keine Bestätigungs-E-Mail erhalten haben, geben Sie Ihre E-Mail-Adresse unten ein und senden Sie das Formular ab. (Bitte überprüfen Sie Ihre Spam- oder Papierkorb-Ordner, um sicherzustellen, dass die E-Mail nicht versehentlich an der falschen Stelle abgelegt wurde!)", + 'mailChangeOk' => "Ihre E-Mail-Adresse wurde erfolgreich geändert.", + 'mailRevertOk' => "Ihre Anfrage zur Änderung der E-Mail-Adresse wurde storniert/zurückgesetzt.", + 'passChangeOk' => "Ihr Kennwort wurde erfolgreich geändert.", + 'deleteAccSent' => "Eine E-Mail mit einem Bestätigungslink wurde an %s gesendet.", + 'deleteOk' => "Ihr Konto wurde erfolgreich entfernt. Wir hoffen, Sie bald wiederzusehen!

Sie können dieses Fenster jetzt schließen.", + 'deleteCancel' => "Die Kontolöschung wurde abgebrochen.", + 'createAccSent' => 'Eine Nachricht wurde soeben an %s versandt. Folgt einfach den darin enthaltenen Anweisungen, um Euer Konto zu erstellen.

Falls du keine Bestätigungsnachricht erhalten hast klicke hier um eine neue zu senden.
', + 'recovUserSent' => "Eine Nachricht wurde soeben an %s versandt. Folgt einfach den darin enthaltenen Anweisungen, um euren Benutzernamen zu erhalten.", + 'recovPassSent' => "Eine Nachricht wurde soeben an %s versandt. Folgt einfach den darin enthaltenen Anweisungen, um euer Kennwort zurückzusetzen.", + ), + 'error' => array( + 'mailTokenUsed' => 'Dieser Schlüssel zur Änderung der E-Mail-Adresse wurde entweder bereits verwendet oder ist ungültig. Besuchen Sie Ihre Kontoeinstellungen, um es erneut zu versuchen.', + 'passTokenUsed' => 'Dieser Schlüssel zur Änderung des Kennworts wurde entweder bereits verwendet oder ist ungültig. Besuchen Sie Ihre Kontoeinstellungen, um es erneut zu versuchen.', + 'purgeTokenUsed' => 'Dieser Schlüssel zum Löschen des Kontos wurde entweder bereits verwendet oder ist ungültig. Besuchen Sie Ihre Kontoeinstellungen, um es erneut zu versuchen.', + 'passTokenLost' => "Kein Token wurde bereitgestellt. Wenn Sie in einer E-Mail einen Link zum Zurücksetzen des Kennworts erhalten haben, kopieren Sie die gesamte URL (einschließlich des Tokens am Ende) in die Adressleiste Ihres Browsers.", + 'isRecovering' => "Dieses Konto wird bereits wiederhergestellt. Folgt den Anweisungen in der Nachricht oder wartet %s bis das Token verfällt.", + 'loginExceeded' => "Die maximale Anzahl an Anmelde-Versuchen von dieser IP wurde überschritten. Bitte versucht es in %s erneut.", + 'signupExceeded' => "Die maximale Anzahl an Registrierungen von dieser IP wurde überschritten. Bitte versucht es in %s erneut.", + // 'emailNotFound' => "Die E-Mail-Adresse, die Ihr eingegeben habt, ist mit keinem Konto verbunden.

Falls Ihr die E-Mail-Adresse vergessen habt, mit der Ihr Euer Konto erstellt habt, kontaktiert Ihr bitte CFG_CONTACT_EMAIL für Hilfestellung.", + 'emailNotFound' => "Diese E-Mail-Adresse wurde in unserem System nicht gefunden.", + ) + ) ), 'user' => array( 'notFound' => "Der Benutzer \"%s\" wurde nicht gefunden!", 'removed' => "(Entfernt)", - 'joinDate' => "Mitglied seit", - 'lastLogin' => "Letzter Besuch", - 'userGroups' => "Rolle", - 'consecVisits' => "Aufeinanderfolgende Besuche", + 'joinDate' => "Mitglied seit: ", + 'lastLogin' => "Letzter Besuch: ", + 'userGroups' => "Rolle: ", + 'consecVisits' => "Aufeinanderfolgende Besuche: ", 'publicDesc' => "Öffentliche Beschreibung", 'profileTitle' => "Profil von %s", 'contributions' => "Beiträge", - 'uploads' => "Hochladevorgänge", - 'comments' => "Kommentare", - 'screenshots' => "Screenshots", - 'videos' => "Videos", - 'posts' => "Forenbeiträge", - // user mail - 'tokenExpires' => "Das Token wird in %s verfallen.", - 'accConfirm' => ["Kontobestätigung", "Willkommen bei ".CFG_NAME_SHORT."!\r\n\r\nKlicke auf den Link um euren Account zu aktivieren.\r\n\r\n".HOST_URL."?account=signup&token=%s\r\n\r\nFalls Ihr diese Mail nicht angefordert habt kann sie einfach ignoriert werden."], - 'recoverUser' => ["Benutzernamenanfrage", "Folgt diesem Link um euch anzumelden.\r\n\r\n".HOST_URL."?account=signin&token=%s\r\n\r\nFalls Ihr diese Mail nicht angefordert habt kann sie einfach ignoriert werden."], - 'resetPass' => ["Kennwortreset", "Folgt diesem Link um euer Kennwort zurückzusetzen.\r\n\r\n".HOST_URL."?account=forgotpassword&token=%s\r\n\r\nFalls Ihr diese Mail nicht angefordert habt kann sie einfach ignoriert werden."] + 'uploads' => "Hochladevorgänge: ", + 'comments' => "Kommentare: ", + 'screenshots' => "Screenshots: ", + 'videos' => "Videos: ", + 'posts' => "Forenbeiträge: " ), 'emote' => array( + 'id' => "Emote-ID: ", 'notFound' => "Dieses Emote existiert nicht.", - 'self' => "An Euch selbst", - 'target' => "An Andere mit Ziel", - 'noTarget' => "An Andere ohne Ziel", +// 'self' => "An Euch selbst", +// 'target' => "An Andere mit Ziel", +// 'noTarget' => "An Andere ohne Ziel", + 'targeted' => "Benutzt mit Ziel", + 'untargeted' => "Benutzt ohne Ziel", 'isAnimated' => "Besitzt eine Animation", + 'eventSound' => "Ereignis-Klang", 'aliases' => "Aliasse", 'noText' => "Dieses Emote besitzt keinen Text.", + 'noCommand' => "Dieses Emote besitzt keinen /-Befehl. Es kann nicht benutzt werden.", + 'flags' => array( + EMOTE_FLAG_ONLY_STANDING => "Nur im stehen", + EMOTE_FLAG_USE_MOUNT => "Emote benutzt das Reittier", + EMOTE_FLAG_NOT_CHANNELING => "Nicht beim kanalisieren", + EMOTE_FLAG_ANIM_TALK => "Talk anim - Reden", + EMOTE_FLAG_ANIM_QUESTION => "Talk anim - Fragen", + EMOTE_FLAG_ANIM_EXCLAIM => "Talk anim - Ausrufen", + EMOTE_FLAG_ANIM_SHOUT => "Talk anim - Schreien", + EMOTE_FLAG_NOT_SWIMMING => "Nicht beim schwimmen", + EMOTE_FLAG_ANIM_LAUGH => "Talk anim - Lachen", + EMOTE_FLAG_CAN_LIE_ON_GROUND => "Nutzbar im Schlaf/Tot", + EMOTE_FLAG_NOT_FROM_CLIENT => "Nur für Kreaturen", + EMOTE_FLAG_NOT_CASTING => "Nicht beim Zaubern", + EMOTE_FLAG_END_MOVEMENT => "Emote endet Bewegung", + EMOTE_FLAG_INTERRUPT_ON_ATTACK => "Unerbrochen durch Kampf", + EMOTE_FLAG_ONLY_STILL => "Nur in Ruhe", + EMOTE_FLAG_NOT_FLYING => "Nicht im Flug" + ), + 'state' => ['Einmalig', 'Stetiger Zustand', 'Stetiges Emote'] ), 'enchantment' => array( + 'id' => "Verzauberungs-ID: ", + 'notFound' => "Diese Verzauberung existiert nicht.", 'details' => "Details", 'activation' => "Aktivierung", - 'notFound' => "Diese Verzauberung existiert nicht.", 'types' => array( 1 => "Zauber (Auslösung)", 3 => "Zauber (Anlegen)", 7 => "Zauber (Benutzen)", 8 => "Prismatischer Sockel", 5 => "Statistik", 2 => "Waffenschaden", 6 => "DPS", 4 => "Verteidigung" @@ -927,12 +1143,15 @@ $lang = array( 'areatrigger' => array( 'notFound' => "Dieser Areatrigger existiert nicht.", 'foundIn' => "Dieser Areatrigger befindet sich in", + 'unnamed' => "Unbenannter Areatrigger #%d", 'types' => ['Unbenutzt', 'Gasthaus', 'Teleporter', 'Questziel', 'Smarter Trigger', 'Script'] ), 'gameObject' => array( + 'id' => "Objekt-ID: ", 'notFound' => "Dieses Objekt existiert nicht.", - 'cat' => [0 => "Anderes", 9 => "Bücher", 3 => "Behälter", -5 => "Truhen", 25 => "Fischschwärme", -3 => "Kräuter", -4 => "Erzadern", -2 => "Quest", -6 => "Werkzeuge"], - 'type' => [ 9 => "Buch", 3 => "Behälter", -5 => "Truhe", 25 => "", -3 => "Kraut", -4 => "Erzvorkommen", -2 => "Quest", -6 => ""], + 'unnamed' => "Unbenanntes Objekt #%d", + 'cat' => [0 => "Anderes", 3 => "Behälter", 6 => "Fallen", 9 => "Bücher", 25 => "Fischschwärme", -5 => "Truhen", -3 => "Kräuter", -4 => "Erzadern", -2 => "Quest", -6 => "Werkzeuge"], + 'type' => [ 3 => "Behälter", 6 => "", 9 => "Buch", 25 => "", -5 => "Truhe", -3 => "Kraut", -4 => "Erzvorkommen", -2 => "Quest", -6 => ""], 'unkPosition' => "Der Standort dieses Objekts ist nicht bekannt.", 'npcLootPH' => 'Der Behälter %s beinhaltet die Beute vom Kampf gegen %s. Er erscheint nach seinem Tod.', 'key' => "Schlüssel", @@ -944,14 +1163,15 @@ $lang = array( 'foundIn' => "Dieses Objekt befindet sich in", 'restock' => "Wird alle %s wieder aufgefüllt.", 'goFlags' => array( - GO_FLAG_IN_USE => 'In Benutzung', - GO_FLAG_LOCKED => 'Verschlossen', - GO_FLAG_INTERACT_COND => 'Nicht interagierbar', - GO_FLAG_TRANSPORT => 'Transporter', - GO_FLAG_NOT_SELECTABLE => 'Nicht anwählbar', - GO_FLAG_TRIGGERED => 'Ausgelöst', - GO_FLAG_DAMAGED => 'Belagerung beschädigt', - GO_FLAG_DESTROYED => 'Belagerung zerstört' + GO_FLAG_IN_USE => 'In Benutzung', + GO_FLAG_LOCKED => 'Verschlossen', + GO_FLAG_INTERACT_COND => 'Nicht interagierbar', + GO_FLAG_TRANSPORT => 'Transporter', + GO_FLAG_NOT_SELECTABLE => 'Nicht anwählbar', + GO_FLAG_AI_OBSTACLE => 'Ausgelöst', + GO_FLAG_FREEZE_ANIMATION => 'Pausiert Animation', + GO_FLAG_DAMAGED => 'Belagerung beschädigt', + GO_FLAG_DESTROYED => 'Belagerung zerstört' ), 'actions' => array( "None", "Animate Custom 0", "Animate Custom 1", "Animate Custom 2", "Animate Custom 3", @@ -962,38 +1182,39 @@ $lang = array( ) ), 'npc' => array( + 'id' => "NPC-ID: ", 'notFound' => "Dieser NPC existiert nicht.", - 'classification'=> "Einstufung", - 'petFamily' => "Tierart", - 'react' => "Reaktion", - 'worth' => "Wert", + 'classification'=> "Einstufung: %s", + 'petFamily' => "Tierart: ", + 'react' => "Reaktion: %s", + 'worth' => "Wert: %s", 'unkPosition' => "Der Aufenthaltsort dieses NPCs ist nicht bekannt.", - 'difficultyPH' => "Dieser NPC ist ein Platzhalter für einen anderen Modus von", + 'difficultyPH' => 'Dieser NPC ist ein Platzhalter für einen anderen Modus von %2$s.', 'seat' => "Sitz", 'accessory' => "Zusätze", 'accessoryFor' => "Dieser NPC ist Zusatz für Fahrzeug", - 'quotes' => "Zitate", - 'gainsDesc' => "Nach dem Töten dieses NPCs erhaltet Ihr", + 'quotes' => "Zitate (%d)", + 'gainsDesc' => "Nach dem Töten dieses NPCs erhaltet Ihr: ", 'repWith' => "Ruf mit der Fraktion", 'stopsAt' => "Endet bei %s", 'vehicle' => "Fahrzeug", 'stats' => "Werte", - 'melee' => "Nahkampf", - 'ranged' => "Fernkampf", - 'armor' => "Rüstung", - 'resistances' => "Widerstände", + 'melee' => "Nahkampf: ", + 'ranged' => "Fernkampf: ", + 'armor' => "Rüstung: ", + 'resistances' => "Widerstände: ", 'foundIn' => "Dieser NPC befindet sich in", 'tameable' => "Zähmbar (%s)", 'waypoint' => "Wegpunkt", 'wait' => "Wartezeit", - 'respawnIn' => "Wiedereinstieg in", + 'respawnIn' => "Respawn in: %s", + 'despawnAfter' => "Gespawnt durch Script
Despawn nach: %s", 'rank' => [0 => "Normal", 1 => "Elite", 4 => "Rar", 2 => "Rar Elite", 3 => "Boss"], 'textRanges' => [null, "an das Gebiet gesendet", "an die Zone gesendet", "an die Map gesendet", "an die Welt gesendet"], 'textTypes' => [null, "schreit", "sagt", "flüstert"], - 'modes' => array( - 1 => ["Normal", "Heroisch"], - 2 => ["10-Spieler Normal", "25-Spieler Normal", "10-Spieler Heroisch", "25-Spieler Heroisch"] - ), + 'mechanicimmune'=> 'Nicht anfällig für Mechanik: %s', + '_extraFlags' => 'Extra Flags: ', + 'versions' => 'Schwierigkeitsgrade: ', 'cat' => array( "Nicht kategorisiert", "Wildtiere", "Drachkin", "Dämonen", "Elementare", "Riesen", "Untote", "Humanoide", "Tiere", "Mechanisch", "Nicht spezifiziert", "Totems", "Haustiere", "Gaswolken" @@ -1023,29 +1244,57 @@ $lang = array( NPC_FLAG_GUILD_BANK => 'Gildenbank', NPC_FLAG_SPELLCLICK => 'Zauber-Klick', NPC_FLAG_MAILBOX => 'Briefkasten' + ), + 'extraFlags' => array( + CREATURE_FLAG_EXTRA_INSTANCE_BIND => 'Bindet Angreifer im Tod an die Instanz', + CREATURE_FLAG_EXTRA_CIVILIAN => "[tooltip name=civilian]- hat keine Aggro\n- Tod kostet Ehre[/tooltip][span class=tip tooltip=civilian]Zivilist[/span]", + CREATURE_FLAG_EXTRA_NO_PARRY => 'Kann nicht [spell=3127]', + CREATURE_FLAG_EXTRA_NO_PARRY_HASTEN => 'Erhält keine Eile nach [spell=3127]', + CREATURE_FLAG_EXTRA_NO_BLOCK => 'Kann nicht [spell=107]', + CREATURE_FLAG_EXTRA_NO_CRUSHING_BLOWS => 'Kann keine schmetternden Schläge verursachen', + CREATURE_FLAG_EXTRA_NO_XP => 'Belohnt keine Erfahrung', + CREATURE_FLAG_EXTRA_TRIGGER => 'Auslöser NPC', + CREATURE_FLAG_EXTRA_NO_TAUNT => 'Immun gegen Spott', + // CREATURE_FLAG_EXTRA_NO_MOVE_FLAGS_UPDATE => '', // ?? + CREATURE_FLAG_EXTRA_GHOST_VISIBILITY => '[tooltip name=spirit]Nur für tote Spieler sichtbar[/tooltip][span class=tip tooltip=spirit]Geist[/span]', + CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK => 'Benutzt [spell=674]', + CREATURE_FLAG_EXTRA_NO_SELL_VENDOR => 'Händler kauft nicht vom Spieler', + CREATURE_FLAG_EXTRA_IGNORE_COMBAT => 'Kann nicht in einen Kampf verwickelt werden', + CREATURE_FLAG_EXTRA_WORLDEVENT => 'Gehört zu Weltereignis', + CREATURE_FLAG_EXTRA_GUARD => "[tooltip name=guard]- greift PvP-Angreifer an\n- ignoriert Unsichtbarkeit, Verstohlenheit und [spell=5384][/tooltip][span class=tip tooltip=guard]Wache[/span]", + CREATURE_FLAG_EXTRA_IGNORE_FEIGN_DEATH => 'Ignoriert [spell=5384]', + CREATURE_FLAG_EXTRA_NO_CRIT => 'Kann keine kritischen Treffer verursachen', + CREATURE_FLAG_EXTRA_NO_SKILL_GAINS => 'Angreifer erhält keine Waffenfertigkeit', + CREATURE_FLAG_EXTRA_OBEYS_TAUNT_DIMINISHING_RETURNS => 'Spott hat abnehmende Wirkung', + CREATURE_FLAG_EXTRA_ALL_DIMINISH => 'Alle Mechaniken haben abnehmende Wirkung', + CREATURE_FLAG_EXTRA_NO_PLAYER_DAMAGE_REQ => 'Angreifender Spieler ist immer lootberechtigt', + // CREATURE_FLAG_EXTRA_DUNGEON_BOSS => '', // set during runtime + CREATURE_FLAG_EXTRA_IGNORE_PATHFINDING => 'Ignoriert Wegfindung', + CREATURE_FLAG_EXTRA_IMMUNITY_KNOCKBACK => 'Immung gegen Rückstoß' ) ), 'event' => array( + 'id' => "Weltereignis-ID: ", 'notFound' => "Dieses Weltereignis existiert nicht.", - 'start' => "Anfang", - 'end' => "Ende", - 'interval' => "Intervall", + 'start' => "Anfang: ", + 'end' => "Ende: ", + 'interval' => "Intervall: ", 'inProgress' => "Ereignis findet gerade statt", 'category' => ["Nicht kategorisiert", "Feiertage", "Wiederkehrend", "Spieler vs. Spieler"] ), 'achievement' => array( + 'id' => "Erfolgs-ID: ", 'notFound' => "Dieser Erfolg existiert nicht.", 'criteria' => "Kriterien", 'points' => "Punkte", 'series' => "Reihe", - 'outOf' => "von", 'criteriaType' => "Criterium Typ-Id:", 'itemReward' => "Ihr bekommt", 'titleReward' => 'Euch wird der Titel "%s" verliehen', 'slain' => "getötet", - 'reqNumCrt' => "Benötigt", + 'reqNumCrt' => 'Benötigt %1$d von %2$d', 'rfAvailable' => "Verfügbar auf Realm: ", - '_transfer' => 'Dieser Erfolg wird mit %s vertauscht, wenn Ihr zur %s wechselt.', + '_transfer' => 'Dieser Erfolg wird mit %s vertauscht, wenn Ihr zur %s wechselt.', 'cat' => array( 1 => "Statistiken", 21 => "Spieler gegen Spieler", 81 => "Heldentaten", 92 => "Allgemein", @@ -1093,11 +1342,13 @@ $lang = array( ) ), 'chrClass' => array( + 'id' => "Klassen-ID: ", 'notFound' => "Diese Klasse existiert nicht." ), 'race' => array( + 'id' => "Volks-ID: ", 'notFound' => "Dieses Volk existiert nicht.", - 'racialLeader' => "Volksanführer", + 'racialLeader' => "Volksanführer: ", 'startZone' => "Startgebiet", ), 'maps' => array( @@ -1116,9 +1367,10 @@ $lang = array( 'Miscellaneous' => "Diverse", 'Azeroth' => "Azeroth", 'CosmicMap' => "Kosmische Karte", + 'floorN' => "%d. Stockwerk" ), 'privileges' => array( - 'main' => "Auf unserer Seite könnt Ihr Ruf erringen. Hauptsächlich erringt man Ruf dadurch, dass Eure Kommentare positiv bewertet werden.

Das heißt, Euer Ruf hängt in gewissem Maße davon ab, wie sehr Ihr der Community beiträgt.

Mit dem Sammeln von Ruf verdient Ihr Euch auch das Vertrauen der Gemeinschaft ein, und Ihr erhält Privilegien. Unten könnt Ihr eine vollständige Liste einsehen.", + 'main' => "Auf unserer Seite könnt Ihr Ruf erringen. Hauptsächlich erringt man Ruf dadurch, dass Eure Kommentare positiv bewertet werden.

Das heißt, Euer Ruf hängt in gewissem Maße davon ab, wie sehr Ihr der Community beiträgt.

Mit dem Sammeln von Ruf verdient Ihr Euch auch das Vertrauen der Gemeinschaft ein, und Ihr erhält Privilegien. Unten könnt Ihr eine vollständige Liste einsehen.", 'privilege' => "Privileg", 'privileges' => "Privilegien", 'requiredRep' => "Benötigter Ruf", @@ -1132,87 +1384,56 @@ $lang = array( ) ), 'zone' => array( + 'id' => "Gebiets-ID: ", 'notFound' => "Dieses Gebiet existiert nicht.", - 'attunement' => ["Einstimmung", "Heroische Einstimmung"], - 'key' => ["Schlüssel", "Heroischer Schlüssel"], - 'location' => "Ort", - 'raidFaction' => "Schlachtzugsfraktion", - 'boss' => "Endboss", + 'attunement' => ["Einstimmung: ", "Heroische Einstimmung: "], + 'key' => ["Schlüssel: ", "Heroischer Schlüssel: "], + 'location' => "Ort: ", + 'faction' => "Fraktion: ", + 'factions' => "Fraktionen: ", + 'raidFaction' => "Schlachtzugsfraktion: ", + 'reputationHub' => "Reputation Hub: ", + 'boss' => "Endboss: ", 'reqLevels' => "Mindeststufe: [tooltip=instancereqlevel_tip]%d[/tooltip], [tooltip=lfgreqlevel_tip]%d[/tooltip]", 'zonePartOf' => "Diese Zone ist Teil von [zone=%d].", 'autoRez' => "Automatische Wiederbelebung", 'city' => "Stadt", - 'territory' => "Territorium", - 'instanceType' => "Instanzart", + 'territory' => "Territorium: ", + 'instanceType' => "Instanzart: ", 'hcAvailable' => "Heroischer Modus verfügbar (%d)", - 'numPlayers' => "Anzahl an Spielern", + 'numPlayers' => 'Anzahl an Spielern: %1$s', + 'numPlayersVs' => 'Anzahl an Spielern: %1$dv%1$d', 'noMap' => "Für dieses Gebiet steht keine Karte zur Verfügung.", + 'fishingSkill' => "25 – 100% Chance einen gelisteten Fisch zu fangen.", 'instanceTypes' => ["Zone", "Durchgang", "Dungeon", "Schlachtzug", "Battleground", "Dungeon", "Arena", "Schlachtzug", "Schlachtzug"], 'territories' => ["Allianz", "Horde", "Umkämpft", "Sicheres Gebiet", "PvP", "Welt-PvP"], 'cat' => array( "Östliche Königreiche", "Kalimdor", "Dungeons", "Schlachtzüge", "Unbenutzt", null, "Schlachtfelder", null, "Scherbenwelt", "Arenen", "Nordend" - ), - 'floors' => array( - 206 => ["Vorbereitung der Norndir", "Aufstieg der Drachenschinder", "Tyrs Terrasse"], - 209 => ["Der Hof", "Speisesaal", "Die Verwaiste Höhle", "Das Tiefere Observatorium", "Das Obere Observatorium", "Lord Godfreys Kammer", "Der Wehrgang"], - 719 => ["Der Teich von Ask'Ar", "Mondschreinsanktum", "Der Vergessene Teich"], - 721 => ["Die Halle der Zahnräder", "Der Schlafsaal", "Startrampe", "Tüftlerhof"], - 796 => ["Friedhof", "Bibliothek", "Waffenkammer", "Kathedrale"], - 1196 => ["Untere Spitze", "Obere Spitze"], - 1337 => ["Halle der Bewahrer", "Khaz'goroths Sitz"], - 1581 => ["Die Todesminen", "Eiserne Bucht"], - 1583 => ["Tazz'Alaor", "Listspinnertunnel", "Hordemar", "Schwarzfausthalle", "Drachenspitzhalle", "Der Krähenhorst", "Schwarzfelsstadion"], - 1584 => ["Gefängnisblock", "Die Schattenschmiede"], - 2017 => ["Kreuzzüglerplatz", "Der Spießrutenlauf"], - 2057 => ["Das Reliquiarium", "Kammer der Beschwörung", "Das Arbeitszimmer des Direktors", "Familiengruft der Barovs"], - 2100 => ["Höhlen von Maraudon", "Zaetars Grab"], - 2557 => ["Gordokhallen", "Hauptstadtgärten", "Hof der Hochgeborenen", "Das Gefängnis von Immol'thar", "Wucherborkenviertel", "Der Schrein von Eldretharr"], - 2677 => ["Garnison des Drachenmals", "Hallen des Zwists", "Die Blutroten Labore", "Nefarians Unterschlupf"], - 3428 => ["Untergrund des Schwarmbaus", "Die Tempeltore", "Höhle von C'Thun"], - 3457 => ["Bedienstetenunterkünfte", "Obere Nobelställe", "Der Bankettsaal", "Die Gästezimmer", "Balkon des Opernsaals", "Die Terrasse des Meisters", "Untere Eingestürzte Treppe", "Obere Eingestürzte Treppe", "Die Menagerie", "Bibliothek des Wächters", "Das Warenlager", "Obere Bibliothek", "Die Himmelswacht", "Halle der Spiele", "Medivhs Gemächer", "Die Energiekammer", "Netherraum"], - 3790 => ["Hallen des Jenseits", "Brücke der Seelen"], - 3791 => ["Sethekkversteck", "Hallen der Trauer"], - 3959 => ["Ausbildungsgelände der Illidari", "Kanäle von Karabor", "Zuflucht der Schatten", "Hallen der Pein", "Blutschattens Wacht", "Hof der Irdischen Gelüste", "Kommandoraum", "Tempelspitze"], - 3456 => ["Das Konstruktviertel", "Das Arachnidenviertel", "Das Militärviertel", "Das Seuchenviertel", "Übersicht", "Frostwyrmhort"], - 3715 => ["Die Dampfkammer", "Die Kühlteiche"], - 3848 => ["Stasisblock: Trion", "Stasisblock: Maximus", "Eindämmungskern"], - 3849 => ["Die Mechanar", "Berechnungskammer"], - 4075 => ["Sonnenbrunnenplateau", "Schrein der Finsternis"], - 4100 => ["Außerhalb von Stratholme", "Stratholme"], - 4131 => ["Zuflucht des Großmagisters", "Beobachtungsplatz"], - 4196 => ["Die Vorhallen von Drak'Tharon", "Aussichtspunkt von Drak'Tharon"], - 4228 => ["Band der Varianz", "Band der Akzeleration", "Band der Transmutation", "Band der Angleichung"], - 4272 => ["Die unnachgiebige Garnison", "Straße der Schöpfer"], - 4273 => ["Der große Vorstoß", "Die Vorkammer von Ulduar", "Das innere Sanktum von Ulduar", "Das Gefängnis von Yogg-Saron", "Der Funke der Imagination", "Das Gedankenauge"], - 4277 => ["Die Brutgrube", "Hadronox' Hort", "Das vergoldete Tor"], - 4395 => ["Dalaran", "Die Schattenseite"], - 4494 => ["Ahn'kahet", "2. Stockwerk"], - 4722 => ["Kolosseum der Kreuzfahrer", "Die eisigen Tiefen"], - 4812 => ["Die untere Zitadelle", "Das Schädelbollwerk", "Dom des Todbringers", "Hort der Frostkönigin", "Der obere Bereich", "Königliche Quartiere", "Der Frostthron", "Frostgram"] ) ), 'quest' => array( + 'id' => "Quest-ID: ", 'notFound' => "Diese Quest existiert nicht.", '_transfer' => 'Dieses Quest wird mit %s vertauscht, wenn Ihr zur %s wechselt.', 'questLevel' => "Stufe %s", 'requirements' => "Anforderungen", - 'reqMoney' => "Benötigtes Geld", + 'reqMoney' => "Benötigtes Geld: %s", 'money' => "Geld", 'additionalReq' => "Zusätzliche Anforderungen um das Quest zu erhalten", 'reqRepWith' => 'Eure Reputation mit %s %s %s sein', 'reqRepMin' => "muss mindestens", 'reqRepMax' => "darf höchstens", 'progress' => "Fortschritt", - 'provided' => "Bereitgestellt", + 'provided' => "(Bereitgestellt)", 'providedItem' => "Bereitgestellter Gegenstand", 'completion' => "Abschluss", 'description' => "Beschreibung", - 'playerSlain' => "Spieler getötet", - 'profession' => "Beruf", - 'timer' => "Zeitbegrenzung", - 'loremaster' => "Meister der Lehren", - 'suggestedPl' => "Empfohlene Spielerzahl", + 'playerSlain' => "Spieler getötet (%d)", + 'profession' => "Beruf: ", + 'timer' => "Zeitbegrenzung: ", + 'loremaster' => "Meister der Lehren: ", + 'suggestedPl' => "Empfohlene Spielerzahl: %d", 'keepsPvpFlag' => "Hält Euch im PvP", 'daily' => 'Täglich', 'weekly' => "Wöchentlich", @@ -1233,16 +1454,17 @@ $lang = array( 'enabledByQ' => "Aktiviert durch", 'enabledByQDesc'=> "Ihr könnt diese Quest nur annehmen, wenn eins der nachfolgenden Quests aktiv ist", 'gainsDesc' => "Bei Abschluss dieser Quest erhaltet Ihr", - 'theTitle' => 'den Titel "%s"', 'unavailable' => "Diese Quest wurde als nicht genutzt markiert und kann weder erhalten noch vollendet werden.", 'experience' => "Erfahrung", 'expConvert' => "(oder %s, wenn auf Stufe %d vollendet)", 'expConvert2' => "%s, wenn auf Stufe %d vollendet", - 'chooseItems' => "Auf Euch wartet eine dieser Belohnungen", - 'receiveItems' => "Ihr bekommt", - 'receiveAlso' => "Ihr bekommt außerdem", - 'spellCast' => "Der folgende Zauber wird auf Euch gewirkt", - 'spellLearn' => "Ihr erlernt", + 'rewardChoices' => "Auf Euch wartet eine dieser Belohnungen:", // REWARD_CHOICES + 'rewardItems' => "Ihr bekommt:", // REWARD_ITEMS_ONLY + 'rewardAlso' => "Ihr bekommt außerdem:", // REWARD_ITEMS + 'rewardSpell' => "Ihr erlernt:", // REWARD_SPELL + 'rewardAura' => "Der folgende Zauber wird auf Euch gewirkt:", // REWARD_AURA + 'rewardTradeSkill'=>"Ihr erlernt die Herstellung von:", // REWARD_TRADESKILL_SPELL + 'rewardTitle' => 'Euch wird folgender Titel verliehen: "%s"', // REWARD_TITLE 'bonusTalents' => "%d |4Talentpunkt:Talentpunkte;", 'spellDisplayed'=> ' (%s wird angezeigt)', 'questPoolDesc' => 'Nur %d |4Quest kann:Quests können; aus diesem Tab gleichzeitig aktiv sein', @@ -1273,7 +1495,7 @@ $lang = array( 2 => array( "Dungeons", 206 => "Burg Utgarde", 209 => "Burg Schattenfang", 491 => "Kral der Klingenhauer", 717 => "Das Verlies", 718 => "Die Höhlen des Wehklagens", 719 => "Tiefschwarze Grotte", 721 => "Gnomeregan", 722 => "Hügel der Klingenhauer", 796 => "Das Scharlachrote Kloster", 1176 => "Zul'Farrak", - 1196 => "Turm Utgarde", 1337 => "Uldaman", 1417 => "Versunkener Tempel", 1581 => "Die Todesminen", 1583 => "Schwarzfelsspitze", + 1196 => "Turm Utgarde", 1337 => "Uldaman", 1477 => "Versunkener Tempel", 1581 => "Die Todesminen", 1583 => "Schwarzfelsspitze", 1584 => "Schwarzfelstiefen", 1941 => "Höhlen der Zeit", 2017 => "Stratholme", 2057 => "Scholomance", 2100 => "Maraudon", 2366 => "Der schwarze Morast", 2367 => "Vorgebirge des Alten Hügellands",2437 => "Der Flammenschlund", 2557 => "Düsterbruch", 3535 => "Höllenfeuerzitadelle", 3562 => "Höllenfeuerbollwerk", 3688 => "Auchindoun", 3713 => "Der Blutkessel", 3714 => "Die zerschmetterten Hallen", 3715 => "Die Dampfkammer", @@ -1329,6 +1551,7 @@ $lang = array( 'notFound' => "Dieses Icon existiert nicht." ), 'title' => array( + 'id' => "Titel-ID: ", 'notFound' => "Dieser Titel existiert nicht.", '_transfer' => 'Dieser Titel wird mit %s vertauscht, wenn Ihr zur %s wechselt.', 'cat' => array( @@ -1336,6 +1559,7 @@ $lang = array( ) ), 'skill' => array( + 'id' => "Fertigkeits-ID: ", 'notFound' => "Diese Fertigkeit existiert nicht.", 'cat' => array( -6 => "Haustiere", -5 => "Reittiere", -4 => "Völkerfertigkeiten", 5 => "Attribute", 6 => "Waffenfertigkeiten", 7 => "Klassenfertigkeiten", 8 => "Rüstungssachverstand", @@ -1343,8 +1567,9 @@ $lang = array( ) ), 'currency' => array( + 'id' => "Währungs-ID: ", 'notFound' => "Diese Währung existiert nicht.", - 'cap' => "Obergrenze", + 'cap' => "Obergrenze: ", 'cat' => array( 1 => "Verschiedenes", 2 => "Spieler gegen Spieler", 4 => "Classic", 21 => "Wrath of the Lich King", 22 => "Dungeon und Schlachtzug", 23 => "Burning Crusade", 41 => "Test", 3 => "Unbenutzt" ) @@ -1364,27 +1589,30 @@ $lang = array( ) ), 'mail' => array( + 'id' => "Brief-ID: ", 'notFound' => "Dieser Brief existiert nicht.", 'attachment' => "Anlage", 'mailDelivery' => 'Ihr werdet diesen Brief%s%s erhalten', 'mailBy' => ' von %s', 'mailIn' => " nach %s", - 'delay' => "Verzögerung", - 'sender' => "Absender", + 'delay' => "Verzögerung: %s", + 'sender' => "Absender: %s", 'untitled' => "Unbetitelter Brief #%d" ), 'pet' => array( + 'id' => "Tierart-ID: ", 'notFound' => "Diese Tierart existiert nicht.", 'exotic' => "Exotisch", 'cat' => ["Wildheit", "Hartnäckigkeit", "Gerissenheit"], 'food' => ["Fleisch", "Fisch", "Käse", "Brot", "Fungus", "Obst", "Rohes Fleisch", "Roher Fisch"] ), 'faction' => array( + 'id' => "Fraktions-ID: ", 'notFound' => "Diese Fraktion existiert nicht.", 'spillover' => "Reputationsüberlauf", 'spilloverDesc' => "Für diese Fraktion erhaltener Ruf wird zusätzlich mit den unten aufgeführten Fraktionen anteilig verrechnet.", 'maxStanding' => "Max. Ruf", - 'quartermaster' => "Rüstmeister", + 'quartermaster' => "Rüstmeister: ", 'customRewRate' => "Abweichende Belohnungsraten", '_transfer' => 'Die Reputation mit dieser Fraktion wird mit dem für %s vertauscht, wenn Ihr zur %s wechselt.', 'cat' => array( @@ -1395,14 +1623,15 @@ $lang = array( ) ), 'itemset' => array( + 'id' => "Ausrüstungsset-ID: ", 'notFound' => "Dieses Ausrüstungsset existiert nicht.", '_desc' => "%s ist das %s. Es enthält %s Teile.", '_descTagless' => "%s ist ein Ausrüstungsset, das %s Teile enthält.", '_setBonuses' => "Setboni", '_conveyBonus' => "Das Tragen mehrerer Gegenstände aus diesem Set gewährt Eurem Charakter Boni.", - '_pieces' => "Teile", + '_pieces' => "%d Teile: ", '_unavailable' => "Dieses Ausrüstungsset ist nicht für Spieler verfügbar.", - '_tag' => "Tag", + '_tag' => "Tag: ", 'summary' => "Zusammenfassung", 'notes' => array( null, "Dungeon-Set 1", "Dungeon-Set 2", "Tier 1 Raid-Set", @@ -1420,13 +1649,14 @@ $lang = array( ) ), 'spell' => array( + 'id' => "Zauber-ID: ", 'notFound' => "Dieser Zauber existiert nicht.", '_spellDetails' => "Zauberdetails", '_cost' => "Kosten", '_range' => "Reichweite", '_castTime' => "Zauberzeit", '_cooldown' => "Abklingzeit", - '_distUnit' => "Meter", + '_distUnit' => " Meter", '_forms' => "Gestalten", '_aura' => "Aura", '_effect' => "Effekt", @@ -1435,23 +1665,26 @@ $lang = array( '_globCD' => "Globale Abklingzeit", '_gcdCategory' => "GCD-Kategorie", '_value' => "Wert", - '_radius' => "Radius", - '_interval' => "Interval", - '_inSlot' => "im Platz", + '_radius' => "Radius: ", + '_interval' => "Interval: ", + '_inSlot' => "im Platz: ", '_collapseAll' => "Alle einklappen", '_expandAll' => "Alle ausklappen", - '_transfer' => 'Dieser Zauber wird mit %s vertauscht, wenn Ihr zur %s wechselt.', + '_transfer' => 'Dieser Zauber wird mit %s vertauscht, wenn Ihr zur %s wechselt.', + '_affected' => "Betroffene Zauber: ", + '_seeMore' => "Mehr anzeigen", + '_rankRange' => "Rang: %d - %d", + '_showXmore' => "Zeige %d weitere", + + 'normal' => "Normal", + 'special' => "Besonders", + 'currentArea' => '<Momentanes Gebiet>', 'discovered' => "Durch Geistesblitz erlernt", - 'ppm' => "%s Auslösungen pro Minute", - 'procChance' => "Procchance", + 'ppm' => "(%.1f Auslösungen pro Minute)", + 'procChance' => "Procchance: %.4g%%", 'starter' => "Basiszauber", - 'trainingCost' => "Trainingskosten", - 'remaining' => "Noch %s", - 'untilCanceled' => "bis Abbruch", - 'castIn' => "Wirken in %s Sek.", - 'instantPhys' => "Sofort", - 'instantMagic' => "Spontanzauber", + 'trainingCost' => "Trainingskosten: ", 'channeled' => "Kanalisiert", 'range' => "%s Meter Reichweite", 'meleeRange' => "Nahkampfreichweite", @@ -1462,15 +1695,50 @@ $lang = array( 'pctCostOf' => "vom Grund%s", 'costPerSec' => ", plus %s pro Sekunde", 'costPerLevel' => ", plus %s pro Stufe", + 'pointsPerCP' => ", plus %s pro Combopunkt", 'stackGroup' => "Stack Gruppierung", 'linkedWith' => "Verknüpft mit", - '_scaling' => "Skalierung", - 'scaling' => array( - 'directSP' => "+%.2f%% der Zaubermacht zum direkten Effekt", 'directAP' => "+%.2f%% der Angriffskraft zum direkten Effekt", - 'dotSP' => "+%.2f%% der Zaubermacht pro Tick", 'dotAP' => "+%.2f%% der Angriffskraft pro Tick" + 'apMod' => " (AP mod: %.3g)", + 'spMod' => " (ZM mod: %.3g)", + 'instantPhys' => "Sofort", + 'castTime' => array( + "Spontanzauber", + "Wirken in %.3g Sek.", + "Wirken in %.3g Min." ), + 'cooldown' => array( + "Keine Abklingzeit", + "%.3g Sek. Abklingzeit", + "%.3g Min. Abklingzeit", + "%.3g |4Stunde:Stunden; Abklingzeit", + "%.3g |4Tag:Tage; Abklingzeit" + ), + 'duration' => array( + "bis Abbruch", + "%.2G Sek.", + "%.2G Min.", + "%.2G |4Stunde:Stunden;", + "%.2G |4Tag:Tage;" + ), + 'timeRemaining' => array( + "", + "Noch %d |4Sekunde:Sekunden;", + "Noch %d |4Minute:Minuten;", + "Noch %d |4Stunde:Stunden;", + "Noch %d |4Tag:Tage;" + ), + 'powerCost' => array( + -2 => ["%d Gesundheit", '%1$d Gesundheit und %2$d pro Sek.'], + 0 => ["%d Mana", '%1$d Mana und %2$d pro Sek.' ], + 1 => ["%d Wut", '%1$d Wut und %2$d pro Sek.' ], + 2 => ["%d Fokus", "%d Fokus und %d pro Sek." ], + 3 => ["%d Energie", "%d Energie und %d pro Sek." ], + 6 => ["%d Runenmacht", "%d Runenmacht, plus %d pro Sek." ], + ), + 'powerDisplayCost' => ["%d %s", "%d %s, plus %d pro Sek"], + 'powerCostRunes'=> ["%d Blut", "%d Unheilig", "%d Frost"], 'powerRunes' => ["Blut", "Unheilig", "Frost", "Tod"], - 'powerTypes' => array( + 'powerTypes' => array( // POWER_TYPE_* // conventional -2 => "Gesundheit", 0 => "Mana", 1 => "Wut", 2 => "Fokus", 3 => "Energie", 4 => "Zufriedenheit", 5 => "Runen", 6 => "Runenmacht", @@ -1541,9 +1809,9 @@ $lang = array( ), 'spellModOp' => array( "Schaden", "Dauer", "Bedrohung", "Effekt 1", "Aufladungen", - "Reichweite", "Radius", "kritische Trefferchance", "Alle Effekte", "Zauberzeitverlust", + "Reichweite", "Radius", "Kritische Trefferchance", "Alle Effekte", "Zauberzeitverlust", "Zauberzeit", "Abklingzeit", "Effekt 2", "Ignoriere Rüstung", "Kosten", - "Kritischer Bonusschaden", "Chance auf Fehlschlag", "Sprung-Ziele", "Chance auf Auslösung", "Intervall", + "Kritischer Bonusschaden", "Trefferchance", "Sprung-Ziele", "Chance auf Auslösung", "Intervall", "Multiplikator (Schaden)", "Globale Abklingzeit", "Schaden über Zeit", "Effekt 3", "Multiplikator (Bonus)", null, "Auslösungen pro Minute", "Multiplikator (Betrag)", "Widerstand gegen Bannung", "kritischer Bonusschaden2", "Kostenrückerstattung bei Fehlschlag" @@ -1555,6 +1823,9 @@ $lang = array( "erhaltene kritische Fernkampftreffer", "erhaltene kritische Zaubertreffer", "Nahkampftempo", "Fernkampftempo", "Zaubertempo", "Waffenfertigkeit Haupthand", "Waffenfertigkeit Nebenhand", "Waffenfertigkeit Fernkampf", "Waffenkunde", "Rüstungsdurchschlag" ), + 'combatRatingMask' => array( + 0xE0 => "Trefferchance", 0x700 => "Kritische Trefferchance", 0x1C000 => "Abhärtung" + ), 'lockType' => array( null, "Schlossknacken", "Kräuterkunde", "Bergbau", "Falle entschärfen", "Öffnen", "Schatz (DND)", "Verkalkte Elfenedelsteine (DND)", "Schließen", "Falle scharf machen", @@ -1563,67 +1834,10 @@ $lang = array( "Inschriftenkunde", "Vom Fahrzeug öffnen" ), 'stealthType' => ["Allgemein", "Falle"], - 'invisibilityType' => [null, "Allgemein", null, "Falle", null, null, "Trunkenheit", null, null, null, null, null], - 'attributes' => array( // index defined by filters - 69 => "Alle Zaubereffekte sind schädlich", - 57 => "Aura kann nicht entfernt werden", - 51 => "Aura ist versteckt", - 95 => "Verbandszauber", - 61 => "Kann tot verwendet werden", - 62 => "Kann verwendet werden, während Ihr auf einem Reittier sitzt", - 64 => "Kann im Sitzen benutzt werden", - 53 => "Kann nur tagsüber benutzt werden", - 54 => "Kann nur nachts verwendet werden", - 55 => "Kann nur drinnen verwendet werden", - 56 => "Kann nur draußen verwendet werden", - 79 => "Kann nur einen Spieler zum Ziel haben", - 60 => "Kann nicht ausgewichen, pariert oder geblockt werden", - 67 => "Kann nicht reflektiert werden", - 91 => "Kann nicht im Schlachtzug verwendet werden", - 33 => "Kann im Kampf gewirkt werden", - 34 => "Chance, kritisch zu treffen", - 35 => "Chance to verfehlen", - 27 => "Kanalisiert", - 66 => "Kanalisiert 2", - 85 => "Dauert an, während Ihr ausgeloggt seid", - 84 => "Erscheint nicht im Log", - 68 => "Beendet Verstohlenheitsmodus nicht", - 81 => "Verwickelt das Ziel nicht in einen Kampf", - 77 => "Erfordert keine Gestaltwandlung", - // 46 => "Ignoriert Unverwundbarkeit", - 47 => "Ignoriert Unverwundbarkeit gegen Magieart", - 78 => "Essens-/Getränk-Buff", - 71 => "Generiert keine Bedrohung", - 52 => "Mit dem nächsten Schwung (NSCs)", - 49 => "Mit dem nächsten Schwung (Spieler)", - 90 => "Nur in der Arena benutzbar", - 92 => "Paladin Aura", - 50 => "Passiver Zauber", - 36 => "Hält über Tod hinaus an", - 72 => "Taschendiebstahl-Zauber", - 73 => "Entfernt Auren auf Immunität", - 48 => "Benötigt eine Fernkampfwaffe", - 82 => "Benötigt einen Zauberstab", - 83 => "Benötigt eine Schildhandwaffe", - 74 => "Erfordert Angelrute", - 41 => "Benötigt Metamorphose", - 80 => "Benötigt eine Haupthandwaffe", - 38 => "Benötigt Verstohlenheit", - 75 => "Setzt ein unmarkiertes Ziel voraus", - 58 => "Zauberschaden ist abhängig von der Stufe des Zauberers", - 39 => "Zauber kann geraubt werden", - 63 => "Abklingzeit beginnt, nachdem die Aura schwindet", - 87 => "Beginnt zu ticken, sobald die Aura angewendet wird", - 59 => "Stoppt Autoangriff", - // 76 => "Das Ziel muss ein eigener Gegenstand sein", - 70 => "Das Ziel darf sich nicht im Kampf befinden", - 93 => "Totem", - 42 => "Benutzbar in Betäubung", - 88 => "Verwendbar, während Ihr verwirrt seid", - 89 => "Verwendbar, während Ihr verängstigt seid", - 65 => "Braucht alle Ressourcen auf" - ), - 'unkEffect' => 'Unknown Effect', + 'invisibilityType' => ["Allgemein", "UNK-1", "UNK-2", "Falle", "UNK-4", "UNK-5", "Trunkenheit", "UNK-7", "UNK-8", "UNK-9", "UNK-10", "UNK-11"], + 'summonControl' => ["Ungesteuert", "Wächter", "Begleiter", "Bezaubert", "Gesteuertes Fahrzeug", "Ungesteuertes Fahrzeug"], + 'summonSlot' => ["Begleiter", "Feuertotem", "Erdtotem", "Wassertotem", "Lufttotem", "Haustier", "Quest"], + 'unkEffect' => 'Unknown Effect (%1$d)', 'effects' => array( /*0-5 */ 'None', 'Instakill', 'School Damage', 'Dummy', 'Portal Teleport', 'Teleport Units', /*6+ */ 'Apply Aura', 'Environmental Damage', 'Drain Power', 'Drain Health', 'Heal', 'Bind', @@ -1645,16 +1859,16 @@ $lang = array( /*102+ */ 'Dismiss Pet', 'Give Reputation', 'Summon Object (Trap)', 'Summon Object (Battle S.)','Summon Object (#3)', 'Summon Object (#4)', /*108+ */ 'Dispel Mechanic', 'Summon Dead Pet', 'Destroy All Totems', 'Durability Damage - Flat', 'Summon Demon', 'Resurrect with Flat Health', /*114+ */ 'Taunt', 'Durability Damage - %', 'Skin Player Corpse (PvP)', 'AoE Resurrect with % Health','Learn Skill', 'Apply Area Aura - Pet', -/*120+ */ 'Teleport to Graveyard', 'Normalized Weapon Damage', null, 'Take Flight Path', 'Pull Towards', 'Modify Threat - %', +/*120+ */ 'Teleport to Graveyard', 'Normalized Weapon Damage', '', 'Take Flight Path', 'Pull Towards', 'Modify Threat - %', /*126+ */ 'Spell Steal ', 'Prospect', 'Apply Area Aura - Friend', 'Apply Area Aura - Enemy', 'Redirect Done Threat %', 'Play Sound', -/*132+ */ 'Play Music', 'Unlearn Specialization', 'Kill Credit2', 'Call Pet', 'Heal for % of Total Health','Give % of Total Power', +/*132+ */ 'Play Music', 'Unlearn Specialization', 'Kill Credit 2', 'Call Pet', 'Heal for % of Total Health','Give % of Total Power', /*138+ */ 'Leap Back', 'Abandon Quest', 'Force Cast', 'Force Spell Cast with Value','Trigger Spell with Value','Apply Area Aura - Pet Owner', -/*144+ */ 'Knockback to Dest.', 'Pull Towards Dest.', 'Activate Rune', 'Fail Quest', null, 'Charge to Dest', +/*144+ */ 'Knockback to Dest.', 'Pull Towards Dest.', 'Activate Rune', 'Fail Quest', 'Trigger Missile with Value','Charge to Dest', /*150+ */ 'Start Quest', 'Trigger Spell 2', 'Summon - Refer-A-Friend', 'Create Tamed Pet', 'Discover Flight Path', 'Dual Wield 2H Weapons', -/*156+ */ 'Add Socket to Item', 'Create Tradeskill Item', 'Milling', 'Rename Pet', null, 'Change Talent Spec. Count', -/*162-167*/ 'Activate Talent Spec.', null, 'Remove Aura', null, null, 'Update Player Phase' +/*156+ */ 'Add Socket to Item', 'Create Tradeskill Item', 'Milling', 'Rename Pet', 'Force Cast 2', 'Change Talent Spec. Count', +/*162-167*/ 'Activate Talent Spec.', '', 'Remove Aura' ), - 'unkAura' => 'Unknown Aura', + 'unkAura' => 'Unknown Aura (%1$d)', 'auras' => array( /*0- */ 'None', 'Bind Sight', 'Possess', 'Periodic Damage - Flat', 'Dummy', /*5+ */ 'Confuse', 'Charm', 'Fear', 'Periodic Heal', 'Mod Attack Speed', @@ -1665,7 +1879,7 @@ $lang = array( 'Mod Skill - Temporary', 'Increase Run Speed %', 'Mod Mounted Speed %', 'Decrease Run Speed %', 'Mod Maximum Health - Flat', 'Mod Maximum Power - Flat', 'Shapeshift', 'Spell Effect Immunity', 'Spell Aura Immunity', 'Spell School Immunity', 'Damage Immunity', 'Dispel Type Immunity', 'Proc Trigger Spell', 'Proc Trigger Damage', 'Track Creatures', - 'Track Resources', 'Ignore All Gear', 'Mod Parry %', null, 'Mod Dodge %', + 'Track Resources', 'Ignore All Gear', 'Mod Parry %', 'Periodic Trigger Spell from Client', 'Mod Dodge %', /*50+ */ 'Mod Critical Healing Amount %', 'Mod Block %', 'Mod Physical Crit Chance', 'Periodically Drain Health', 'Mod Physical Hit Chance', 'Mod Spell Hit Chance', 'Transform', 'Mod Spell Crit Chance', 'Increase Swim Speed %', 'Mod Damage Done Versus Creature', 'Pacify & Silence', 'Mod Size %', 'Periodically Transfer Health', 'Periodic Transfer Power', 'Periodic Drain Power', @@ -1688,19 +1902,19 @@ $lang = array( 'Increase Pet Talent Points', 'Allow Exotic Pets Taming', 'Mechanic Immunity Mask', 'Retain Combo Points', 'Reduce Pushback Time %', /*150+ */ 'Mod Shield Block Value - %', 'Track Stealthed', 'Mod Player Aggro Range', 'Split Damage - Flat', 'Mod Stealth Level', 'Mod Underwater Breathing %', 'Mod All Reputation Gained by %', 'Done Pet Damage Multiplier', 'Mod Shield Block Value - Flat', 'No PvP Credit', - 'Mod AoE Avoidance', 'Mod Health Regen During Combat', 'Mana Burn', 'Mod Melee Critical Damage %', null, + 'Mod AoE Avoidance', 'Mod Health Regen During Combat', 'Mana Burn', 'Mod Melee Critical Damage %', '', 'Mod Attacker Melee Attack Power', 'Mod Melee Attack Power - %', 'Mod Ranged Attack Power - %', 'Mod Damage Done vs Creature', 'Mod Crit Chance vs Creature', - 'Change Object Visibility for Player', 'Mod Run Speed (not stacking)', 'Mod Mounted Speed (not stacking)', null, 'Mod Spell Power by % of Stat', + 'Change Object Visibility for Player', 'Mod Run Speed (not stacking)', 'Mod Mounted Speed (not stacking)', '', 'Mod Spell Power by % of Stat', /*175+ */ 'Mod Healing Power by % of Stat', 'Spirit of Redemption', 'AoE Charm', 'Mod Debuff Resistance - %', 'Mod Attacker Spell Crit Chance', - 'Mod Spell Power vs Creature', null, 'Mod Resistance by % of Stat', 'Mod Threat % of Critical Hits', 'Mod Attacker Melee Hit Chance', + 'Mod Spell Power vs Creature', '', 'Mod Resistance by % of Stat', 'Mod Threat % of Critical Hits', 'Mod Attacker Melee Hit Chance', 'Mod Attacker Ranged Hit Chance', 'Mod Attacker Spell Hit Chance', 'Mod Attacker Melee Crit Chance', 'Mod Attacker Ranged Crit Chance', 'Mod Rating', 'Mod Reputation Gained %', 'Limit Movement Speed', 'Mod Attack Speed %', 'Mod Haste % (gain)', 'Mod Target School Absorb %', - 'Mod Target School Absorb for Ability', 'Mod Cooldowns', 'Mod Attacker Crit Chance', null, 'Mod Spell Hit Chance', + 'Mod Target School Absorb for Ability', 'Mod Cooldowns', 'Mod Attacker Crit Chance', '', 'Mod Spell Hit Chance', /*200+ */ 'Mod Kill Experience Gained %', 'Can Fly', 'Ignore Combat Result', 'Mod Attacker Melee Crit Damage %', 'Mod Attacker Ranged Crit Damage %', 'Mod Attacker Spell Crit Damage %', 'Mod Vehicle Flight Speed %', 'Mod Mounted Flight Speed %', 'Mod Flight Speed %', 'Mod Mounted Flight Speed % (always)', 'Mod Vehicle Speed % (always)', 'Mod Flight Speed % (not stacking)', 'Mod Ranged Attack Power by % of Stat', 'Mod Rage Generated from Damage Dealt', 'Tamed Pet Passive', 'Arena Preparation', 'Mod Spell Haste %', 'Killing Spree', 'Mod Ranged Haste %', 'Mod Mana Regeneration by % of Stat', - 'Mod Combat Rating by % of Stat', 'Ignore Threat', null, 'Raid Proc from Charge', null, + 'Mod Combat Rating by % of Stat', 'Ignore Threat', '', 'Raid Proc from Charge', '', /*225+ */ 'Raid Proc from Charge with Value', 'Periodic Dummy', 'Periodically Trigger Spell with Value','Detect Stealth', 'Mod AoE Damage Taken %', 'Mod Maximum Health - Flat (no stacking)','Proc Trigger Spell with Value', 'Mod Mechanic Duration %', 'Change other Humanoid Display', 'Mod Mechanic Duration % (not stacking)', 'Mod Dispel Resistance %', 'Control Vehicle', 'Mod Spell Power by % of Attack Power', 'Mod Healing Power by % of Attack Power','Mod Size % (not stacking)', @@ -1708,27 +1922,300 @@ $lang = array( 'Mod Aura Duration by Dispel Type', 'Mod Aura Duration by Dispel Type (not stacking)', 'Clone Caster', 'Mod Combat Result Chance', 'Convert Rune', /*250+ */ 'Mod Maximum Health - Flat (stacking)', 'Mod Enemy Dodge Chance', 'Mod Haste % (loss)', 'Mod Critical Block Chance', 'Disarm Offhand', 'Mod Mechanic Damage Taken %', 'No Reagent Cost', 'Mod Target Resistance by Spell Class', 'Mod Spell Visual', 'Mod Periodic Healing Taken %', - 'Screen Effect', 'Phase', 'Ability Ignore Aurastate', 'Allow Only Ability', null, - null, null, 'Cancel Aura Buffer at % of Caster Health','Mod Attack Power by % of Stat', 'Ignore Target Resistance', + 'Screen Effect', 'Phase', 'Ability Ignore Aurastate', 'Allow Only Ability', '', + '', '', 'Cancel Aura Buffer at % of Caster Health','Mod Attack Power by % of Stat', 'Ignore Target Resistance', 'Ignore Target Resistance for Ability', 'Mod Damage Taken % from Caster', 'Ignore Swing Timer Reset', 'X-Ray', 'Ability Consume No Ammo', /*275+ */ 'Mod Ability Ignore Shapeshift', 'Mod Mechanic Damage Done %', 'Mod Max Affected Targets', 'Disarm Ranged Weapon', 'Spawn Effect', 'Mod Armor Penetration %', 'Mod Honor Gain %', 'Mod Base Health %', 'Mod Healing Taken % from Caster', 'Linked Aura', - 'Mod Attack Power by School Resistance','Allow Periodic Ability to Crit', 'Mod Spell Deflect Chance', 'Ignore Hit Direction', null, + 'Mod Attack Power by School Resistance','Allow Periodic Ability to Crit', 'Mod Spell Deflect Chance', 'Ignore Hit Direction', '', 'Mod Crit Chance', 'Mod Quest Experience Gained %', 'Open Stable', 'Override Spells', 'Prevent Power Regeneration', - null, 'Set Vehicle Id', 'Spirit Burst', 'Strangulate', null, -/*300+ */ 'Share Damage %', 'Mod Absorb School Healing', null, 'Mod Damage Done vs Aurastate - %', 'Fake Inebriate', - 'Mod Minimum Speed %', null, 'Heal Absorb Test', 'Mod Critical Strike Chance for Caster',null, - 'Mod Pet AoE Damage Avoidance', null, null, null, 'Prevent Ressurection', + '', 'Set Vehicle Id', 'Spirit Burst', 'Strangulate', '', +/*300+ */ 'Share Damage %', 'Mod Absorb School Healing', '', 'Mod Damage Done vs Aurastate - %', 'Fake Inebriate', + 'Mod Minimum Speed %', '', 'Heal Absorb Test', 'Mod Critical Strike Chance for Caster','', + 'Mod Pet AoE Damage Avoidance', '', '', '', 'Prevent Ressurection', /* -316*/ 'Underwater Walking', 'Periodic Haste' + ), + 'attributes0' => array( + SPELL_ATTR0_PROC_FAILURE_BURNS_CHARGE => 'Proc-Fehlschlag verbraucht Aufladung', + SPELL_ATTR0_REQ_AMMO => 'Benötigt eine Fernkampfwaffe', + SPELL_ATTR0_ON_NEXT_SWING => 'Mit dem nächsten Schwung (Spieler)', + SPELL_ATTR0_IS_REPLENISHMENT => 'Verfehlen durch Immunität nicht loggen', + SPELL_ATTR0_ABILITY => 'Ist Fähigkeit', + SPELL_ATTR0_TRADESPELL => 'Handwerksrezept', + SPELL_ATTR0_PASSIVE => 'Passiver Zauber', + SPELL_ATTR0_HIDDEN_CLIENTSIDE => 'Aura ist versteckt', + SPELL_ATTR0_HIDE_IN_COMBAT_LOG => 'Erscheint nicht im Log', + SPELL_ATTR0_TARGET_MAINHAND_ITEM => 'Nur angelegte Gegenstände', + SPELL_ATTR0_ON_NEXT_SWING_2 => 'Mit dem nächsten Schwung (NSCs)', + SPELL_ATTR0_WEARER_CASTS_PROC_TRIGGER => 'Träger wirkt Proc-Auslöser', + SPELL_ATTR0_DAYTIME_ONLY => 'Kann nur tagsüber benutzt werden', + SPELL_ATTR0_NIGHT_ONLY => 'Kann nur nachts verwendet werden', + SPELL_ATTR0_INDOORS_ONLY => 'Kann nur drinnen verwendet werden', + SPELL_ATTR0_OUTDOORS_ONLY => 'Kann nur draußen verwendet werden', + SPELL_ATTR0_NOT_SHAPESHIFT => 'Kann nicht verwendet werden, während Ihr gestaltverwandelt seid', + SPELL_ATTR0_ONLY_STEALTHED => 'Muss in Verstohlenheit sein', + SPELL_ATTR0_DONT_AFFECT_SHEATH_STATE => 'Waffe nicht wegstecken', + SPELL_ATTR0_LEVEL_DAMAGE_CALCULATION => 'Zauberschaden ist abhängig von der Stufe des Zauberers', + SPELL_ATTR0_STOP_ATTACK_TARGET => 'Stoppt Autoangriff', + SPELL_ATTR0_IMPOSSIBLE_DODGE_PARRY_BLOCK => 'Kann nicht ausgewichen, pariert oder geblockt werden', + SPELL_ATTR0_CAST_TRACK_TARGET => 'Ziel bei Wirken verfolgen (nur Spieler)', + SPELL_ATTR0_CASTABLE_WHILE_DEAD => 'Kann tot verwendet werden', + SPELL_ATTR0_CASTABLE_WHILE_MOUNTED => 'Kann verwendet werden, während Ihr auf einem Reittier sitzt', + SPELL_ATTR0_DISABLED_WHILE_ACTIVE => 'Abklingzeit beginnt, nachdem die Aura schwindet', + SPELL_ATTR0_NEGATIVE_1 => 'Aura ist Schwächungszauber', + SPELL_ATTR0_CASTABLE_WHILE_SITTING => 'Kann im Sitzen benutzt werden', + SPELL_ATTR0_CANT_USED_IN_COMBAT => 'Kann nicht im Kampf verwendet werden', + SPELL_ATTR0_UNAFFECTED_BY_INVULNERABILITY => 'Nicht betroffen von Unverwundbarkeit', + SPELL_ATTR0_HEARTBEAT_RESIST_CHECK => 'Herzschlagresistenz', + SPELL_ATTR0_CANT_CANCEL => 'Aura kann nicht entfernt werden' + ), + 'attributes1' => array( + SPELL_ATTR1_DISMISS_PET => 'Zuerst Begleiter freigeben', + SPELL_ATTR1_DRAIN_ALL_POWER => 'Braucht alle Ressourcen auf', + SPELL_ATTR1_CHANNELED_1 => 'Kanalisiert 1', + SPELL_ATTR1_CANT_BE_REDIRECTED => 'Kann nicht umgelenkt werden', + SPELL_ATTR1_NO_SKILL_INCREASE => 'Keine Fähigkeitenerhöhung', + SPELL_ATTR1_NOT_BREAK_STEALTH => 'Beendet Verstohlenheitsmodus nicht', + SPELL_ATTR1_CHANNELED_2 => 'Kanalisiert 2', + SPELL_ATTR1_CANT_BE_REFLECTED => 'Kann nicht reflektiert werden', + SPELL_ATTR1_CANT_TARGET_IN_COMBAT => 'Das Ziel darf sich nicht im Kampf befinden', + SPELL_ATTR1_MELEE_COMBAT_START => 'Initiiere Kampf (aktiviert Autoangriff)', + SPELL_ATTR1_NO_THREAT => 'Generiert keine Bedrohung', + SPELL_ATTR1_DONT_REFRESH_DURATION_ON_RECAST => 'Einzigartige Aura', + SPELL_ATTR1_IS_PICKPOCKET => 'Taschendiebstahl-Zauber', + SPELL_ATTR1_FARSIGHT => 'Fernsicht umschalten', + SPELL_ATTR1_CHANNEL_TRACK_TARGET => 'Ziel beim Kanalisieren verfolgen', + SPELL_ATTR1_DISPEL_AURAS_ON_IMMUNITY => 'Entfernt Auren bei Immunität', + SPELL_ATTR1_UNAFFECTED_BY_SCHOOL_IMMUNE => 'Nicht betroffen von Immunität gegen diese Magiesart', + SPELL_ATTR1_UNAUTOCASTABLE_BY_PET => 'Kein Auto-Zaubern (KI)', + SPELL_ATTR1_PREVENTS_ANIM => 'Verhindert Animation', + SPELL_ATTR1_CANT_TARGET_SELF => 'Zauberer kann nicht Ziel sein', + SPELL_ATTR1_FINISHING_MOVE_DAMAGE => 'Erfordert Combo-Punkte auf dem Ziel (Effektstärke)', + SPELL_ATTR1_THREAT_ONLY_ON_MISS => 'Bedrohung nur bei verfehlen', + SPELL_ATTR1_FINISHING_MOVE_DURATION => 'Erfordert Combo-Punkte auf dem Ziel (Effektdauer)', + SPELL_ATTR1_IGNORE_OWNERS_DEATH => 'Ignoriere Tod des Besitzers', + SPELL_ATTR1_IS_FISHING => 'Erfordert Angelrute', + SPELL_ATTR1_AURA_STAYS_AFTER_COMBAT => 'Aura bleibt nach Kampf bestehen', + SPELL_ATTR1_REQUIRE_ALL_TARGETS => 'Benötige alle Ziele', + SPELL_ATTR1_DISCOUNT_POWER_ON_MISS => 'Reduzierte Kosten bei Verfehlen', + SPELL_ATTR1_DONT_DISPLAY_IN_AURA_BAR => 'Kein Aura-Symbol', + SPELL_ATTR1_CHANNEL_DISPLAY_SPELL_NAME => 'Name im Zauberbalken', + SPELL_ATTR1_ENABLE_AT_DODGE => 'Kombo beim Ausweichen', + SPELL_ATTR1_CAST_WHEN_LEARNED => 'Beim erlernen Zaubern', + ), + 'attributes2' => array( + SPELL_ATTR2_CAN_TARGET_DEAD => 'Totes Ziel zulässig', + SPELL_ATTR2_NO_SHAPESHIFT_UI => 'Kein Gestaltwandel-UI', + SPELL_ATTR2_CAN_TARGET_NOT_IN_LOS => 'Ignoriere Sichtlinie', + SPELL_ATTR2_ALLOW_LOW_LEVEL_BUFF => 'Erlaube Verstärkungszauber auf niederstufigem Ziel', + SPELL_ATTR2_DISPLAY_IN_STANCE_BAR => 'Spezialaktionsleiste benutzen', + SPELL_ATTR2_AUTOREPEAT_FLAG => 'Automatische Wiederholung', + SPELL_ATTR2_CANT_TARGET_TAPPED => 'Setzt ein unmarkiertes Ziel voraus', + SPELL_ATTR2_DO_NOT_REPORT_SPELL_FAILURE => 'Fehlgeschlagene Zauber nicht melden', + SPELL_ATTR2_INCLUDE_IN_ADVANCED_COMBAT_LOG => '', + SPELL_ATTR2_ALWAYS_CAST_AS_UNIT => 'Immer als Einheit zaubern', + SPELL_ATTR2_SPECIAL_TAMING_FLAG => 'Markierung für besonderes Zähmen', + SPELL_ATTR2_HEALTH_FUNNEL => 'Lebenslinie', + SPELL_ATTR2_CHAIN_FROM_CASTER => 'Verkettung vom Zauberer ausgehend', + SPELL_ATTR2_PRESERVE_ENCHANT_IN_ARENA => 'Das Ziel muss ein eigener Gegenstand sein', + SPELL_ATTR2_ALLOW_WHILE_INVISIBLE => 'Nutzbar während unsichtbar', + SPELL_ATTR2_DO_NOT_CONSUME_IF_GAINED_DURING_CAST => 'Nicht verbrauchen, wenn beim Zaubern erlangt', + SPELL_ATTR2_TAME_BEAST => 'Kein aktiver Begleiter', + SPELL_ATTR2_NOT_RESET_AUTO_ACTIONS => 'Kampftimer nicht zurücksetzen', + SPELL_ATTR2_REQ_DEAD_PET => 'Erfordert toten Begleiter', + SPELL_ATTR2_NOT_NEED_SHAPESHIFT => 'Gestaltwandlung nicht erforderlich', + SPELL_ATTR2_INITIATE_COMBAT_POST_CAST_ENABLES_AUTO_ATTACK => 'Initiiere Kampf nach Wirken (aktiviert Autoangriff)', + SPELL_ATTR2_FAIL_ON_ALL_TARGETS_IMMUNE => 'Scheitern, wenn alle Ziele immun', + SPELL_ATTR2_NO_INITIAL_THREAT => 'Keine Initialbedrohung', + SPELL_ATTR2_IS_ARCANE_CONCENTRATION => 'Abklingzeit bei Fehlschlag proccen', + SPELL_ATTR2_ITEM_CAST_WITH_OWNER_SKILL => 'Gegenstand mit Besitzerfertigkeit gewirkt', + SPELL_ATTR2_DONT_BLOCK_MANA_REGEN => 'Manaregenaration nicht blockieren', + SPELL_ATTR2_UNAFFECTED_BY_AURA_SCHOOL_IMMUNE => 'Ignoriert Immunität gegen Magieart', + SPELL_ATTR2_IGNORE_WEAPONSKILL => 'Ignoriere Waffenfertigkeit', + SPELL_ATTR2_NOT_AN_ACTION => 'Keine Aktion', + SPELL_ATTR2_CANT_CRIT => 'Kann nicht kritisch treffen', + SPELL_ATTR2_ACTIVE_THREAT => 'Aktive Bedrohung', + SPELL_ATTR2_FOOD_BUFF => 'Essens-/Getränk-Stärkungszauber', + ), + 'attributes3' => array( + SPELL_ATTR3_PVP_ENABLING => 'Aktiviert PvP', + SPELL_ATTR3_IGNORE_PROC_SUBCLASS_MASK => 'Keine Ausrüstungs-Voraussetzung für Proc', + SPELL_ATTR3_NO_CASTING_BAR_TEXT => 'Kein Text im Zauberbalken', + SPELL_ATTR3_COMPLETELY_BLOCKED => 'Vollständig geblockt', + SPELL_ATTR3_IGNORE_RESURRECTION_TIMER => 'Kein Wiederbelebungs-Verzögerung', + SPELL_ATTR3_NO_DURABILTIY_LOSS => 'Kein Haltbarkeitsverlust', + SPELL_ATTR3_NO_AVOIDANCE => 'Kann nicht vermieden werden', + SPELL_ATTR3_STACK_FOR_DIFF_CASTERS => 'Nutzt Regeln fürs DoT-Stapeln', + SPELL_ATTR3_ONLY_TARGET_PLAYERS => 'Kann nur einen Spieler zum Ziel haben', + SPELL_ATTR3_NOT_A_PROC => 'Kein Proc', + SPELL_ATTR3_MAIN_HAND => 'Benötigt eine Haupthandwaffe', + SPELL_ATTR3_BATTLEGROUND => 'Nur in Schlachtfeldern benutzbar', + SPELL_ATTR3_ONLY_TARGET_GHOSTS => 'Nur auf Geister', + SPELL_ATTR3_DONT_DISPLAY_CHANNEL_BAR => 'Verstecke Kanalisierungsbalken', + SPELL_ATTR3_IS_HONORLESS_TARGET => 'Ist Ehrenloses Ziel', + SPELL_ATTR3_NORMAL_RANGED_ATTACK => 'Normaler Fernkampfangriff', + SPELL_ATTR3_CANT_TRIGGER_PROC => 'Wirker Procs unterdrücken', + SPELL_ATTR3_NO_INITIAL_AGGRO => 'Verwickelt das Ziel nicht in einen Kampf', + SPELL_ATTR3_IGNORE_HIT_RESULT => 'Kann nicht verfehlen', + SPELL_ATTR3_DISABLE_PROC => 'Deaktiviert Procs', + SPELL_ATTR3_DEATH_PERSISTENT => 'Wirkt über den Tod hinaus', + SPELL_ATTR3_ONLY_PROC_OUTDOORS => 'Procct nur draußen', + SPELL_ATTR3_REQ_WAND => 'Benötigt einen Zauberstab', + SPELL_ATTR3_NO_DAMAGE_HISTORY => 'Keine Schadenshistorie', + SPELL_ATTR3_REQ_OFFHAND => 'Benötigt eine Nebenhandwaffe', + SPELL_ATTR3_TREAT_AS_PERIODIC => 'Als periodischen Zauber behandeln', + SPELL_ATTR3_CAN_PROC_FROM_PROCS => 'Kann durch Procs proccen', + SPELL_ATTR3_DRAIN_SOUL => 'Proc nur bei Wirker', + SPELL_ATTR3_IGNORE_CASTER_AND_TARGET_RESTRICTIONS => 'Ignoriere Beschränkungen an Wirker und Ziel', + SPELL_ATTR3_NO_DONE_BONUS => 'Ignoriere Zauberer-Modifikatoren', + SPELL_ATTR3_DONT_DISPLAY_RANGE => 'Reichweite nicht anzeigen', + SPELL_ATTR3_NOT_ON_AOE_IMMUNE => 'Nicht bei AoE-Immunität' + ), + 'attributes4' => array( + SPELL_ATTR4_IGNORE_RESISTANCES => 'Wirken nicht im Log', + SPELL_ATTR4_PROC_ONLY_ON_CASTER => 'Klassenauslöser nur am Ziel', + SPELL_ATTR4_FADES_WHILE_LOGGED_OUT => 'Dauert an, während Ihr ausgeloggt seid', + SPELL_ATTR4_NO_HELPFUL_THREAT => 'Verursacht keine hilfreiche Bedrohung', + SPELL_ATTR4_NO_HARMFUL_THREAT => 'Verursacht keine offensive Bedrohung', + SPELL_ATTR4_ALLOW_CLIENT_TARGETING => 'Erlaube Client-Zielsetzung', + SPELL_ATTR4_NOT_STEALABLE => 'Zauber kann nicht geraubt werden', + SPELL_ATTR4_CAN_CAST_WHILE_CASTING => 'Zaubern während des zauberns zulässig', + SPELL_ATTR4_FIXED_DAMAGE => 'Ignoriere Modifikatoren für erlittenen Schaden', + SPELL_ATTR4_TRIGGER_ACTIVATE => 'Combat Feedback When Usable', + SPELL_ATTR4_SPELL_VS_EXTEND_COST => 'Kostenskalierung mit Waffengeschwindigkeit', + SPELL_ATTR4_NO_PARTIAL_IMMUNITY => 'Keine teilweise Immunität', + SPELL_ATTR4_AURA_IS_BUFF => 'Aura ist Stärkungszauber', + SPELL_ATTR4_DO_NOT_LOG_CASTER => 'Zauberer nicht loggen', + SPELL_ATTR4_DAMAGE_DOESNT_BREAK_AURAS => 'Reaktiver Schadens-Proc', + SPELL_ATTR4_NOT_IN_SPELLBOOK => 'Nicht im Zauberbuch', + SPELL_ATTR4_NOT_USABLE_IN_ARENA => 'Kann nicht in der Arena verwendet werden', + SPELL_ATTR4_USABLE_IN_ARENA => 'Benutzbar in Arenen', + SPELL_ATTR4_AREA_TARGET_CHAIN => 'Überspringende Kettengeschosse', + SPELL_ATTR4_ALLOW_PROC_WHILE_SITTING => 'Erlaube Proc im Sitzen', + SPELL_ATTR4_NOT_CHECK_SELFCAST_POWER => 'Anwendung der Aura kann nicht fehlschlagen', + SPELL_ATTR4_DONT_REMOVE_IN_ARENA => 'Zulässig beim Betreten der Arena', + SPELL_ATTR4_PROC_SUPPRESS_SWING_ANIM => 'Proc unterdrückt Schwung-Animation', + SPELL_ATTR4_CANT_TRIGGER_ITEM_SPELLS => 'Unterdrücke Waffen-Procs', + SPELL_ATTR4_AUTO_RANGED_COMBAT => 'Automatischer Fernkampf', + SPELL_ATTR4_IS_PET_SCALING => 'Skalliert mit Statistiken des Besitzers', + SPELL_ATTR4_CAST_ONLY_IN_OUTLAND => 'Nur in Flugzonen', + SPELL_ATTR4_FORCE_DISPLAY_CASTBAR => 'Zauberbalkenanzeige erzwingen', + SPELL_ATTR4_IGNORE_COMBAT_TIMER => 'Ignoriere Kampftimer', + SPELL_ATTR4_AURA_BOUNCE_FAILS_SPELL => 'Abweisung der Aura unterbricht Zauber', + SPELL_ATTR4_OBSOLETE => '', + SPELL_ATTR4_USE_FACING_FROM_SPELL => 'Blickrichtung von Zauber benutzen' + ), + 'attributes5' => array( + SPELL_ATTR5_CAN_CHANNEL_WHEN_MOVING => 'Erlaube Aktionen beim kanalisieren', + SPELL_ATTR5_NO_REAGENT_WHILE_PREP => 'Keine Reagenzkosten bei Aura', + SPELL_ATTR5_REMOVE_ON_ARENA_ENTER => 'Beim Betreten der Arena entfernt', + SPELL_ATTR5_USABLE_WHILE_STUNNED => 'Verwendbar, während Ihr betäubt seid', + SPELL_ATTR5_TRIGGERS_CHANNELING => 'Löst Kanalisieren aus', + SPELL_ATTR5_SINGLE_TARGET_SPELL => 'Die Aura wirkt auf nur ein Ziel', + SPELL_ATTR5_IGNORE_AREA_EFFECT_PVP_CHECK => 'Ignoriere PvP-Check für Gebietseffekt', + SPELL_ATTR5_NOT_ON_PLAYER => 'Nicht auf Spielern', + SPELL_ATTR5_CANT_TARGET_PLAYER_CONTROLLED => 'Nicht auf von Spielern gesteuertem NSC', + SPELL_ATTR5_START_PERIODIC_AT_APPLY => 'Beginnt zu ticken, sobald die Aura angewendet wird', + SPELL_ATTR5_HIDE_DURATION => 'Dauer nicht anzeigen', + SPELL_ATTR5_ALLOW_TARGET_OF_TARGET_AS_TARGET => 'Implizierte Zielfindung', + SPELL_ATTR5_MELEE_CHAIN_TARGETING => 'Kettenzielsetzung für Nahkampf', + SPELL_ATTR5_HASTE_AFFECT_DURATION => 'Zaubertempo beeinflusst Intervall', + SPELL_ATTR5_NOT_USABLE_WHILE_CHARMED => 'Nicht benutzbar wenn bezaubert', + SPELL_ATTR5_TREAT_AS_AREA_EFFECT => 'Als Gebietseffekt behandeln', + SPELL_ATTR5_AURA_AFFECTS_NOT_JUST_REQ_EQUIPPED_ITEM => 'Aura betrifft nicht nur benötigten angelegten Gegenstand', + SPELL_ATTR5_USABLE_WHILE_FEARED => 'Verwendbar, während Ihr verängstigt seid', + SPELL_ATTR5_USABLE_WHILE_CONFUSED => 'Verwendbar, während Ihr verwirrt seid', + SPELL_ATTR5_DONT_TURN_DURING_CAST => 'KI ist Ziel nicht zugewandt', + SPELL_ATTR5_DO_NOT_ATTEMPT_A_PET_RESUMMON_WHEN_DISMOUNTING => 'Herbeirufen eines Begleiters beim Absteigen nicht versuchen', + SPELL_ATTR5_IGNORE_TARGET_REQUIREMENTS => 'Ignoriere Zielvoraussetzungen', + SPELL_ATTR5_NOT_ON_TRIVIAL => 'Nicht auf trivialen Zielen', + SPELL_ATTR5_NO_PARTIAL_RESISTS => 'Kein teilweises Widerstehen', + SPELL_ATTR5_IGNORE_CASTER_REQUIREMENTS => 'Ignoriere Zauberer-Voraussetzungen', + SPELL_ATTR5_ALWAYS_LINE_OF_SIGHT => 'Immer in Sichtlinie', + SPELL_ATTR5_SKIP_CHECKCAST_LOS_CHECK => 'AoE immer in Sichtlinie', + SPELL_ATTR5_DONT_SHOW_AURA_IF_SELF_CAST => 'Kein Aura-Symbol beim Zauberer', + SPELL_ATTR5_DONT_SHOW_AURA_IF_NOT_SELF_CAST => 'Kein Aura-Symbol beim Ziel', + SPELL_ATTR5_AURA_UNIQUE_PER_CASTER => 'Aura je Wirker einzigartig', + SPELL_ATTR5_ALWAYS_SHOW_GROUND_TEXTURE => 'Immer Bodentexturen zeigen', + SPELL_ATTR5_ADD_MELEE_HIT_RATING => 'Nahkampftrefferwertung hinzufügen' + ), + 'attributes6' => array( + SPELL_ATTR6_DONT_DISPLAY_COOLDOWN => 'Keine Abklingzeit im Tooltip', + SPELL_ATTR6_ONLY_IN_ARENA => 'Nur in der Arena benutzbar', + SPELL_ATTR6_IGNORE_CASTER_AURAS => 'Ignoriere Auren auf Zauberer', + SPELL_ATTR6_ASSIST_IGNORE_IMMUNE_FLAG => 'Kann immunem Spieler assistieren', + SPELL_ATTR6_IGNORE_FOR_MOD_TIME_RATE => 'Für Mod Zeitrate ignorieren', + SPELL_ATTR6_DONT_CONSUME_PROC_CHARGES => 'Keine Ressourcen aufbrauchen', + SPELL_ATTR6_USE_SPELL_CAST_EVENT => 'Sende \'spell cast\' Ereignis', + SPELL_ATTR6_AURA_IS_WEAPON_PROC => 'Aura ist Waffen-Proc', + SPELL_ATTR6_CANT_TARGET_CROWD_CONTROLLED => 'Springt nicht auf Ziele unter Gruppenkontrolle über', + SPELL_ATTR6_ALLOW_ON_CHARMED_TARGETS => 'Zulässig für bezauberte Ziele', + SPELL_ATTR6_CAN_TARGET_POSSESSED_FRIENDS => 'Aura nicht im Log', + SPELL_ATTR6_NOT_IN_RAID_INSTANCE => 'Kann nicht im Schlachtzug verwendet werden', + SPELL_ATTR6_CASTABLE_WHILE_ON_VEHICLE => 'Zulässig beim führen eines Fahrzeugs', + SPELL_ATTR6_CAN_TARGET_INVISIBLE => 'Ignoriere Phasenwechsel', + SPELL_ATTR6_AI_PRIMARY_RANGED_ATTACK => 'Primärer Fernkampfangriff für KI', + SPELL_ATTR6_NO_PUSHBACK => 'Keine Zauberzeiterhöhung durch Schaden', + SPELL_ATTR6_NO_JUMP_PATHING => 'Keine Wegfindung für Sprung', + SPELL_ATTR6_ALLOW_EQUIP_WHILE_CASTING => 'Erlaube Anlegen beim Zaubern', + SPELL_ATTR6_CAST_BY_CHARMER => 'Vom Steuernden ausgehend', + SPELL_ATTR6_DELAY_COMBAT_TIMER_DURING_CAST => 'Verzögere Kampftimer während Zauber', + SPELL_ATTR6_ONLY_VISIBLE_TO_CASTER => 'Aura-Symbol nur für Zauberer sichtbar (Max 10)', + SPELL_ATTR6_CLIENT_UI_TARGET_EFFECTS => '', + SPELL_ATTR6_ABSORB_CANNOT_BE_IGNORE => 'Absorbtion kann nicht ignoriert werden', + SPELL_ATTR6_TAPS_IMMEDIATELY => 'Tappt sofort', + SPELL_ATTR6_CAN_TARGET_UNTARGETABLE => 'Kann nicht-Anvisierbares anvisieren', + SPELL_ATTR6_NOT_RESET_SWING_IF_INSTANT => 'Schlagtimer bei Spontanzauber nicht zurücksetzen', + SPELL_ATTR6_VEHICLE_IMMUNITY_CATEGORY => 'Fahrzeugimmunitätenkategorie', + SPELL_ATTR6_LIMIT_PCT_HEALING_MODS => 'Ignoriere Heilungsmodifikatoren', + SPELL_ATTR6_DO_NOT_AUTO_SELECT_TARGET_WITH_INITIATES_COMBAT => 'Wählt nicht automatisch Ziele, wenn es Kampf initiieren würde', + SPELL_ATTR6_LIMIT_PCT_DAMAGE_MODS => 'Ignoriere Schadensmodifikator für Zauberer', + SPELL_ATTR6_DISABLE_TIED_EFFECT_POINTS => 'Gebundene Effektpunkte deaktivieren', // Tie: "Gebunden"? "Gleichstand"? + SPELL_ATTR6_IGNORE_CATEGORY_COOLDOWN_MODS => 'Ignoriere Modifikatoren für Kategorie-Abklingzeit' + ), + 'attributes7' => array( + SPELL_ATTR7_ALLOW_SPELL_REFLECTION => '', + SPELL_ATTR7_IGNORE_DURATION_MODS => 'Kein Zieldauer-Modifikator', + SPELL_ATTR7_DISABLE_AURA_WHILE_DEAD => 'Paladin Aura', + SPELL_ATTR7_IS_CHEAT_SPELL => 'Debug Zauber', + SPELL_ATTR7_TREAT_AS_RAID_BUFF => 'Als Schlachtzugs-Stärkungszauber behandeln', + SPELL_ATTR7_SUMMON_PLAYER_TOTEM => 'Totem', + SPELL_ATTR7_NO_PUSHBACK_ON_DAMAGE => 'Verursacht keine Zauberzeitverlängerung', + SPELL_ATTR7_PREPARE_FOR_VEHICLE_CONTROL_END => 'Für Ende der Fahrzeugsteuerung vorbereiten', + SPELL_ATTR7_HORDE_ONLY => 'Horde-spezifischer Zauber', + SPELL_ATTR7_ALLIANCE_ONLY => 'Allianz-spezifischer Zauber', + SPELL_ATTR7_DISPEL_CHARGES => 'Magiebannung entfernt Stapel', + SPELL_ATTR7_INTERRUPT_ONLY_NONPLAYER => 'Kann Unterbrechen verursachen', + SPELL_ATTR7_CAN_CAUSE_SILENCE => 'Kann Stille verursachen', + SPELL_ATTR7_NO_UI_NOT_INTERRUPTIBLE => 'Nicht unterbrechbar bei fehlendem UI', + SPELL_ATTR7_RECAST_ON_RESUMMON => 'Neuwirken bei Wiederbeschwörung', + SPELL_ATTR7_RESET_SWING_TIMER_AT_SPELL_START => 'Schwungtimer bei Zauberbeginn zurücksetzen', + SPELL_ATTR7_CAN_RESTORE_SECONDARY_POWER => 'Kann inaktive Ressourcen wiederherstellen', + SPELL_ATTR7_DO_NOT_LOG_PVP_KILL => 'PvP-Todesstoß nicht loggen', + SPELL_ATTR7_HAS_CHARGE_EFFECT => 'Attacke bei Sturmangriff auf Einheit', + SPELL_ATTR7_ZONE_TELEPORT => 'Zauberfehlschlag an Einheitsziel melden', + SPELL_ATTR7_NO_CLIENT_FAIL_WHILE_STUNNED_FLEEING_CONFUSED => 'Kein Abbruch durch Client während Betäubung, Flucht, Verwirrung', + SPELL_ATTR7_RETAIN_COOLDOWN_THROUGH_LOAD => 'Abklingzeit beim Laden beibehalten', + SPELL_ATTR7_IGNORE_COLD_WEATHER_FLYING => 'Ignoriere Voraussetzung für Kaltwetterflug', + SPELL_ATTR7_CANT_DODGE => 'Angriff nicht ausweichbar', + SPELL_ATTR7_CANT_PARRY => 'Angriff nicht parrierbar', + SPELL_ATTR7_CANT_MISS => 'Angriff nicht verfehlbar', + SPELL_ATTR7_TREAT_AS_NPC_AOE => 'Als NSC-Gebietseffekt behandeln', + SPELL_ATTR7_BYPASS_NO_RESURRECT_AURA => 'Umgehe Auren mit \'Verhindere Wiederbelebung\'', + SPELL_ATTR7_CONSOLIDATED_RAID_BUFF => 'Wird mit anderen Stärkungszaubern zusammengefasst', + SPELL_ATTR7_REFLECTION_ONLY_DEFENDS => 'Reflektion beschützt nur', + SPELL_ATTR7_CAN_PROC_FROM_SUPPRESSED_TARGET_PROCS => 'Kann von unterdrückten Ziel-Procs proccen', + SPELL_ATTR7_CLIENT_INDICATOR => 'Zauber immer loggen', ) ), 'item' => array( + 'id' => "Gegenstands-ID: ", 'notFound' => "Dieser Gegenstand existiert nicht .", 'armor' => "%s Rüstung", 'block' => "%s Blocken", 'charges' => "%d |4Aufladung:Aufladungen;", 'locked' => "Verschlossen", - 'ratingString' => "%s @ L%s", + 'ratingString' => '%2$s @ Lvl%3$d', 'heroic' => "Heroisch", 'startQuest' => "Dieser Gegenstand startet eine Quest", 'bagSlotString' => '%1$d Platz %2$s', @@ -1752,7 +2239,7 @@ $lang = array( 'refundable' => "Rückzahlbar", 'noNeedRoll' => "Kann nicht für Bedarf werfen", 'atKeyring' => "Passt in den Schlüsselbund", - 'worth' => "Wert", + 'worth' => "Wert: ", 'consumable' => "Verbrauchbar", 'nonConsumable' => "Nicht verbrauchbar", 'accountWide' => "Accountweit", @@ -1761,17 +2248,17 @@ $lang = array( 'prospectable' => "Sondierbar", 'disenchantable'=> "Kann entzaubert werden", 'cantDisenchant'=> "Kann nicht entzaubert werden", - 'repairCost' => "Reparaturkosten", - 'tool' => "Werkzeug", + 'repairCost' => "Reparaturkosten: ", + 'tool' => "Werkzeug: ", 'cost' => "Preis", 'content' => "Inhalt", - '_transfer' => 'Dieser Gegenstand wird mit %s vertauscht, wenn Ihr zur %s wechselt.', + '_transfer' => 'Dieser Gegenstand wird mit %s vertauscht, wenn Ihr zur %s wechselt.', '_unavailable' => "Dieser Gegenstand ist nicht für Spieler verfügbar.", '_rndEnchants' => "Zufällige Verzauberungen", '_chance' => "(Chance von %s%%)", - 'slot' => "Platz", - '_quality' => "Qualität", - 'usableBy' => "Benutzbar von", + 'slot' => "Platz: ", + '_quality' => "Qualität: ", + 'usableBy' => "Benutzbar von: ", 'buyout' => "Sofortkaufpreis", 'each' => "Stück", 'tabOther' => "Anderes", @@ -1781,13 +2268,31 @@ $lang = array( 'uniqueEquipped'=> ["Einzigartig anlegbar", null, "Einzigartig angelegt: %s (%d)"], 'speed' => "Tempo", 'dps' => "(%.1f Schaden pro Sekunde)", + 'vendorLoc' => "Händlerstandpunkte", + 'purchasedIn' => "Dieser Gegenstand kann gekauft werden in", + 'fishingLoc' => "Angelplätze", + 'fishedIn' => "Dieser Gegenstand kann geangelt werden in", + 'duration' => array( // ITEM_DURATION_* + '', + "Dauer: %d Sek.", + "Dauer: %d Min.", + "Dauer: %d |4Stunde:Stunden;", + "Dauer: %d |4Tag:Tage;" + ), + 'cooldown' => array( // ITEM_COOLDOWN_TOTAL* + '(%s Abklingzeit)', + "(%d Sek. Abklingzeit)", + "(%d Min. Abklingzeit)", + "(%d |4Stunde:Stunden; Abklingzeit)", + "(%d |4Tag:Tage; Abklingzeit)" + ), 'damage' => array( // *DAMAGE_TEMPLATE* // basic, basic /w school, add basic, add basic /w school 'single' => ['%d Schaden', '%1$d %2$sschaden', '+ %1$d Schaden', '+ %1$d %2$sschaden' ], 'range' => ['%1$d - %2$d Schaden', '%1$d - %2$d %3$sschaden', '+ %1$d - %2$d Schaden', '+ %1$d - %2$d %3$sschaden' ], 'ammo' => ["Verursacht %g zusätzlichen Schaden pro Sekunde.", "Verursacht %g zusätzlichen %sschaden pro Sekunde", "+ %g Schaden pro Sekunde", "+ %g %sschaden pro Sekunde"] ), - 'gems' => "Edelsteine", + 'gems' => "Edelsteine: ", 'socketBonus' => "Sockelbonus: %s", 'socket' => array( "Metasockel", "Roter Sockel", "Gelber Sockel", "Blauer Sockel", -1 => "Prismatischer Sockel" @@ -1795,10 +2300,11 @@ $lang = array( 'gemColors' => array( // *_GEM "Meta", "Rot", "Gelb", "Blau" ), + 'gemRequires' => "Benötigt ", // ENCHANT_CONDITION_REQUIRES 'gemConditions' => array( // ENCHANT_CONDITION_* in GlobalStrings.lua; 2 not in use (use as PH) - 2 => "weniger als %d |4Edelstein:Edelsteine; der Kategorie %s", - 3 => "mehr Edelsteine der Kategorie %s als Edelsteine der Kategorie %s", - 5 => "mindestens %d |4Edelstein:Edelsteine; der Kategorie %s" + ENCHANT_CONDITION_LESS_VALUE => "weniger als %d |4Edelstein:Edelsteine; der Kategorie %s", + ENCHANT_CONDITION_MORE_COMPARE => "mehr Edelsteine der Kategorie %s als Edelsteine der Kategorie %s", + ENCHANT_CONDITION_MORE_VALUE => "mindestens %d |4Edelstein:Edelsteine; der Kategorie %s" ), 'reqRating' => array( // ITEM_REQ_ARENA_RATING* "Benötigt eine persönliche Arenawertung und Teamwertung von %d.", @@ -1813,7 +2319,7 @@ $lang = array( "Benutzen: ", "Anlegen: ", "Chance bei Treffer: ", "", "", "", "" ), - 'bonding' => array( + 'bonding' => array( // ITEM_BIND_* "Accountgebunden", "Wird beim Aufheben gebunden", "Wird beim Anlegen gebunden", "Wird bei Benutzung gebunden", "Questgegenstand", "Questgegenstand" ), @@ -1847,7 +2353,7 @@ $lang = array( ), 'elixirType' => [null, "Kampf", "Wächter"], 'cat' => array( - 2 => "Waffen", // self::$spell['weaponSubClass'] + 2 => array("Waffen", []), // filled with self::$spell['weaponSubClass'] on load 4 => array("Rüstung", array( 1 => "Stoffrüstung", 2 => "Lederrüstung", 3 => "Schwere Rüstung", 4 => "Plattenrüstung", 6 => "Schilde", 7 => "Buchbände", 8 => "Götzen", 9 => "Totems", 10 => "Siegel", -6 => "Umhänge", -5 => "Nebenhandgegenstände", -8 => "Hemden", @@ -1890,54 +2396,54 @@ $lang = array( 12 => "Quest", 13 => "Schlüssel", ), - 'statType' => array( - "Mana", - "Gesundheit", + 'statType' => array( // ITEM_MOD_* + '%1$c%2$d Mana', + '%1$c%2$d Gesundheit', null, - "Beweglichkeit", - "Stärke", - "Intelligenz", - "Willenskraft", - "Ausdauer", + '%1$c%2$d Beweglichkeit', + '%1$c%2$d Stärke', + '%1$c%2$d Intelligenz', + '%1$c%2$d Willenskraft', + '%1$c%2$d Ausdauer', null, null, null, null, "Erhöht die Verteidigungswertung um %d.", "Erhöht Eure Ausweichwertung um %d.", "Erhöht Eure Parierwertung um %d.", "Erhöht Eure Blockwertung um %d.", "Erhöht Nahkampftrefferwertung um %d.", - "Erhöht Fernkampftrefferwertung um %d.", + "Erhöht Distanztrefferwertung um %d.", "Erhöht Zaubertrefferwertung um %d.", "Erhöht kritische Nahkampftrefferwertung um %d.", - "Erhöht kritische Fernkampftrefferwertung um %d.", + "Erhöht kritische Distanztrefferwertung um %d.", "Erhöht kritische Zaubertrefferwertung um %d.", - "Erhöht Vermeidungswertung für Nahkampftreffer um +3.", + "Erhöht Vermeidungswertung für Nahkampftreffer um %d.", "Erhöht Vermeidungswertung für Distanztreffer um %d.", "Erhöht Vermeidungswertung für Zaubertreffer um %d.", "Erhöht Vermeidungswertung für kritische Nahkampftreffer um %d.", "Erhöht Vermeidungswertung für kritische Distanztreffer um %d.", "Erhöht Vermeidungswertung für kritische Zaubertreffer um %d.", "Erhöht Nahkampftempowertung um %d.", - "Erhöht Fernkampftempowertung um %d.", + "Erhöht Distanztempowertung um %d.", "Erhöht Zaubertempowertung um %d.", - "Erhöht Eure Trefferwertung um %d.", - "Erhöht Eure kritische Trefferwertung um %d.", + "Erhöht Trefferwertung um %d.", + "Erhöht kritische Trefferwertung um %d.", "Erhöht Vermeidungswertung um %d.", "Erhöht Vermeidungswertung für kritische Treffer um %d.", "Erhöht Eure Abhärtungswertung um %d.", - "Erhöht Eure Tempowertung um %d.", - "Erhöht Waffenkundewertung um %d.", + "Erhöht Tempowertung um %d.", + "Erhöht Eure Waffenkundewertung um %d.", "Erhöht Angriffskraft um %d.", "Erhöht Distanzangriffskraft um %d.", "Erhöht die Angriffskraft in Katzen-, Bären-, Terrorbären- und Mondkingestalt um %d.", - "Erhöht den von Zaubern und Effekten verursachten Schaden um bis zu %d.", "Erhöht die von Zaubern und Effekten verursachte Heilung um bis zu %d.", + "Erhöht den von Zaubern und Effekten verursachten Schaden um bis zu %d.", "Stellt alle 5 Sek. %d Mana wieder her.", - "Erhöht Euren Rüstungsdurchschlagwert um %d.", + "Erhöht Eure Rüstungsdurchschlagwertung um %d.", "Erhöht die Zaubermacht um %d.", "Stellt alle 5 Sek. %d Gesundheit wieder her.", "Erhöht den Zauberdurchschlag um %d.", - "Erhöht Blockwert um %d.", - "Unbekannter Bonus #%d (%d)", + "Erhöht den Blockwert Eures Schilds um %d.", + "Unbekannter Bonus #%d (%d)" ) ) ); diff --git a/localization/locale_enus.php b/localization/locale_enus.php index 2f3bc2b1..6735239a 100644 --- a/localization/locale_enus.php +++ b/localization/locale_enus.php @@ -1,10 +1,11 @@ ["years", "months", "weeks", "days", "hours", "minutes", "seconds", "milliseconds"], 'ab' => ["yr", "mo", "wk", "day", "hr", "min", "sec", "ms"] ), + 'lang' => ['English', null, 'French', 'German', 'Chinese', null, 'Spanish', null, 'Russian'], 'main' => array( 'name' => "name", 'link' => "Link", @@ -23,45 +25,48 @@ $lang = array( 'jsError' => "Please make sure you have javascript enabled.", 'language' => "Language", 'feedback' => "Feedback", - 'numSQL' => "Number of MySQL queries", - 'timeSQL' => "Time of MySQL queries", + 'numSQL' => "Number of SQL queries", + 'timeSQL' => "Time of SQL queries", 'noJScript' => 'This site makes extensive use of JavaScript.
Please enable JavaScript in your browser.', - 'userProfiles' => "My Profiles", + // 'userProfiles' => "My Profiles", 'pageNotFound' => "This %s doesn't exist.", 'gender' => "Gender", 'sex' => [null, "Male", "Female"], 'players' => "Players", + 'thePlayer' => "The Player", 'quickFacts' => "Quick Facts", 'screenshots' => "Screenshots", 'videos' => "Videos", - 'side' => "Side", + 'side' => "Side: ", 'related' => "Related", 'contribute' => "Contribute", - // 'replyingTo' => "The answer to a comment from", + // 'replyingTo' => "The answer to a comment from", 'submit' => "Submit", + 'save' => 'Save', 'cancel' => "Cancel", 'rewards' => "Rewards", 'gains' => "Gains", - 'login' => "Login", + // 'login' => "Login", 'forum' => "Forum", - 'n_a' => "n/a", - 'siteRep' => "Reputation", + 'siteRep' => "Reputation: ", 'yourRepHistory'=> "Your Reputation History", 'aboutUs' => "About us & contact", 'and' => " and ", 'or' => " or ", 'back' => "Back", 'reputationTip' => "Reputation points", - 'byUser' => 'By %1$s ', // mind the \s + 'byUser' => 'By %1$s ', // mind the \s 'help' => "Help", 'status' => "Status", 'yes' => "Yes", 'no' => "No", + 'any' => "Any", + 'all' => "All", // filter 'extSearch' => "Extended search", 'addFilter' => "Add another Filter", - 'match' => "Match", + 'match' => "Match: ", 'allFilter' => "All filters", 'oneFilter' => "At least one", 'applyFilter' => "Apply filter", @@ -69,7 +74,7 @@ $lang = array( 'refineSearch' => 'Tip: Refine your search by browsing a subcategory.', 'clear' => "clear", 'exactMatch' => "Exact match", - '_reqLevel' => "Required level", + '_reqLevel' => "Required level: ", // infobox 'unavailable' => "Not available to players", // alternative wording found: "No longer available to players" ... aw screw it <_< @@ -99,23 +104,23 @@ $lang = array( ), // article & infobox - 'englishOnly' => "This page is only available in English.", + 'langOnly' => "This page is only available in %s.", // calculators - 'preset' => "Preset", + 'preset' => "Preset: ", 'addWeight' => "Add another weight", 'createWS' => "Create a weight scale", 'jcGemsOnly' => "Include JC-only gems", 'cappedHint' => 'Tip: Remove weights for capped statistics such as Hit rating.', - 'groupBy' => "Group By", + 'groupBy' => "Group By: ", 'gb' => array( ["None", "none"], ["Slot", "slot"], ["Level", "level"], ["Source", "source"] ), 'compareTool' => "Item Comparison Tool", 'talentCalc' => "Talent Calculator", 'petCalc' => "Hunter Pet Calculator", - 'chooseClass' => "Choose a class", - 'chooseFamily' => "Choose a pet family", + 'chooseClass' => "Choose a class:", + 'chooseFamily' => "Choose a pet family:", // search 'search' => "Search", @@ -127,8 +132,27 @@ $lang = array( // formating 'colon' => ': ', 'dateFmtShort' => "Y/m/d", - 'dateFmtLong' => "Y/m/d \a\\t H:i A", - 'timeAgo' => "%s ago", + 'dateFmtLong' => "Y/m/d \a\\t g:i A", + 'dateFmtIntl' => "MMMM d, y", + 'nfSeparators' => [',', '.'], + 'n_a' => "n/a", + + // date time + 'date' => "Date", + 'date_colon' => "Date: ", + 'date_on' => "on ", + 'date_ago' => "%s ago", + 'date_at' => " at ", + 'date_to' => " to ", + 'date_simple' => '%2$d/%1$d/%3$d', + 'unknowndate' => "Unknown date", + 'ddaysago' => "%d days ago", + 'today' => "today", + 'yesterday' => "yesterday", + 'noon' => "noon", + 'midnight' => "midnight", + 'am' => "AM", + 'pm' => "PM", // error 'intError' => "An internal error has occurred.", @@ -159,25 +183,23 @@ $lang = array( ) ), 'guide' => array( - 'guide' => "Guide", - 'guides' => "Guides", 'myGuides' => "My Guides", 'editTitle' => "Edit your Guide", 'newTitle' => "Create New Guide", - 'author' => "Author", - 'spec' => "Specialization", + 'author' => "Author: ", + 'spec' => "Specialization: ", 'sticky' => "Sticky Status", - 'views' => "Views", + 'views' => "Views: ", 'patch' => "Patch", - 'added' => "Added", - 'rating' => "Rating", - 'votes' => "[span id=guiderating-value]%d[/span]/5 ([span id=guiderating-votes][n5=%d][/span] votes) [span id=guiderating][/span]", + 'added' => "Added: ", + 'rating' => "Rating: ", + 'votes' => "[span id=guiderating-value]%.2g[/span]/5 ([span id=guiderating-votes][n5=%d][/span] votes) [span id=guiderating][/span]", 'noVotes' => "not enough votes [span id=guiderating][/span]", 'byAuthor' => "By %s", 'notFound' => "This guide doesn't exist.", 'clTitle' => 'Changelog For "%2$s"', - 'clStatusSet' => 'Status set to %s', - 'clCreated' => 'Created', + 'clStatusSet' => 'Status set to %s: ', + 'clCreated' => 'Created: ', 'clMinorEdit' => 'Minor Edit', 'editor' => array( 'fullTitle' => 'Full Title', @@ -185,7 +207,7 @@ $lang = array( 'name' => 'Name', 'nameTip' => 'This should be a simple and clear name of what the guide is, for use in places like menus and guide lists.', 'description' => 'Description', - 'descriptionTip' => 'Description that will be used for search engines.<br><br>If left empty, it will be generated automatically.', + 'descriptionTip' => "Description that will be used for search engines.

If left empty, it will be generated automatically.", // 'commentEmail' => 'Comment Emails', // 'commentEmailTip' => 'Should the author get emailed whenever a user comments on this guide?', 'changelog' => 'Changelog For This Edit', @@ -200,11 +222,11 @@ $lang = array( 'testGuide' => 'See how your guide will look', 'images' => 'Images', 'statusTip' => array( - GUIDE_STATUS_DRAFT => 'Your guide is in "Draft" status and you are the only one able to see it. Keep editing it as long as you like, and when you feel it's ready submit it for review.', - GUIDE_STATUS_REVIEW => 'Your guide is being reviewed.', - GUIDE_STATUS_APPROVED => 'Your guide has been published.', - GUIDE_STATUS_REJECTED => 'Your guide has been rejected. After it\'s shortcomings have been remedied you may resubmit it for review.', - GUIDE_STATUS_ARCHIVED => 'Your guide is outdated and has been archived. Is will no longer be listed and can\'t be edited.', + GuideMgr::STATUS_DRAFT => 'Your guide is in "Draft" status and you are the only one able to see it. Keep editing it as long as you like, and when you feel it's ready submit it for review.', + GuideMgr::STATUS_REVIEW => 'Your guide is being reviewed.', + GuideMgr::STATUS_APPROVED => 'Your guide has been published.', + GuideMgr::STATUS_REJECTED => 'Your guide has been rejected. After it\'s shortcomings have been remedied you may resubmit it for review.', + GuideMgr::STATUS_ARCHIVED => 'Your guide is outdated and has been archived. Is will no longer be listed and can\'t be edited.', ) ), 'category' => array( @@ -229,11 +251,10 @@ $lang = array( 'guildRoster' => "Guild Roster for <%s>", 'arenaRoster' => "Arena Team Roster for <%s>", 'atCaptain' => "Arena Team Captain", - + 'atSize' => "Size: ", 'profiler' => "Character Profiler", - 'arenaTeams' => "Arena Teams", - 'guilds' => "Guilds", - + 'completion' => "Completion: ", + 'attainedBy' => "Attained by %d%% of profiles", 'notFound' => array( 'guild' => "This Guild doesn't exist or is not yet in the database.", 'arenateam' => "This Arena Team doesn't exist or is not yet in the database.", @@ -244,7 +265,8 @@ $lang = array( 'eu' => "Europe", 'kr' => "Korea", 'tw' => "Taiwan", - 'cn' => "China" + 'cn' => "China", + 'dev' => "Development" ), 'encounterNames'=> array( // from dungeonencounter.dbc 243 => "The Seven", @@ -269,93 +291,127 @@ $lang = array( ), 'error' => array( 'unkFormat' => "Unknown image format.", - 'tooSmall' => "Your screenshot is way too small. (< ".CFG_SCREENSHOT_MIN_SIZE."x".CFG_SCREENSHOT_MIN_SIZE.").", + 'tooSmall' => "Your screenshot is way too small. (< CFG_SCREENSHOT_MIN_SIZE x CFG_SCREENSHOT_MIN_SIZE).", 'selectSS' => "Please select the screenshot to upload.", 'notAllowed' => "You are not allowed to upload screenshots!", ) ), + 'video' => array( + 'submission' => "Video Suggestion", + 'thanks' => array( + 'contrib' => "Thanks a lot for your contribution!", + 'goBack' => 'Click here to go back to the page you came from.', + 'note' => "Note: Your video will need to be approved before appearing on the site. This can take up to 72 hours." + ), + 'error' => array( + 'isPrivate' => "The suggested video is private.", + 'noExist' => "No video found at the provided Url.", + 'selectVI' => "Please enter valid video information.", // message_novideo + 'notAllowed' => "You are not allowed to suggest videos!", + ) + ), 'game' => array( - 'achievement' => "achievement", - 'achievements' => "Achievements", - 'areatrigger' => "areatrigger", - 'areatriggers' => "Areatrigger", - 'class' => "class", - 'classes' => "Classes", - 'currency' => "currency", - 'currencies' => "Currencies", - 'difficulty' => "Difficulty", - 'dispelType' => "Dispel type", - 'duration' => "Duration", - 'emote' => "emote", - 'emotes' => "Emotes", - 'enchantment' => "enchantment", - 'enchantments' => "Enchantments", - 'flags' => "Flags", + // type strings + 'npc' => "NPC", + 'npcs' => "NPCs", 'object' => "object", 'objects' => "Objects", - 'glyphType' => "Glyph type", - 'race' => "race", - 'races' => "Races", - 'title' => "title", - 'titles' => "Titles", - 'eventShort' => "Event", - 'event' => "World Event", - 'events' => "World Events", - 'faction' => "faction", - 'factions' => "Factions", - 'cooldown' => "%s cooldown", - 'icon' => "icon", - 'icons' => "icons", 'item' => "item", 'items' => "Items", 'itemset' => "item Set", 'itemsets' => "Item Sets", - 'mail' => "mail", - 'mails' => "Mails", - 'mechanic' => "Mechanic", - 'mechAbbr' => "Mech.", - 'meetingStone' => "Meeting Stone", - 'npc' => "NPC", - 'npcs' => "NPCs", - 'pet' => "Pet", - 'pets' => "Hunter Pets", - 'profile' => "profile", - 'profiles' => "Profiles", 'quest' => "quest", 'quests' => "Quests", + 'spell' => "spell", + 'spells' => "Spells", + 'zone' => "zone", + 'zones' => "Zones", + 'faction' => "faction", + 'factions' => "Factions", + 'pet' => "Pet", + 'pets' => "Hunter Pets", + 'achievement' => "achievement", + 'achievements' => "Achievements", + 'title' => "title", + 'titles' => "Titles", + 'event' => "World Event", + 'events' => "World Events", + 'class' => "class", + 'classes' => "Classes", + 'race' => "race", + 'races' => "Races", + 'skill' => "skill", + 'skills' => "Skills", + 'currency' => "currency", + 'currencies' => "Currencies", + 'sound' => "sound", + 'sounds' => "Sounds", + 'icon' => "icon", + 'icons' => "icons", + 'profile' => "profile", + 'profiles' => "Profiles", + 'guild' => "Guild", + 'guilds' => "Guilds", + 'arenateam' => "Arena Team", + 'arenateams' => "Arena Teams", + 'guide' => "Guide", + 'guides' => "Guides", + 'emote' => "emote", + 'emotes' => "Emotes", + 'enchantment' => "enchantment", + 'enchantments' => "Enchantments", + 'areatrigger' => "areatrigger", + 'areatriggers' => "Areatrigger", + 'mail' => "mail", + 'mails' => "Mails", + + 'cooldown' => "%s cooldown", + 'difficulty' => "Difficulty: ", + 'dispelType' => "Dispel type", + 'duration' => "Duration", + 'eventShort' => "Event: %s", + 'flags' => "Flags", + 'glyphType' => "Glyph type: ", + 'level' => "Level", + 'mechanic' => "Mechanic", + 'mechAbbr' => "Mech.: ", + 'meetingStone' => "Meeting Stone: ", 'requires' => "Requires %s", 'requires2' => "Requires", 'reqLevel' => "Requires Level %s", - 'reqSkillLevel' => "Required skill level", - 'level' => "Level", + 'reqSkillLevel' => "Required skill level: ", 'school' => "School", - 'skill' => "skill", - 'skills' => "Skills", - 'sound' => "sound", - 'sounds' => "Sounds", - 'spell' => "spell", - 'spells' => "Spells", - 'type' => "Type", + 'type' => "Type: ", 'valueDelim' => " to ", - 'zone' => "zone", - 'zones' => "Zones", + 'target' => "", 'pvp' => "PvP", // PVP 'honorPoints' => "Honor Points", // HONOR_POINTS 'arenaPoints' => "Arena Points", // ARENA_POINTS 'heroClass' => "Hero class", - 'resource' => "Resource", - 'resources' => "Resources", - 'role' => "Role", // ROLE - 'roles' => "Roles", // LFG_TOOLTIP_ROLES - 'specs' => "Specs", + 'resource' => "Resource: ", + 'resources' => "Resources: ", + 'role' => "Role: ", // ROLE + 'roles' => "Roles: ", // LFG_TOOLTIP_ROLES + 'specs' => "Specs: ", '_roles' => ["Healer", "Melee DPS", "Ranged DPS", "Tank"], 'phases' => "Phases", - 'mode' => "Mode", - 'modes' => [-1 => "Any", "Normal / Normal 10", "Heroic / Normal 25", "Heroic 10", "Heroic 25"], + 'mode' => "Mode: ", + 'modes' => array( + [-1 => "Any", "Normal / Normal 10", "Heroic / Normal 25", "Heroic 10", "Heroic 25"], + ["Normal", "Heroic"], + ["Normal 10", "Normal 25", "Heroic 10", "Heroic 25"] + ), 'expansions' => ["Classic", "The Burning Crusade", "Wrath of the Lich King"], 'stats' => ["Strength", "Agility", "Stamina", "Intellect", "Spirit"], + 'timeAbbrev' => array( //